From 745d6825195803d5f723753e32c2fbc692ea8228 Mon Sep 17 00:00:00 2001 From: Michael Belousov Date: Sat, 24 Oct 2026 16:17:38 -0400 Subject: [PATCH] web: dvui's web.js, patched, and an embeddable Fizzy class in fizzy.js - The web build ships dvui's `web.js` with `web/patches/dvui` applied by zig-patch, in place of fizzy's fork. The patches are dvui commits meant to go upstream (docs/DEPENDENCIES.md). - `index.html`'s script is now `web/fizzy.js`: `new Fizzy(opts).run()` takes the canvas, storage name, persistence, worker mode, form factor, startup plugins/files/demos, extra wasm imports and `onReady`/`onError`, and reads neither the URL nor other page elements. It drives dvui's `Dvui` class itself, so nothing wraps `WebAssembly.instantiateStreaming`. - `index.html` maps its query string to those options. --- app/store/PluginLoader_web.zig | 4 +- app/store/PluginStore.zig | 2 +- build.zig.zon | 6 + build/app.zig | 3 +- build/web.zig | 83 +- core/AGENTS.md | 3 +- docs/DEPENDENCIES.md | 41 +- plugins/markdown/src/md/net_image.zig | 4 +- scripts/compile-shaders.sh | 3 +- sdk/plugin_sdk.zig | 2 +- src/backend/backend_web.zig | 8 +- src/editor/Editor.zig | 2 +- src/editor/Keybinds.zig | 2 +- web/AGENTS.md | 20 +- web/fizzy-worker.js | 40 +- web/fizzy.js | 1242 +++++++++++ web/index.html | 1224 +---------- .../0001-web-highp-float-in-the-shaders.patch | 100 + ...vents-carry-their-own-modifier-state.patch | 112 + ...the-mouse-and-a-cancelled-touch-ends.patch | 185 ++ ...end-only-the-GL-state-a-draw-changes.patch | 665 ++++++ ...arget-keeps-the-target-that-was-boun.patch | 45 + web/patches/dvui/0006-web-textureBlend.patch | 217 ++ .../0007-web-precise-render-targets.patch | 339 +++ ...erStream-a-flush-s-draws-in-one-call.patch | 98 + ...fragment-programs-in-the-draw-stream.patch | 369 ++++ web/web.js | 1813 ----------------- 27 files changed, 3553 insertions(+), 3079 deletions(-) create mode 100644 web/fizzy.js create mode 100644 web/patches/dvui/0001-web-highp-float-in-the-shaders.patch create mode 100644 web/patches/dvui/0002-web-mouse-events-carry-their-own-modifier-state.patch create mode 100644 web/patches/dvui/0003-web-a-pencil-is-the-mouse-and-a-cancelled-touch-ends.patch create mode 100644 web/patches/dvui/0004-web-send-only-the-GL-state-a-draw-changes.patch create mode 100644 web/patches/dvui/0005-web-clearing-a-target-keeps-the-target-that-was-boun.patch create mode 100644 web/patches/dvui/0006-web-textureBlend.patch create mode 100644 web/patches/dvui/0007-web-precise-render-targets.patch create mode 100644 web/patches/dvui/0008-web-wasm_renderStream-a-flush-s-draws-in-one-call.patch create mode 100644 web/patches/dvui/0009-web-custom-fragment-programs-in-the-draw-stream.patch delete mode 100644 web/web.js diff --git a/app/store/PluginLoader_web.zig b/app/store/PluginLoader_web.zig index f81f7ffe3..8b263b637 100644 --- a/app/store/PluginLoader_web.zig +++ b/app/store/PluginLoader_web.zig @@ -1,6 +1,6 @@ //! Runtime plugin loading on the web: a plugin built as a wasm *side module* (see //! `sdk/plugin_sdk.zig`'s `create` for wasm32 and `plans/REVIEW_2026-09.md` §2), fetched and -//! linked into the page by `web/index.html`'s `loadPlugin`, which hands back one function-table +//! linked into the page by `web/fizzy.js`'s `loadPlugin`, which hands back one function-table //! index per entry point. On wasm32 a function pointer *is* a table index, so from there this //! runs the same sequence the desktop loader runs (`PluginLoader.loadAndRegister`): fingerprint, //! versions, id, inject the host's globals, `fizzy_plugin_register`. @@ -62,7 +62,7 @@ pub const PreRegister = struct { arg_c: ?*anyopaque = null, }; -/// Entry points by name → table index, in the order `web/index.html`'s `pluginEntryPoints` +/// Entry points by name → table index, in the order `web/fizzy.js`'s `pluginEntryPoints` /// lists them (the same order as `plugin_sdk.dylib_exports`). pub const WebDynLib = struct { indices: [entry_names.len]u32, diff --git a/app/store/PluginStore.zig b/app/store/PluginStore.zig index afa05f0c4..25c4a1583 100644 --- a/app/store/PluginStore.zig +++ b/app/store/PluginStore.zig @@ -111,7 +111,7 @@ const Job = struct { /// `std.Thread`. The worker writes `err_buf`/`err_len`/`status` back into the `Job`, so the /// `Job` must not be freed while it is alive — and a detached thread gave `freeJob` no way to /// know. Nothing in the native download path sets a network timeout (the web's fetches - /// have one, `web/index.html`'s `fetchPluginBytes`), so a stalled or + /// have one, `web/fizzy.js`'s `fetchPluginBytes`), so a stalled or /// blackholed release host keeps that worker running arbitrarily long: quitting mid-download /// left a live thread writing into freed memory. `Group.cancel` both interrupts the blocked /// syscall (the worker returns `error.Canceled`) *and* awaits the task, so `freeJob` is diff --git a/build.zig.zon b/build.zig.zon index 61e0a3ac4..5132dc046 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -49,6 +49,12 @@ .url = "git+https://codeberg.org/ziglang/translate-c?ref=zig-0.17.x#02ff0c523fb92a38939b04d0145c4a9602a74d56", .hash = "translate_c-2.0.0-Q_BUWltOBwBYABF84EUL208pIFeUIwbBs29FmVBaafN5", }, + // Applies `web/patches/dvui` to dvui for the web build's `web.js`: docs/DEPENDENCIES.md. + // The pin `backend/build.zig.zon` makes for SDL's; keep them the same. + .patch = .{ + .url = "https://github.com/marler8997/zig-patch/archive/c3ad78b305ba03bcd645c0bac004d1719407c6b8.tar.gz", + .hash = "patch-0.0.0-WCKjsxixAADfwXQv4Vugyxo6PFbDFuuyM7ly3OiqZRYR", + }, .dvui_singleton_app = .{ .path = "libs/dvui-singleton-app", }, diff --git a/build/app.zig b/build/app.zig index 814a182ab..82552bb8a 100644 --- a/build/app.zig +++ b/build/app.zig @@ -641,8 +641,7 @@ pub fn construct( , .{cfg.branding.pixel_art})), }); - // `zig build web` produces `zig-out/web/{web.wasm, web.js, index.html, - // NotoSansKR-Regular.ttf}`, + // `zig build web` produces `zig-out/web/{web.wasm, fizzy.js, web.js, index.html, …}`, // deployable as-is to a static host. const w = web.build( b, diff --git a/build/web.zig b/build/web.zig index 01ac9c1ef..542dc447a 100644 --- a/build/web.zig +++ b/build/web.zig @@ -11,11 +11,11 @@ pub const Web = struct { wasm: *std.Build.Step.Compile, /// The whole site, deployable as it is: `files` and `page`. site: std.Build.LazyPath, - /// Everything but the page: the wasm, `web.js`, the worker, the font, the OAuth page, the + /// Everything but the page: the wasm, `fizzy.js`, `web.js`, the worker, the font, the OAuth page, the /// hosted demo tapes under `demos/` (fizzy's own build) and each plugin's pages under /// `plugins//`. files: std.Build.LazyPath, - /// `index.html`, stamped with the wasm's hash and the storage name. + /// `index.html`, stamped with `fizzy.js`'s hash. page: std.Build.LazyPath, /// Fizzy's own build: a native-built plugin graph re-homed to wasm32 and compiled apart from /// the app, for `check-web`. @@ -49,8 +49,8 @@ pub const Framework = struct { pub fn framework(b: *std.Build, optimize: std.lang.OptimizeMode) Framework { const web_target = target(b); - // The web backend is fizzy's own (`src/backend/WebBackend.zig` + `web/web.js`), started as a - // copy of dvui's: dvui is built in its `custom` mode and our backend linked to it, as dvui's + // The web backend is fizzy's own (`src/backend/WebBackend.zig` + dvui's `web.js`, patched: + // `patchedWebJs`), started as a copy of dvui's: dvui is built in its `custom` mode and our backend linked to it, as dvui's // build does for its own (`linkBackend`). Every option is the one dvui's `web` mode sets, so // dvui's shape — and with it the plugin fingerprint — is exactly what it was. const dvui_web_dep = sdk.dvuiDependency(b, .{ @@ -72,7 +72,7 @@ pub fn framework(b: *std.Build, optimize: std.lang.OptimizeMode) Framework { .optimize = optimize, .single_threaded = true, }); - // What the page calls into (`web/web.js`): dvui's web exports. + // What the page calls into (`web.js`): dvui's web exports. web_backend.export_symbol_names = &.{ "dvui_init", "dvui_deinit", @@ -162,7 +162,7 @@ pub fn build( // Extra wasm exports beyond dvui's own (`dvui_init`/`dvui_update`/etc.). The wasm // linker only emits symbols listed here, so `export fn` in Zig isn't enough on its // own — without this line our trackpad pinch entry point would compile cleanly but - // be missing from `instance.exports`, and the JS bootstrap in `web/index.html` + // be missing from `instance.exports`, and the JS bootstrap in `web/fizzy.js` // would never be able to forward pinch deltas into the canvas widget. The image // trio is the other half of that seam: JS rasterizes a remote URL and writes the // pixels back through these. @@ -183,7 +183,7 @@ pub fn build( "FizzyWebOAuthAlloc", "FizzyWebOAuthResult", "FizzyWebOAuthFailed", - // Plugins loaded at runtime (wasm side modules — `web/index.html`'s `loadPlugin`): + // Plugins loaded at runtime (wasm side modules — `web/fizzy.js`'s `loadPlugin`): // the host's side of the handshake, and what a side module links against — the // stack pointer it shares, dvui's C shims and stb, which it imports from `env`. "FizzyWebPluginAlloc", @@ -352,7 +352,7 @@ pub fn build( web_exe.root_module, ); - // Cache-buster: stamps a 64-char hash into the index.html / web.js placeholders so + // Cache-buster: stamps a 64-char hash into the index.html / fizzy.js placeholders so // the browser picks up new wasm builds without manual hard-reloads. Re-implements // upstream DVUI's `addWebExample` machinery so we don't have to invoke its step. const cb = b.addExecutable(.{ @@ -362,16 +362,23 @@ pub fn build( .target = b.graph.host, }), }); - const cb_run = b.addRunArtifact(cb); - cb_run.addFileArg(stampStorage(b)); - cb_run.addFileArg(b.path("web/web.js")); - cb_run.addFileArg(b.path("web/fizzy-worker.js")); - cb_run.addFileArg(web_exe.getEmittedBin()); - const index_html_with_hash = cb_run.captureStdOut(.{}); + const web_js = patchedWebJs(b, dvui_web_dep); + // `fizzy.js` names what it loads by their hash, and the page names `fizzy.js` by its own. + const cb_js = b.addRunArtifact(cb); + cb_js.addFileArg(stampStorage(b)); + cb_js.addFileArg(web_js); + cb_js.addFileArg(b.path("web/fizzy-worker.js")); + cb_js.addFileArg(web_exe.getEmittedBin()); + const fizzy_js = cb_js.captureStdOut(.{}); + const cb_page = b.addRunArtifact(cb); + cb_page.addFileArg(b.path("web/index.html")); + cb_page.addFileArg(fizzy_js); + const index_html_with_hash = cb_page.captureStdOut(.{}); const dir = b.addWriteFiles(); _ = dir.addCopyFile(web_exe.getEmittedBin(), web_exe.out_filename); - _ = dir.addCopyFile(b.path("web/web.js"), "web.js"); + _ = dir.addCopyFile(fizzy_js, "fizzy.js"); + _ = dir.addCopyFile(web_js, "web.js"); // Worker mode (`?worker=1`): the editor in a worker, drawing through an OffscreenCanvas. _ = dir.addCopyFile(b.path("web/fizzy-worker.js"), "fizzy-worker.js"); _ = dir.addCopyFile(dvui_web_dep.path("src/fonts/NotoSansKR-Regular.ttf"), "NotoSansKR-Regular.ttf"); @@ -474,10 +481,34 @@ pub fn install(b: *std.Build, web: Web) void { ).dependOn(&serve_web_cmd.step); } -/// A plugin package's `"plugin"` module as this web build's own: the same root file and the -/// plugin's own imports (manifest options, its dependencies), with `dvui`/`core`/`fizzy_sdk` -/// swapped for the web build's. A module cannot import two frameworks, so it must be a copy. -/// `web/index.html` with the name the page files what it keeps in the browser under stamped in +/// dvui's `web.js` with `web/patches/dvui/*.patch` applied, in name order, by zig-patch: what +/// fizzy's web backend (`src/backend/WebBackend.zig`) needs of it, each patch a dvui commit +/// written to go upstream. How they are made: docs/DEPENDENCIES.md. +fn patchedWebJs(b: *std.Build, dvui_dep: *std.Build.Dependency) std.Build.LazyPath { + const io = b.graph.io; + const patches_dir = "web/patches/dvui"; + b.dependOnDirectoryContents(b.path(patches_dir)); + var names: std.ArrayList([]const u8) = .empty; + var dir = b.root.openDir(io, patches_dir, .{ .iterate = true }) catch |err| + std.debug.panic("fizzy web: open {s}: {t}", .{ patches_dir, err }); + defer dir.close(io); + var it = dir.iterate(); + while (it.next(io) catch |err| std.debug.panic("fizzy web: list {s}: {t}", .{ patches_dir, err })) |entry| { + if (entry.kind != .file or !std.mem.endsWith(u8, entry.name, ".patch")) continue; + names.append(b.allocator, b.dupe(entry.name)) catch @panic("OOM"); + } + std.mem.sort([]const u8, names.items, {}, struct { + fn lessThan(_: void, l: []const u8, r: []const u8) bool { + return std.mem.lessThan(u8, l, r); + } + }.lessThan); + const patches = b.allocator.alloc(std.Build.LazyPath, names.items.len) catch @panic("OOM"); + for (patches, names.items) |*p, name| p.* = b.path(b.pathJoin(&.{ patches_dir, name })); + const patched = b.dependency("patch", .{ .source = dvui_dep.path(""), .patches = patches }).namedLazyPath("patched"); + return patched.path(b, "src/backends/web.js"); +} + +/// `web/fizzy.js` with the name the app files what it keeps in the browser under stamped in /// (`storageName` there): `-Dweb-storage`, or "fizzy". Every copy of the app on one site shares /// the browser's storage for it, so a test build served beside the real one — fizzyed.it/testapp/ /// beside /app/ — is built with a name of its own and keeps out of the real one's settings. @@ -493,17 +524,17 @@ fn stampStorage(b: *std.Build) std.Build.LazyPath { else => std.debug.panic("-Dweb-storage={s}: only a-z, 0-9, '-' and '_'", .{name}), }; const token = "__FIZZY_WEB_STORAGE__"; - b.dependOnFileContents(b.path("web/index.html")); - const html = std.Io.Dir.cwd().readFileAlloc( + b.dependOnFileContents(b.path("web/fizzy.js")); + const js = std.Io.Dir.cwd().readFileAlloc( b.graph.io, - b.root.joinString(b.allocator, "web/index.html") catch @panic("OOM"), + b.root.joinString(b.allocator, "web/fizzy.js") catch @panic("OOM"), b.allocator, .unlimited, ) catch |err| - std.debug.panic("web/index.html: {t}", .{err}); - if (std.mem.find(u8, html, token) == null) std.debug.panic("web/index.html: no {s} to stamp", .{token}); - const stamped = std.mem.replaceOwned(u8, b.allocator, html, token, name) catch @panic("OOM"); - return b.addWriteFiles().add("index.html", stamped); + std.debug.panic("web/fizzy.js: {t}", .{err}); + if (std.mem.find(u8, js, token) == null) std.debug.panic("web/fizzy.js: no {s} to stamp", .{token}); + const stamped = std.mem.replaceOwned(u8, b.allocator, js, token, name) catch @panic("OOM"); + return b.addWriteFiles().add("fizzy.js", stamped); } /// Every tape fizzy hosts, built on the host (`scripts/demos.zig`): the bundled demos from their diff --git a/core/AGENTS.md b/core/AGENTS.md index 9af7fafbb..cd464040a 100644 --- a/core/AGENTS.md +++ b/core/AGENTS.md @@ -38,7 +38,8 @@ paths, fuzzy matching, the LSP client, the profiler. A lifted finger hovers nothing: dvui puts the frame's `.position` event off every window, so a hover look needs no touch check of its own. The native backend gives only fingers on the screen touch buttons (`backend/src/touch_slots.zig`); a trackpad's fingers move the mouse. - The web backend does the same: a stylus touch (an Apple Pencil) is the mouse (`web/web.js`). + The web backend does the same: a stylus touch (an Apple Pencil) is the mouse (dvui's `web.js`, + `web/patches/dvui/0003`). - **A cursor that must win asks with a priority.** `dvui.cursorSet` keeps the first asked in a frame; `dvui.cursorSetPriority` the highest. Splits and a float's corner ask at `.resize`, a carried view at `.drag`, so a hover cursor (a tab's hand) never takes their arrow. The cursor diff --git a/docs/DEPENDENCIES.md b/docs/DEPENDENCIES.md index de8900192..17942b919 100644 --- a/docs/DEPENDENCIES.md +++ b/docs/DEPENDENCIES.md @@ -8,6 +8,7 @@ bump it; keep it current whenever a pin or a patch moves. | Dependency | Upstream | Fizzy's changes | Pinned in | Carries | |---|---|---|---|---| | [`foxnne/dvui-dev`](https://github.com/foxnne/dvui-dev) | [`david-vanderson/dvui`](https://github.com/david-vanderson/dvui) | branch `zig-0.17` (on 0.16, `fizzy-dev`) | `sdk/build.zig.zon` | dvui changes fizzy needs | +| dvui's `web.js` | the dvui `sdk/build.zig.zon` pins | `web/patches/dvui` | `build.zig.zon` (`.patch`) | the web backend's JavaScript, patched by zig-patch for the web build (below) | | SDL | [`libsdl-org/SDL`](https://github.com/libsdl-org/SDL) `release-3.4.16` | `backend/patches/sdl` | `backend/build.zig.zon` (`.sdl_source`) | SDL C patches, applied by [zig-patch](https://github.com/marler8997/zig-patch) (below) | | [`fizzyedit/sdl_zig`](https://github.com/fizzyedit/sdl_zig) | [`Games-By-Mason/sdl_zig`](https://github.com/Games-By-Mason/sdl_zig) | branch `fizzy` | `backend/build.zig.zon` (`.sdl`) | the Zig build of SDL, taking the patched tree | | [`fizzyedit/nightwatch`](https://github.com/fizzyedit/nightwatch) | [`neurocyte/nightwatch`](https://github.com/neurocyte/nightwatch) | branch `fizzy` | `build.zig.zon` | a fix to its macOS FSEvents backend | @@ -175,9 +176,10 @@ pinned); `fizzy-sdk-0.2.30-4` → `44010a3`; `fizzy-sdk-0.2.30-5` → `fizzy-sdk-0.2.30-8` → `3d90fdd` (item 14), what `sdk/build.zig.zon` pins now. Fizzy builds dvui in its `custom` backend mode on every target and links its own backends -(`backend/`, `src/backend/WebBackend.zig` + `web/web.js`), so dvui's `sdl3` and `web` -backends are only built by `-Dnative-backend=sdl3`. A dvui change to those backends is not a -change to fizzy: anything fizzy wants from one is merged into its own by hand. +(`backend/`, `src/backend/WebBackend.zig`), so dvui's `sdl3` and `web` backends are only built +by `-Dnative-backend=sdl3`. A dvui change to those backends is not a change to fizzy: anything +fizzy wants from one is merged into its own by hand. The exception is the web backend's +JavaScript: fizzy's web build ships dvui's own `web.js`, with `web/patches/dvui` (below). Local copies of dvui, kept in fizzy until upstream has them (diff these on every dvui bump): @@ -196,6 +198,39 @@ Fizzy adapts to two upstream behaviours rather than patching them: first frame, whatever width it was given. The markdown preview treats a block holding one as not yet measured (`render_ast.Stats.fresh_text_layouts`). +## dvui's `web.js`: `web/patches/dvui` + +The web build ships `src/backends/web.js` of the dvui `sdk/build.zig.zon` pins, patched at build +time: `build/web.zig` (`patchedWebJs`) hands dvui and `web/patches/dvui/*.patch`, in name order, +to zig-patch and takes `web.js` from the tree it writes. The page and `fizzy-worker.js` import it +and drive its `Dvui` class. Each patch is a commit on dvui, `git format-patch` output whose header +says what it does and why, written to go upstream: where dvui's own `web.zig` has a counterpart, +the patch changes it too, and nothing dvui's own web backend uses is taken away +(`wasm_renderGeometry` stays beside `wasm_renderStream`). + +- `0001` **highp float in the shaders.** mediump is a 16-bit float on Apple Silicon and most + mobile GPUs, too coarse for BlurBackdrop's sub-texel taps. +- `0002` **Mouse events carry their own modifier state** (and `web.zig` reads it), so a lost keyup + no longer leaves a modifier held. +- `0003` **A pencil is the mouse, and a cancelled touch ends** (`core/AGENTS.md`). +- `0004` **Send only the GL state a draw changes**, and a framebuffer per render target. +- `0005` **Clearing a target keeps the target that was bound.** +- `0006` **`textureBlend`** (`dvui.Backend.textureBlend`), with `web.zig`'s. +- `0007` **Precise render targets** (`dvui.Backend.support_precise_targets`), with `web.zig`'s. +- `0008` **`wasm_renderStream`**, a flush's draws in one call: what `WebBackend.flushBatch` sends. +- `0009` **Custom fragment programs in the draw stream** (`core.gfx.programs`). + +The patches are the commits of a dvui checkout's branch, from the pinned commit. To change one, +or to carry them to a new dvui pin: rebase the branch (`git rebase `), fix what +conflicts, and export it again over the old files: + +```sh +git -c diff.noprefix=false -c diff.mnemonicPrefix=false format-patch -U8 --zero-commit \ + --no-signature --no-numbered --src-prefix=a/ --dst-prefix=b/ -o /web/patches/dvui ..HEAD +``` + +A patch that dvui merges comes out empty at the rebase and is dropped. + ## SDL: `backend/patches/sdl` Upstream `release-3.4.16`, patched at build time: `backend/build.zig` (`patchedSdl`) hands the diff --git a/plugins/markdown/src/md/net_image.zig b/plugins/markdown/src/md/net_image.zig index 51be0be33..85ca255e5 100644 --- a/plugins/markdown/src/md/net_image.zig +++ b/plugins/markdown/src/md/net_image.zig @@ -7,7 +7,7 @@ //! //! Native uses a worker thread + `std.http.Client` and stores encoded bytes (stb decodes //! them later). Web has neither threads nor that HTTP client — the browser loads the URL -//! instead (`new Image`, see `web/index.html`). When CORS allows it, the pixels are copied +//! instead (`new Image`, see `web/fizzy.js`). When CORS allows it, the pixels are copied //! into a dvui texture. When it does not — GitHub Actions badges are the usual case — //! the browser still has the image, so the preview positions an `` over the canvas //! rather than drawing the "open" fallback link. Same ownership shape as fizzy's own @@ -75,7 +75,7 @@ var gpa: ?std.mem.Allocator = null; var next_id: u32 = 1; const wasm = if (is_wasm) struct { - /// Starts a browser load for `url`. Wired in `web/index.html` by adding a `fizzy` + /// Starts a browser load for `url`. Wired in `web/fizzy.js` by adding a `fizzy` /// import namespace at instantiate time — dvui's `web.js` only provides `dvui.*`. extern "fizzy" fn fizzy_web_image_request(id: u32, url_ptr: [*]const u8, url_len: usize) void; } else struct {}; diff --git a/scripts/compile-shaders.sh b/scripts/compile-shaders.sh index 7f890b65f..ee520e451 100755 --- a/scripts/compile-shaders.sh +++ b/scripts/compile-shaders.sh @@ -12,7 +12,8 @@ # # The GLSL is complete source, for the web backend's programs (`core.gfx.programs`): its inputs # are named as the web's vertex shader writes them (`vColor`, `vTextureCoord`), its samplers by -# unit (`uSampler` 0, `uTex1` 1), its uniforms `uData`, the names `web/web.js` binds. +# unit (`uSampler` 0, `uTex1` 1), its uniforms `uData`, the names dvui's `web.js` binds +# (`web/patches/dvui/0009`). # # Needs `shadercross` and `spirv-cross` on PATH. Exits non-zero if any shader does not compile. set -euo pipefail diff --git a/sdk/plugin_sdk.zig b/sdk/plugin_sdk.zig index 102399055..287cdc616 100644 --- a/sdk/plugin_sdk.zig +++ b/sdk/plugin_sdk.zig @@ -532,7 +532,7 @@ pub fn create(b: *std.Build, opts: CreateOptions) PluginArtifact { // A wasm *side module* (Emscripten's term): position-independent, no entry, no // threads, loaded into the web host's memory and function table at runtime — the // dylib model with table indices for function pointers. See `plans/REVIEW_2026-09.md` - // §2 and `web/index.html`'s loader. + // §2 and `web/fizzy.js`'s loader. lib.root_module.pic = true; lib.root_module.single_threaded = true; lib.entry = .disabled; diff --git a/src/backend/backend_web.zig b/src/backend/backend_web.zig index 893bd54bb..c8bb7174b 100644 --- a/src/backend/backend_web.zig +++ b/src/backend/backend_web.zig @@ -90,7 +90,7 @@ pub fn setWindowStyle(_: *dvui.Window) void {} /// popup already returns to the page that opened it. pub fn raiseWindow() void {} -/// The page's `keydown` check (`web/index.html`): it writes `KeyboardEvent.key` here, then asks +/// The page's `keydown` check (`web/fizzy.js`): it writes `KeyboardEvent.key` here, then asks /// `FizzyWebKeyBound` whether the app binds it — see `Keybinds.webKeyBound`. export fn FizzyWebKeyBuffer() [*]u8 { return &fizzy.Editor.Keybinds.web_key_buf; @@ -108,7 +108,7 @@ const page = if (builtin.target.cpu.arch == .wasm32) struct { fn fizzy_web_toggle_fullscreen() void {} }; -/// The page's Fullscreen API (`web/index.html`), which also takes the keyboard lock so the +/// The page's Fullscreen API (`web/fizzy.js`), which also takes the keyboard lock so the /// shortcuts a browser keeps for itself (⌘W, ⌘T, Ctrl+Tab) reach the app while full screen. /// Runs a frame after the key press or click that asked for it, which is inside the browser's /// user-activation window. @@ -161,13 +161,13 @@ pub fn setupMacOSMenuBar() void {} pub fn teardownMacOSMenuBar() void {} // Browser trackpad pinch arrives as a `wheel` event with `ctrlKey=true` (synthesized by every -// modern browser). The bootstrap JS in `web/index.html` intercepts those events in the +// modern browser). The bootstrap JS in `web/fizzy.js` intercepts those events in the // capture phase, prevents the browser's default page-zoom, and forwards the magnification // delta into the wasm export below. Same accumulator pattern as the macOS native trackpad // monitor — the canvas widget drains via `takeTrackpadPinchRatio` once per frame. var pending_pinch_ratio: f32 = 1.0; -/// Called from `web/index.html` via `app.instance.exports.FizzyWebTrackpadMagnification` +/// Called from `web/fizzy.js` via `app.instance.exports.FizzyWebTrackpadMagnification` /// for every pinch wheel event. `delta` is a small relative magnification (positive = zoom in) /// derived from `-ev.deltaY` scaled to match macOS NSEvent magnification magnitudes. export fn FizzyWebTrackpadMagnification(delta: f32) void { diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index ac88a0bca..1ec8baeeb 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -1623,7 +1623,7 @@ fn beginWebPluginLoad(editor: *Editor, id: []const u8, url: []const u8, sha256: // Two requests for one id before the first lands (the page's remembered list and a // `?plugin=` of the same id, say) would register it twice; the second one waits for nothing. // The first one's `arrived` reports for both, so the store's spinner still ends: every - // request ends in exactly one `arrived` (the page's fetches have a deadline, `index.html`'s + // request ends in exactly one `arrived` (the page's fetches have a deadline, `fizzy.js`'s // `fetchPluginBytes`, and its `.catch` reports a failure), and `arrived` settles the store's // row by id, so this early return cannot leave a row on "Updating…". if (web_loads_in_flight.contains(id)) return; diff --git a/src/editor/Keybinds.zig b/src/editor/Keybinds.zig index 0677e678f..29db9c310 100644 --- a/src/editor/Keybinds.zig +++ b/src/editor/Keybinds.zig @@ -1532,7 +1532,7 @@ pub var web_key_buf: [16]u8 = undefined; /// editor exists. var web_keys_ready = false; -/// Asked by `web/index.html` from inside a `keydown` listener, before the browser acts on the key: +/// Asked by `web/fizzy.js` from inside a `keydown` listener, before the browser acts on the key: /// true means "this key is the app's", and the page cancels the browser's default (⌘S saving the /// page, ⌘P printing it, ⌘O opening a file of the browser's own). It has to be answered here and /// synchronously — by the time dvui sees the event on the next frame the browser has already diff --git a/web/AGENTS.md b/web/AGENTS.md index 164ceef29..579e85caa 100644 --- a/web/AGENTS.md +++ b/web/AGENTS.md @@ -1,17 +1,27 @@ # web/: fizzy in a browser -The page and its JavaScript (`index.html`, `web.js`, workers). The Zig half is +The page and its JavaScript (`index.html`, the app's `fizzy.js`, workers). The Zig half is `src/backend/WebBackend.zig` and `src/web_main.zig`. `zig build web` builds into `zig-out/web`, and `scripts/serve-web.sh` serves it on :8765. `main` deploys to fizzyed.it/testapp; fizzyed.it/app is the live SDK release (`web.yml`). -- **The web backend is fizzy's,** begun from dvui's `web.zig`/`web.js` and linked through dvui's - `custom` backend with dvui's web options passed explicitly (`build/web.zig`). A dvui bump that - touches dvui's own web backend is merged into these by hand. +- **The web backend is fizzy's,** begun from dvui's `web.zig` and linked through dvui's `custom` + backend with dvui's web options passed explicitly (`build/web.zig`). A dvui bump that touches + dvui's own `web.zig` is merged into it by hand. +- **`fizzy.js` is the app on a page,** `index.html` only the page it runs on. + `new Fizzy(opts).run()` runs the editor on `opts.canvas`, through dvui's `Dvui` (`web.js`), with + the wasm's `fizzy` and `env` imports, plus any of the caller's own (`opts.imports`, which keep it + on the page's thread). `Fizzy` never reads the URL or the page's other elements: `index.html` + turns its query (`?storage=`, `?persist=`, `?worker=`, `?form=`, `?plugin=`, ...) into options + and shows `onReady`/`onError` itself, and an app built on fizzy passes its own. Nothing wraps + `WebAssembly.instantiateStreaming`. +- **`web.js` is dvui's,** with `patches/dvui` applied at build time (`docs/DEPENDENCIES.md`). A + change to it is a change to a patch: make it on the dvui branch the patches come from and export + them again, so it can go upstream. Never keep a copy of `web.js` here. - **One upload per frame where possible.** In Firefox (WebGL runs out of process) `bufferData` costs ~15–20 µs, about 100× any other GL call. The backend records a frame as one stream and flushes only at pixel reads, frame end, or a change to a texture a pending draw uses. Keep it that way; call count barely matters, uploads do. -- **Plugins load at runtime as wasm side modules** (`index.html`'s `loadPlugin` → +- **Plugins load at runtime as wasm side modules** (`fizzy.js`'s `loadPlugin` → `app/store/PluginLoader_web.zig`). To test one: build it with `-Dtarget=wasm32-freestanding -Doptimize=ReleaseSmall`, copy it to `zig-out/web/plugins//.wasm`, and open `/?plugin=&open=`. `?open=` opens a file diff --git a/web/fizzy-worker.js b/web/fizzy-worker.js index 4506106fc..7d9f641a0 100644 --- a/web/fizzy-worker.js +++ b/web/fizzy-worker.js @@ -1,11 +1,11 @@ -// The editor in a worker: dvui's `web.js`, unchanged, driving WebGL on the page's canvas through -// an `OffscreenCanvas`, with the page (`index.html`, worker mode) forwarding input and doing what -// only a page can. +// The editor in a worker: dvui's `web.js` (`patches/dvui`) driving WebGL on the page's canvas +// through an `OffscreenCanvas`, with the page (`index.html`, worker mode) forwarding input and +// doing what only a page can. // // Why: Chrome on Android throttles main-thread frames (`requestAnimationFrame` on the page) to // 60 Hz on a 120 Hz screen unless input is driving them, so every animation — a sidebar // sliding, a dialog forming — ran at half the display's rate. A worker's own frame loop is not -// throttled. Everything here is the page's `index.html` bootstrap, moved: the fizzy imports, the +// throttled. Everything here is the page's bootstrap (`fizzy.js`), moved: the fizzy imports, the // plugin loader. What needs the page is a message to it and back. const utf8 = new TextDecoder(); @@ -23,11 +23,12 @@ const env = { prefs: { dark: false, light: false, reduce: false }, storage: {}, // The page's `storageName`: what this copy of the app files its storage under — the build's - // name, or that and an embed's own (`?storage=`). + // name, or that and an embed's own (`storage`). storageName: "fizzy", // The build's own name, which plugin bytes are kept under whatever the embed's is. buildStorageName: "fizzy", - search: "", + // The page's `startup`: what to load, open and play once the editor is up. + startup: { plugins: [], open: [], show: "", demos: [], tape: "" }, baseURI: "", }; const inputStub = { style: {}, value: "", setAttribute() {}, addEventListener() {}, focus() {}, select() {} }; @@ -89,7 +90,7 @@ function ask(msg, transfer) { }); } -// ---- the fizzy imports (was `index.html`'s `fizzyImports`) ------------------------------------ +// ---- the fizzy imports (as `fizzy.js`'s `fizzyImports`) ----------------------------------------- function mem() { return wasmInstance.exports.memory.buffer; } @@ -274,7 +275,7 @@ const fizzyImports = (target) => ({ }, }); -// ---- plugins at runtime (was `index.html`'s loader; see there for the why) -------------------- +// ---- plugins at runtime (as `fizzy.js`'s loader; see there for the why) ------------------------- const rememberedKey = () => env.storageName + ".web_plugins"; function rememberedPlugins() { try { return JSON.parse(storageGet(rememberedKey()) || "{}"); } catch (_) { return {}; } @@ -348,14 +349,13 @@ function requestPlugin(id, url) { function requestUrlPlugins() { const inst = wasmInstance; if (!inst || !inst.exports.FizzyWebPluginRequest) return; - const params = new URLSearchParams(env.search); - // A `?plugin=` build stands in for a remembered one of the same id. - const devPlugins = params.getAll("plugin"); + // A dev build stands in for a remembered one of the same id. + const devPlugins = env.startup.plugins; for (const [id, url] of Object.entries(rememberedPlugins())) if (!devPlugins.includes(id)) requestPlugin(id, url); for (const id of devPlugins) requestPlugin(id, ""); if (inst.exports.FizzyWebStartupPluginsRequested) inst.exports.FizzyWebStartupPluginsRequested(); render(); - for (const url of params.getAll("open")) { + for (const url of env.startup.open) { fetch(new URL(url, env.baseURI), { cache: "no-store" }).then((r) => r.arrayBuffer()).then((buf) => { const name = utf8encode(url.split("/").pop().split("?")[0]); const namePtr = wasmInstance.exports.FizzyWebPluginAlloc(name.length, 1); @@ -365,8 +365,7 @@ function requestUrlPlugins() { new Uint8Array(mem(), namePtr, name.length).set(name); new Uint8Array(mem(), bytesPtr, bytes.length).set(bytes); wasmInstance.exports.FizzyWebOpenBytes(namePtr, name.length, bytesPtr, bytes.length); - // `&show=`: then open that file from inside the folder just opened. - const show = params.get("show"); + const show = env.startup.show; if (show && wasmInstance.exports.FizzyWebShowInRoot) { const showBytes = utf8encode(show); const showPtr = wasmInstance.exports.FizzyWebPluginAlloc(showBytes.length, 1); @@ -378,13 +377,8 @@ function requestUrlPlugins() { render(); }).catch((err) => console.error("fizzy: could not open", url, err)); } - // `?demo=tour` plays a bundled demo; `?demo=.zon` (or `.tape`, the binary form) fetches a - // tape and plays that. - for (const demo of params.getAll("demo")) playDemo(demo, env.baseURI); - // `?tape=` plays a shared tape: the plugins it needs come from the store, and the page - // shows only the tape. - const tape = params.get("tape"); - if (tape) playTape(tape, env.baseURI); + for (const demo of env.startup.demos) playDemo(demo, env.baseURI); + if (env.startup.tape) playTape(env.startup.tape, env.baseURI); } function playTape(url, base) { @@ -459,7 +453,7 @@ function readDylinkNeeds(module) { return needs; } -// fetchPluginBytes and reportPluginFailed have a matching copy in web/index.html; change both. +// fetchPluginBytes and reportPluginFailed have a matching copy in web/fizzy.js; change both. async function fetchPluginBytes(url) { // Each fetch (the body included) gets a deadline: a request the network swallows would // otherwise leave the store's row on "Updating..." for good, with nothing to say why. @@ -616,7 +610,7 @@ function postBoundKeys() { // ---- start ------------------------------------------------------------------------------- async function start(init) { - Object.assign(env, { size: init.size, prefs: init.prefs, storage: init.storage, storageName: init.storageName, buildStorageName: init.buildStorageName || init.storageName, search: init.search, baseURI: init.baseURI }); + Object.assign(env, { size: init.size, prefs: init.prefs, storage: init.storage, storageName: init.storageName, buildStorageName: init.buildStorageName || init.storageName, startup: init.startup, baseURI: init.baseURI }); adoptCanvas(init.canvas); warmRememberedPlugins(); diff --git a/web/fizzy.js b/web/fizzy.js new file mode 100644 index 000000000..3a6e78b27 --- /dev/null +++ b/web/fizzy.js @@ -0,0 +1,1242 @@ +// fizzy's web app on a page: the editor's wasm, drawn by dvui's `web.js` (`Dvui`) on a canvas of +// the page's, with the `fizzy` and `env` imports it needs from the page, the plugins it loads at +// runtime, and on Chromium the editor in a worker (`fizzy-worker.js`). `index.html` is fizzy's own +// page, which reads its options from its URL; an app built on fizzy runs it on a page of its own +// with `new Fizzy({ canvas, ... }).run()`. +// +// Stamped by the build (`build/web.zig`): the hash of what it loads, and the storage name +// (`-Dweb-storage`). + +import { Dvui } from "./web.js?cache-buster=TEMPLATE_HASH_WITH_PADDING__ITS_64_BYTES_LONG_THE_SAME_AS_SHA256"; + +// The name this build files what it keeps in the browser under: its own files, the plugins it +// remembers and their cached bytes. Every copy of the app served from one site shares the +// browser's storage for it, so a test build beside the real one (fizzyed.it/testapp/ beside +// /app/) is built with a name of its own (`zig build web -Dweb-storage=...`) and never reads or +// overwrites the real one's settings, layout or plugins. Stamped by the build. +const buildStorageName = "__FIZZY_WEB_STORAGE__"; + +/** + * @typedef {object} FizzyOptions + * @property {HTMLCanvasElement} canvas - Where the editor draws and takes its input. + * @property {string} [storage] - A name of this copy's own, as `-Dweb-storage` takes them + * (`[a-z0-9_-]{1,64}`). Its settings, layout, files and installed plugins are filed under it, + * beside the build's, so a copy embedded in a page (fizzyed.it's examples) never reads or + * overwrites the real app's, and two embeds on one page never each other's. + * @property {boolean} [persist] - False: nothing this copy changes outlives the page. Its + * settings, layout, files and installed plugins live in memory only and start from defaults on + * every load, for an embed that should stay as the page set it up (fizzyed.it's homepage). + * Default true: the browser's `localStorage`. + * @property {boolean} [worker] - Run the editor in a worker (true) or on the page's thread (false). + * Default: in a worker on Chromium only. Never in a worker with `imports`. + * @property {"desktop" | "tablet" | "mobile"} [form] - The form factor to take, in place of the + * device's (`core.platform.formFactor()`). Anything else is ignored. + * @property {string[]} [plugins] - Ids of plugins to load from `plugins//.wasm` beside the + * app once it is up, each in place of an installed one of the same id: how a developer tries a + * build. + * @property {string[]} [open] - URLs of files to open once the plugins are asked for, as an upload + * would (a zip becomes the root folder). + * @property {string} [show] - A path inside what `open` opened, to open from it. + * @property {string[]} [demos] - Demos to play: a bundled one by name (`tour`), or a tape's URL + * (`.zon`, or `.tape` for the binary form). + * @property {string} [tape] - The URL of a shared tape to play. The plugins it needs come from the + * store, and the page shows only the tape. + * @property {Record} [imports] - Import namespaces of the app's + * own, beside `dvui`, `fizzy` and `env`. The editor then runs on the page's thread, where they + * can be called. + * @property {(instance: WebAssembly.Instance) => void} [onInstance] - Called with the editor's + * instance once it exists, on the page's thread. + * @property {() => void} [onReady] - Called once the editor is up. + * @property {(message: string) => void} [onError] - Called with what to tell the user when the + * editor cannot start. + */ + +export class Fizzy { + /** @param {FizzyOptions} opts */ + constructor(opts) { + if (!opts || !(opts.canvas instanceof HTMLCanvasElement)) throw new TypeError("fizzy: `canvas` must be a canvas element"); + if (opts.storage !== undefined && !/^[a-z0-9_-]{1,64}$/.test(opts.storage)) { + throw new RangeError(`fizzy: storage name "${opts.storage}" is not [a-z0-9_-]{1,64}`); + } + /** @readonly */ + this.opts = opts; + /** What this copy files its storage under: the build's name, or that and `opts.storage`. */ + this.storageName = opts.storage ? buildStorageName + "~" + opts.storage : buildStorageName; + } + + /** Start the editor. Once per `Fizzy`. */ + run() { + const opts = this.opts; + const storageName = this.storageName; + const persists = opts.persist !== false; + const onReady = opts.onReady ?? (() => {}); + const onError = opts.onError ?? (() => {}); + // What to load, open and play once the editor is up; the worker is sent it as it is. + const startup = { + plugins: opts.plugins ?? [], + open: opts.open ?? [], + show: opts.show ?? "", + demos: opts.demos ?? [], + tape: opts.tape ?? "", + }; + const memoryStore = () => { + const m = new Map(); + return { + getItem: (k) => (m.has(k) ? m.get(k) : null), + setItem: (k, v) => { m.set(k, String(v)); }, + removeItem: (k) => { m.delete(k); }, + key: (i) => [...m.keys()][i] ?? null, + get length() { return m.size; }, + }; + }; + const store = (() => { + if (!persists) return memoryStore(); + // A browser with its site data blocked throws on the bare name. + try { return window.localStorage; } catch (_) { return memoryStore(); } + })(); + // Ask, once a thing worth keeping is kept, for storage the browser will not clear under + // pressure; refused is fine, and a copy that keeps nothing never asks. + const askToPersist = () => { + if (persists && navigator.storage && navigator.storage.persist) navigator.storage.persist().catch(() => {}); + }; + + function hasWebGl() { + const probe = document.createElement("canvas"); + return !!(probe.getContext("webgl2") || probe.getContext("webgl")); + } + + /** Wait until layout has settled so clientWidth/Height are correct. */ + function waitForLayout() { + return new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(resolve)); + }); + } + + // Markdown remote images (and SVG badges) load through the browser: Zig calls + // `fizzy_web_image_request`, and the page writes RGBA back through `FizzyWebImageAlloc` / + // `FizzyWebImageReady` for dvui to draw like any other texture. + const utf8 = new TextDecoder(); + const utf8encode = (s) => new TextEncoder().encode(s); + let wasmInstance = null; + + // The one function table, created here so it can grow: a plugin loaded at runtime (a + // wasm side module, see `loadPlugin`) brings functions of its own, and its function + // pointers are indices into this table. The main module imports it (`--import-table`). + const functionTable = new WebAssembly.Table({ initial: 4096, element: "anyfunc" }); + + // The `fizzy` import namespace. Memory is always the main module's (a plugin shares it); + // the *callbacks* go to whichever instance made the call — a plugin exports its own + // `FizzyWebRequestReady` and friends, with state of its own behind them — so this is a + // factory over the instance to call back into. + function oauthPopup(inst, url, cleanup) { + const done = () => { if (cleanup) cleanup(); }; + const fail = () => { + done(); + inst.exports.FizzyWebOAuthFailed(); + if (dvuiApp) dvuiApp.requestRender(); + }; + let settled = false; + const onMessage = (ev) => { + if (ev.origin !== location.origin || !ev.data || typeof ev.data.fizzyOAuth !== "string") return; + settled = true; + window.removeEventListener("message", onMessage); + done(); + const bytes = utf8encode(ev.data.fizzyOAuth); + const ptr = inst.exports.FizzyWebOAuthAlloc(bytes.length); + if (!ptr) return fail(); + new Uint8Array(wasmInstance.exports.memory.buffer, ptr, bytes.length).set(bytes); + inst.exports.FizzyWebOAuthResult(ptr, bytes.length); + if (dvuiApp) dvuiApp.requestRender(); + }; + window.addEventListener("message", onMessage); + const popup = window.open(url, "fizzy-oauth", "popup,width=520,height=680"); + if (!popup) { + window.removeEventListener("message", onMessage); + return fail(); + } + const watch = setInterval(() => { + if (settled) return clearInterval(watch); + if (popup.closed) { + clearInterval(watch); + window.removeEventListener("message", onMessage); + fail(); + } + }, 500); + } + + const fizzyImports = (target) => ({ + fizzy_web_image_request(id, urlPtr, urlLen) { + const inst = target(); + if (!inst) return; + const url = utf8.decode(new Uint8Array(wasmInstance.exports.memory.buffer, urlPtr, urlLen)); + // Pixels or nothing: an image has to be a texture to sit under a menu, be frosted + // behind a flyout and take the theme's corners. A host that does not let the page + // read its pixels is read through the proxy instead, which adds the header. + loadImagePixels(url).catch(() => loadImagePixels(imageProxy + encodeURIComponent(url))).then((imageData) => { + const bytes = new Uint8Array( + imageData.data.buffer, + imageData.data.byteOffset, + imageData.data.byteLength, + ); + const ptr = inst.exports.FizzyWebImageAlloc(bytes.length); + if (!ptr) { + inst.exports.FizzyWebImageFailed(id); + } else { + new Uint8Array(wasmInstance.exports.memory.buffer, ptr, bytes.length).set(bytes); + inst.exports.FizzyWebImageReady(id, ptr, bytes.length, imageData.width, imageData.height); + } + if (dvuiApp) dvuiApp.requestRender(); + }).catch(() => { + inst.exports.FizzyWebImageFailed(id); + if (dvuiApp) dvuiApp.requestRender(); + }); + }, + // A full request for `core.transport.Web`: any method, headers as `Name: value` + // lines, a body, and the status back with the bytes. Cloud drives speak through + // this; `fizzy_web_fetch` below stays the GET-only path the store uses. + fizzy_web_request(id, methodPtr, methodLen, urlPtr, urlLen, headersPtr, headersLen, bodyPtr, bodyLen) { + const inst = target(); + if (!inst) return; + const mem = wasmInstance.exports.memory.buffer; + const method = utf8.decode(new Uint8Array(mem, methodPtr, methodLen)); + const url = utf8.decode(new Uint8Array(mem, urlPtr, urlLen)); + const headers = new Headers(); + for (const line of utf8.decode(new Uint8Array(mem, headersPtr, headersLen)).split("\n")) { + const at = line.indexOf(": "); + if (at > 0) headers.append(line.slice(0, at), line.slice(at + 2)); + } + // Copied: the wasm buffer may move before fetch reads it. + const body = bodyLen > 0 ? new Uint8Array(mem, bodyPtr, bodyLen).slice() : undefined; + const fail = () => { + inst.exports.FizzyWebRequestFailed(id); + if (dvuiApp) dvuiApp.requestRender(); + }; + fetch(url, { method, headers, body, cache: "no-store" }).then((res) => { + return res.arrayBuffer().then((buf) => { + const bytes = new Uint8Array(buf); + const ptr = inst.exports.FizzyWebRequestAlloc(id, bytes.length); + if (!ptr) return fail(); + if (bytes.length > 0) new Uint8Array(wasmInstance.exports.memory.buffer, ptr, bytes.length).set(bytes); + inst.exports.FizzyWebRequestReady(id, res.status, ptr, bytes.length); + if (dvuiApp) dvuiApp.requestRender(); + }); + }).catch(fail); + }, + // A web OAuth round trip for whichever plugin asks (core/transport/WebOAuth.zig): open + // the provider's URL in a popup and hand back what it redirected to + // oauth-callback.html with. Provider-agnostic on purpose. + // The popup round trip: open `url`, wait for the page it ends on to post its + // location back (`oauth-callback.html`, or a page of the plugin's own that does the + // same), fail if the popup closes first. Bound to the instance that asked, whose + // `FizzyWebOAuth*` exports carry the answer. + fizzy_web_oauth_open(urlPtr, urlLen) { + const inst = target(); + if (!inst) return; + const url = utf8.decode(new Uint8Array(wasmInstance.exports.memory.buffer, urlPtr, urlLen)); + oauthPopup(inst, url); + }, + // The same round trip through a page the plugin *carries* rather than one served + // beside the app: `html` becomes a same-origin blob URL (so its `postMessage` to + // the opener passes the origin check), with `hash` as the fragment the page reads. + // How a plugin loaded at runtime brings its own picker. + fizzy_web_oauth_open_page(htmlPtr, htmlLen, hashPtr, hashLen) { + const inst = target(); + if (!inst) return; + const mem = wasmInstance.exports.memory.buffer; + const html = utf8.decode(new Uint8Array(mem, htmlPtr, htmlLen)); + const hash = utf8.decode(new Uint8Array(mem, hashPtr, hashLen)); + const blobUrl = URL.createObjectURL(new Blob([html], { type: "text/html" })); + oauthPopup(inst, blobUrl + (hash.length ? "#" + hash : ""), () => URL.revokeObjectURL(blobUrl)); + }, + // Load a plugin built for the web (see `loadPlugin` below) and report back through + // `FizzyWebPluginReady` / `FizzyWebPluginFailed` on the host. + fizzy_web_plugin_load(req, idPtr, idLen, urlPtr, urlLen, shaPtr, shaLen) { + const inst = wasmInstance; + if (!inst) return; + const mem = wasmInstance.exports.memory.buffer; + const id = utf8.decode(new Uint8Array(mem, idPtr, idLen)); + const url = utf8.decode(new Uint8Array(mem, urlPtr, urlLen)); + const sha256 = shaLen ? utf8.decode(new Uint8Array(mem, shaPtr, shaLen)) : ""; + loadPlugin(req, url, id, sha256).then(() => { + if (dvuiApp) dvuiApp.requestRender(); + }).catch((err) => { + console.error("fizzy: plugin load failed:", url, err); + reportPluginFailed(inst, req, err); + if (dvuiApp) dvuiApp.requestRender(); + }); + }, + // View › Toggle Full Screen. The request runs a frame after the key press or click + // that asked for it, which is still inside the browser's user-activation window. + fizzy_web_toggle_fullscreen() { + if (document.fullscreenElement) { + document.exitFullscreen().catch(() => {}); + } else if (document.documentElement.requestFullscreen) { + document.documentElement.requestFullscreen().catch((err) => console.warn("fizzy: full screen refused:", err)); + } + }, + // `Clock.real` for the app's `Io` (src/backend/web_io.zig). + fizzy_web_now_real_ms() { + return Date.now(); + }, + // `core.fs` on the web: the app's own small files (settings.zon, recents.zon, + // keybinds.zon) live in localStorage, keyed by the path the app would have used. + fizzy_web_storage_get(keyPtr, keyLen, bufPtr, bufLen) { + const key = storageName + ".file:" + utf8.decode(new Uint8Array(wasmInstance.exports.memory.buffer, keyPtr, keyLen)); + let value = null; + try { value = store.getItem(key); } catch (_) {} + if (value === null) return 0xffffffff; + const bytes = utf8encode(value); + if (bytes.length <= bufLen) new Uint8Array(wasmInstance.exports.memory.buffer, bufPtr, bytes.length).set(bytes); + return bytes.length; + }, + fizzy_web_storage_set(keyPtr, keyLen, valPtr, valLen) { + const mem = wasmInstance.exports.memory.buffer; + const key = storageName + ".file:" + utf8.decode(new Uint8Array(mem, keyPtr, keyLen)); + const value = utf8.decode(new Uint8Array(mem, valPtr, valLen)); + try { store.setItem(key, value); } catch (err) { console.warn("fizzy: could not store", key, err); } + }, + fizzy_web_storage_remove(keyPtr, keyLen) { + const key = storageName + ".file:" + utf8.decode(new Uint8Array(wasmInstance.exports.memory.buffer, keyPtr, keyLen)); + try { store.removeItem(key); } catch (_) {} + }, + // What the page remembers for an id, so the host can load it again after a disable + // without a round trip through the store. Same copy-into-buffer shape as the storage + // shims: returns the length needed, or `max` when there is nothing. + // Which catalog shard this build installs from, for the download proxy's allowlist. + fizzy_web_plugin_fingerprint(fpPtr, fpLen) { + pluginFingerprint = utf8.decode(new Uint8Array(wasmInstance.exports.memory.buffer, fpPtr, fpLen)); + }, + fizzy_web_plugin_remembered_url(idPtr, idLen, bufPtr, bufLen) { + const mem = wasmInstance.exports.memory.buffer; + const id = utf8.decode(new Uint8Array(mem, idPtr, idLen)); + const url = rememberedPlugins()[id]; + if (typeof url !== "string") return 0xffffffff; + const bytes = utf8encode(url); + if (bytes.length <= bufLen) new Uint8Array(wasmInstance.exports.memory.buffer, bufPtr, bytes.length).set(bytes); + return bytes.length; + }, + fizzy_web_plugin_forget(idPtr, idLen) { + forgetPlugin(utf8.decode(new Uint8Array(wasmInstance.exports.memory.buffer, idPtr, idLen))); + }, + // Which build to bring back next visit. Written once a module has registered, so a + // build the host refused is never what the page asks for again. + fizzy_web_plugin_remember(idPtr, idLen, urlPtr, urlLen) { + const mem = wasmInstance.exports.memory.buffer; + rememberPlugin( + utf8.decode(new Uint8Array(mem, idPtr, idLen)), + utf8.decode(new Uint8Array(mem, urlPtr, urlLen)), + ); + }, + fizzy_web_oauth_callback_url(bufPtr, bufLen) { + const inst = target(); + if (!inst) return 0; + // Resolved against the document, not the origin: the app is not always at the + // root — fizzyed.it serves it from /app/ — and an origin-rooted guess names a page + // that is not there, which Google rejects as redirect_uri_mismatch before anything + // else happens. `pageUrl` takes this URL's directory for plugin pages too, so both + // follow wherever the app is deployed. + const bytes = utf8encode(new URL("oauth-callback.html", document.baseURI).href); + if (bytes.length <= bufLen) new Uint8Array(wasmInstance.exports.memory.buffer, bufPtr, bytes.length).set(bytes); + return bytes.length; + }, + // Byte fetch for the store catalog / READMEs / icons — no rasterize, just GET. + fizzy_web_fetch(id, urlPtr, urlLen) { + const inst = target(); + if (!inst) return; + const url = utf8.decode(new Uint8Array(wasmInstance.exports.memory.buffer, urlPtr, urlLen)); + fetch(url, { cache: "no-cache" }).then((res) => { + if (!res.ok) throw new Error("http " + res.status); + return res.arrayBuffer(); + }).then((buf) => { + const bytes = new Uint8Array(buf); + const ptr = inst.exports.FizzyWebFetchAlloc(bytes.length); + if (!ptr) { + inst.exports.FizzyWebFetchFailed(id); + } else { + new Uint8Array(wasmInstance.exports.memory.buffer, ptr, bytes.length).set(bytes); + inst.exports.FizzyWebFetchReady(id, ptr, bytes.length); + } + if (dvuiApp) dvuiApp.requestRender(); + }).catch(() => { + inst.exports.FizzyWebFetchFailed(id); + if (dvuiApp) dvuiApp.requestRender(); + }); + }, + }); + + // Plugins the store installed on an earlier visit, by id → URL. The page's memory, since + // the app has no settings file here; forgotten on uninstall. + const rememberedKey = storageName + ".web_plugins"; + function rememberedPlugins() { + try { return JSON.parse(store.getItem(rememberedKey) || "{}"); } catch (_) { return {}; } + } + function rememberPlugin(id, url) { + try { + const all = rememberedPlugins(); + // An update names a new build: the old one's bytes are no longer anybody's. + if (all[id] && all[id] !== url) dropPluginBytes(all[id]); + all[id] = url; + store.setItem(rememberedKey, JSON.stringify(all)); + } catch (_) {} + keepPluginBytes(url); + } + function forgetPlugin(id) { + try { + const all = rememberedPlugins(); + if (all[id]) dropPluginBytes(all[id]); + delete all[id]; + store.setItem(rememberedKey, JSON.stringify(all)); + } catch (_) {} + } + + // ---- installed plugins' bytes, kept between visits -------------------------------------- + // + // Every visit used to download every installed plugin again — `no-store`, and for a release + // asset (no CORS header) a failed direct fetch and then the proxy — before it could compile + // one. The bytes of a build the host has accepted now live in Cache Storage (localStorage + // holds ~5 MB of strings; a plugin is megabytes of binary), keyed by its URL. A store URL + // names one release and never changes under it, so a hit is simply the bytes: no network, + // and an update is a new URL — `rememberPlugin` drops the old entry, `forgetPlugin` the + // uninstalled one. A dev build served beside the app (`opts.plugins`) is rebuilt at the same + // URL, so it is never kept. + // Under the build's name, not the embed's: a store URL names one release, so its bytes + // serve every embed alike, and a plugin installed in one is not downloaded again for the next. + const pluginCacheName = buildStorageName + "-plugins-v1"; + const pluginCacheAvailable = typeof caches !== "undefined"; + // Verified bytes of builds that have linked but not yet registered, by URL: the host + // remembers a build only once it has registered (see the end of `loadPlugin`), and only + // then are they kept. + const unkeptPluginBytes = new Map(); + function keepablePluginUrl(url) { + if (!pluginCacheAvailable || !url) return false; + try { + const u = new URL(url, document.baseURI); + return (u.protocol === "https:" || u.protocol === "http:") && u.origin !== location.origin; + } catch (_) { + return false; + } + } + async function cachedPluginBytes(url) { + if (!keepablePluginUrl(url)) return null; + try { + const hit = await (await caches.open(pluginCacheName)).match(url); + return hit ? await hit.arrayBuffer() : null; + } catch (_) { + return null; + } + } + function keepPluginBytes(url) { + const bytes = unkeptPluginBytes.get(url); + unkeptPluginBytes.delete(url); + if (!bytes || !keepablePluginUrl(url)) return; + caches.open(pluginCacheName) + .then((c) => c.put(url, new Response(bytes, { headers: { "content-type": "application/wasm" } }))) + .catch((err) => console.warn("fizzy: could not keep plugin", url, err)); + askToPersist(); + } + function dropPluginBytes(url) { + if (!keepablePluginUrl(url)) return; + caches.open(pluginCacheName).then((c) => c.delete(url)).catch(() => {}); + } + // Start reading the remembered builds out of the cache now, while the editor itself is + // still downloading and starting — by the time it asks for them they are in hand. + const warmPluginBytes = new Map(); + for (const url of Object.values(rememberedPlugins())) { + if (keepablePluginUrl(url)) warmPluginBytes.set(url, cachedPluginBytes(url)); + } + function requestPlugin(id, url) { + const inst = wasmInstance; + const idBytes = utf8encode(id); + const urlBytes = utf8encode(url || ""); + const idPtr = inst.exports.FizzyWebPluginAlloc(idBytes.length, 1); + const urlPtr = urlBytes.length ? inst.exports.FizzyWebPluginAlloc(urlBytes.length, 1) : 0; + if (!idPtr || (urlBytes.length && !urlPtr)) return; + new Uint8Array(inst.exports.memory.buffer, idPtr, idBytes.length).set(idBytes); + if (urlBytes.length) new Uint8Array(inst.exports.memory.buffer, urlPtr, urlBytes.length).set(urlBytes); + inst.exports.FizzyWebPluginRequest(idPtr, idBytes.length, urlPtr, urlBytes.length); + } + function requestUrlPlugins() { + const inst = wasmInstance; + if (!inst || !inst.exports.FizzyWebPluginRequest) return; + // A dev build stands in for a remembered one of the same id. + const devPlugins = startup.plugins; + for (const [id, url] of Object.entries(rememberedPlugins())) if (!devPlugins.includes(id)) requestPlugin(id, url); + for (const id of devPlugins) requestPlugin(id, ""); + // The store's update pass waits for this: it can only tell a remembered build that runs + // from one that needs replacing once every one of them has been tried. + if (inst.exports.FizzyWebStartupPluginsRequested) inst.exports.FizzyWebStartupPluginsRequested(); + if (dvuiApp) dvuiApp.requestRender(); + // After the plugins, so the one that owns a file may be among them. + for (const url of startup.open) { + fetch(url, { cache: "no-store" }).then((r) => r.arrayBuffer()).then((buf) => { + const inst2 = wasmInstance; + const name = utf8encode(url.split("/").pop().split("?")[0]); + const namePtr = inst2.exports.FizzyWebPluginAlloc(name.length, 1); + const bytes = new Uint8Array(buf); + const bytesPtr = inst2.exports.FizzyWebPluginAlloc(bytes.length, 1); + if (!namePtr || !bytesPtr) return; + new Uint8Array(inst2.exports.memory.buffer, namePtr, name.length).set(name); + new Uint8Array(inst2.exports.memory.buffer, bytesPtr, bytes.length).set(bytes); + inst2.exports.FizzyWebOpenBytes(namePtr, name.length, bytesPtr, bytes.length); + const show = startup.show; + if (show && inst2.exports.FizzyWebShowInRoot) { + const showBytes = utf8encode(show); + const showPtr = inst2.exports.FizzyWebPluginAlloc(showBytes.length, 1); + if (showPtr) { + new Uint8Array(inst2.exports.memory.buffer, showPtr, showBytes.length).set(showBytes); + inst2.exports.FizzyWebShowInRoot(showPtr, showBytes.length); + } + } + if (dvuiApp) dvuiApp.requestRender(); + }).catch((err) => console.error("fizzy: could not open", url, err)); + } + for (const demo of startup.demos) playDemo(demo); + if (startup.tape) playTape(startup.tape); + } + + function playTape(url) { + fetch(url, { cache: "no-store" }) + .then((r) => (r.ok ? r.arrayBuffer() : Promise.reject(r.status))) + .then((buf) => { + const exports = wasmInstance.exports; + if (!exports.FizzyWebPlayTape) return; + const bytes = new Uint8Array(buf); + const ptr = exports.FizzyWebPluginAlloc(bytes.length, 1); + if (!ptr) return; + new Uint8Array(exports.memory.buffer, ptr, bytes.length).set(bytes); + exports.FizzyWebPlayTape(ptr, bytes.length); + if (dvuiApp) dvuiApp.requestRender(); + }) + .catch((err) => console.error("fizzy: could not fetch the tape", url, err)); + } + + function playDemo(demo) { + const send = (bytes, isTape) => { + const exports = wasmInstance.exports; + if (!exports.FizzyWebPlayDemo) return; + const ptr = exports.FizzyWebPluginAlloc(bytes.length, 1); + if (!ptr) return; + new Uint8Array(exports.memory.buffer, ptr, bytes.length).set(bytes); + exports.FizzyWebPlayDemo(ptr, bytes.length, isTape); + if (dvuiApp) dvuiApp.requestRender(); + }; + if (!/\.(zon|tape)(\?|$)/.test(demo)) return send(utf8encode(demo), false); + fetch(demo, { cache: "no-store" }) + .then((r) => (r.ok ? r.arrayBuffer() : Promise.reject(r.status))) + .then((buf) => send(new Uint8Array(buf), true)) + .catch((err) => console.error("fizzy: could not fetch demo", demo, err)); + } + + // ---- plugins at runtime: wasm side modules ------------------------------------------- + // + // A plugin built for the web is an Emscripten-style side module: position-independent, + // importing the host's memory, function table and stack pointer, with its own data laid + // out at `__memory_base` and its functions at `__table_base`. Loading one is: fetch, + // read its `dylink.0` needs, carve its data out of the host's heap, grow the table, + // instantiate against the host's exports, apply relocations, then hand the host a table + // index for each of the plugin's entry points — the dylib model, with indices for + // function pointers. Everything after that is the same code the desktop runs. + const pluginEntryPoints = [ + "fizzy_plugin_abi_fingerprint", + "fizzy_plugin_sdk_version", + "fizzy_plugin_min_sdk_version", + "fizzy_plugin_version", + "fizzy_plugin_id", + "fizzy_plugin_manifest_zon", + "fizzy_plugin_register", + "fizzy_plugin_set_dvui_context", + "fizzy_plugin_set_render_bridge", + "fizzy_plugin_set_globals", + ]; + const loadedPlugins = new Map(); + + function readDylinkNeeds(module) { + const sections = WebAssembly.Module.customSections(module, "dylink.0"); + if (sections.length === 0) throw new Error("not a side module (no dylink.0 section)"); + const bytes = new Uint8Array(sections[0]); + let pos = 0; + const leb = () => { + let result = 0, shift = 0, b; + do { b = bytes[pos++]; result |= (b & 0x7f) << shift; shift += 7; } while (b & 0x80); + return result >>> 0; + }; + const needs = { memorySize: 0, memoryAlign: 0, tableSize: 0, tableAlign: 0 }; + while (pos < bytes.length) { + const type = bytes[pos++]; + const size = leb(); + const end = pos + size; + if (type === 1) { // WASM_DYLINK_MEM_INFO + needs.memorySize = leb(); + needs.memoryAlign = leb(); + needs.tableSize = leb(); + needs.tableAlign = leb(); + } + pos = end; + } + return needs; + } + + // Where a plugin's bytes come from when its own host will not let a browser have them. + // + // A release asset — the way every plugin publishes — carries no `access-control-allow-origin`, + // so `fetch()` fails on it before a byte moves. That is a browser rule, not a property of + // the file, so it is answered here rather than by making every author publish somewhere + // else: try the URL as given, and on failure ask fizzy's own origin to fetch it. A host + // that does send CORS is read directly and never touches the proxy. + // + // On its own `dl.fizzyed.it` host (a Workers Custom Domain, `web/wrangler.toml`) because the + // zone's other records are DNS-only, so a route on `fizzyed.it/dl/*` would never fire. The + // page checks the sha256 the registry published before it compiles anything, so this is a + // pipe, never something trusted. + const pluginProxy = "https://dl.fizzyed.it/dl/"; + // The same Worker's image route (`dl-worker.js`): an image whose host sends no CORS header + // is fetched through it so its pixels can be read. + const imageProxy = "https://dl.fizzyed.it/img/"; + // Set once at startup by the host (`PluginLoader_web.init`): the ABI fingerprint whose + // catalog shard this build installs from. The proxy checks the URL against that shard. + let pluginFingerprint = ""; + + // fetchPluginBytes and reportPluginFailed have a matching copy in web/fizzy-worker.js; change both. + async function fetchPluginBytes(url) { + // Each fetch (the body included) gets a deadline: a request the network swallows would + // otherwise leave the store's row on "Updating..." for good, with nothing to say why. + const timeoutMs = 30000; + const attempt = async (target) => { + try { + return await fetch(target, { cache: "no-store", signal: AbortSignal.timeout(timeoutMs) }); + } catch (err) { + if (err && err.name === "TimeoutError") throw new Error("timed out after " + timeoutMs / 1000 + " s"); + throw err; + } + }; + // The bytes of a response whose headers arrived: the body is under the same deadline. Worst case before a failure shows is three asks (direct, proxy, proxy again), 90 s. + const body = async (response) => { + try { + return await response.arrayBuffer(); + } catch (err) { + if (err && err.name === "TimeoutError") throw new Error("timed out after " + timeoutMs / 1000 + " s"); + throw err; + } + }; + let directWhy = ""; + try { + const direct = await attempt(url); + if (direct.ok) return await body(direct); + // A real 404 is worth reporting as itself; anything else is worth a second try. + if (direct.status === 404) { console.error("fizzy: plugin not found (404):", url); throw new Error("not found (404)"); } + directWhy = "status " + direct.status; + } catch (err) { + if (String(err).includes("not found (404)")) throw err; + directWhy = String(err && err.message ? err.message : err); + } + if (!pluginFingerprint) throw new Error(directWhy + " (and no catalog shard to ask the proxy)"); + // The proxy is asked twice: one dropped or stalled request should not cost the user the update. + let why = ""; + for (let tries = 0; tries < 2; tries++) { + try { + const viaProxy = await attempt(pluginProxy + pluginFingerprint + "/" + encodeURIComponent(url)); + if (viaProxy.ok) return await body(viaProxy); + why = "proxy said " + viaProxy.status; + // Another ask cannot change a refusal of this URL; only a server-side hiccup is retried. + if (viaProxy.status < 500 && viaProxy.status !== 408 && viaProxy.status !== 429) break; + } catch (err) { + why = String(err && err.message ? err.message : err); + } + } + throw new Error(why); // the cause alone: the store shows it in a narrow row, the URL is in the console + } + + // Tell the host a load failed, and why: the store shows the text beside Retry. The text + // is copied into the host's heap, and the host frees it. + function reportPluginFailed(inst, req, err) { + const msg = utf8encode(String(err && err.message ? err.message : err).slice(0, 300)); + const ptr = msg.length ? inst.exports.FizzyWebPluginAlloc(msg.length, 1) : 0; + if (ptr) new Uint8Array(inst.exports.memory.buffer, ptr, msg.length).set(msg); + inst.exports.FizzyWebPluginFailed(req, ptr, ptr ? msg.length : 0); + } + + // Lowercase hex of the bytes' SHA-256, for comparing against what the registry published. + async function sha256Hex(bytes) { + const digest = await crypto.subtle.digest("SHA-256", bytes); + return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join(""); + } + + async function loadPlugin(req, url, id, sha256) { + const host = wasmInstance; + // A build kept from an earlier visit, if there is one (see "installed plugins' bytes"). + const warm = warmPluginBytes.get(url); + warmPluginBytes.delete(url); + let bytes = await (warm || cachedPluginBytes(url)); + // The check the desktop downloader has always done. Before `compile`, so a tampered or + // truncated module is never handed to the engine, let alone registered. A kept build is + // held to it too when the registry's hash is at hand; one that fails it is dropped and + // downloaded again rather than refused. + if (bytes && sha256 && (await sha256Hex(bytes)) !== sha256.toLowerCase()) { + dropPluginBytes(url); + bytes = null; + } + if (!bytes) { + bytes = await fetchPluginBytes(url); + if (sha256) { + const got = await sha256Hex(bytes); + if (got !== sha256.toLowerCase()) { + { console.error("fizzy: sha256 mismatch for " + id + ": expected " + sha256 + ", got " + got); throw new Error("download did not match the published checksum"); } + } + } + if (keepablePluginUrl(url)) unkeptPluginBytes.set(url, bytes); + } + const module = await WebAssembly.compile(bytes); + const needs = readDylinkNeeds(module); + const memoryBase = host.exports.FizzyWebPluginAlloc(needs.memorySize, 1 << needs.memoryAlign); + if (!memoryBase) throw new Error("out of memory for plugin data"); + const tableBase = functionTable.length; + functionTable.grow(needs.tableSize); + // Every function the host exports is available to the plugin (dvui's C shims, stb); + // the linking globals point the plugin at its own slice of memory and table. + const env = {}; + for (const [name, value] of Object.entries(host.exports)) { + if (typeof value === "function") env[name] = value; + } + env.memory = host.exports.memory; + env.__indirect_function_table = functionTable; + env.__stack_pointer = host.exports.__stack_pointer; + env.__memory_base = new WebAssembly.Global({ value: "i32", mutable: false }, memoryBase); + env.__table_base = new WebAssembly.Global({ value: "i32", mutable: false }, tableBase); + // Say which import the host lacks, rather than the linker's "Import #12". + const missing = WebAssembly.Module.imports(module) + .filter((i) => i.module === "env" && i.kind === "function" && !(i.name in env)) + .map((i) => i.name); + if (missing.length) throw new Error("the host does not export: " + missing.join(", ")); + const holder = { inst: null }; + const instance = await WebAssembly.instantiate(module, { + env, + fizzy: fizzyImports(() => holder.inst), + dvui: dvuiApp ? dvuiApp.imports : {}, + }); + holder.inst = instance; + if (instance.exports.__wasm_apply_data_relocs) instance.exports.__wasm_apply_data_relocs(); + if (instance.exports.__wasm_call_ctors) instance.exports.__wasm_call_ctors(); + // The entry points, as table indices the host can call through. + const base = functionTable.length; + functionTable.grow(pluginEntryPoints.length); + pluginEntryPoints.forEach((name, i) => { + const fn = instance.exports[name]; + if (!fn) throw new Error("plugin is missing " + name); + functionTable.set(base + i, fn); + }); + const list = host.exports.FizzyWebPluginAlloc(4 * pluginEntryPoints.length, 4); + const indices = new Uint32Array(host.exports.memory.buffer, list, pluginEntryPoints.length); + for (let i = 0; i < pluginEntryPoints.length; i++) indices[i] = base + i; + loadedPlugins.set(url, instance); + host.exports.FizzyWebPluginReady(req, list, pluginEntryPoints.length); + // Deliberately not remembered here. Linking a module says nothing about whether this + // host will run it — the fingerprint, SDK version and declared id are checked after + // `FizzyWebPluginReady`, in `PluginLoader_web.loadAndRegister` — and a build remembered + // at this point would be re-fetched and re-refused on every later visit. The host calls + // `fizzy_web_plugin_remember` once the plugin has actually registered. + } + + /** Rasterize `url` the way an `` would — PNG/JPEG and CORS-friendly SVG. */ + function loadImagePixels(url) { + return new Promise((resolve, reject) => { + const img = new Image(); + img.crossOrigin = "anonymous"; + img.onload = () => { + // An SVG with no size of its own (a badge, often) still needs a box to raster into; + // GitHub's are about 200×20. + const nw = img.naturalWidth || 200; + const nh = img.naturalHeight || 20; + // Screenshots in READMEs run to 8+ megapixels and are shown at a fraction of that: + // scale down to the cap rather than refuse, so the pixels handed over stay bounded. + const fit = Math.min(1, Math.sqrt((4 * 1024 * 1024) / (nw * nh))); + const w = Math.max(1, Math.floor(nw * fit)); + const h = Math.max(1, Math.floor(nh * fit)); + const canvas = document.createElement("canvas"); + canvas.width = w; + canvas.height = h; + const ctx = canvas.getContext("2d"); + ctx.drawImage(img, 0, 0, w, h); + try { + resolve(ctx.getImageData(0, 0, w, h)); + } catch (err) { + reject(err); + } + }; + img.onerror = () => reject(new Error("image load failed")); + img.src = url; + }); + } + + // Trackpad pinch arrives as a `wheel` event with `ctrlKey=true` and `deltaMode=0` + // (every modern browser synthesizes this). Two things to do here in the capture phase, + // before dvui's bubble-phase listener on the canvas: + // 1. preventDefault + stopImmediatePropagation — keeps the browser from page-zooming + // and keeps dvui's wheel handler from folding the tiny pinch delta into its + // `scroll_lowest` minimum (an all-time min that never resets and otherwise + // poisons trackpad scroll sensitivity for the rest of the session). + // 2. Forward the magnification into the wasm trackpad accumulator + // (`FizzyWebTrackpadMagnification`). The canvas widget drains it each frame and + // applies the same scale-around-point math used by macOS native pinch. + // `dvuiApp` starts null and is filled in once the app loads — events that arrive + // before then are still safely suppressed. + // Scale factor (100): browser pinch deltaY is roughly ±1..±20 per event; dividing by + // 100 produces ratios around 1.01..1.2 per event, matching the magnitude AppKit + // reports for native trackpad gestures. Negate deltaY because positive deltaY means + // "pinch fingers together = zoom out". + let dvuiApp = null; + const dvuiCanvas = opts.canvas; + + // Shortcuts the app binds are the app's, not the browser's: ⌘S must not also save the + // page. Capture phase, so this runs before dvui's own listener (which still receives the + // key — preventDefault stops the browser, not the event). The app answers synchronously + // from its keymap (`Keybinds.webKeyBound`), which also keeps typing, copy/cut/paste and + // unbound chords with the browser. + window.addEventListener("keydown", (ev) => { + const ex = wasmInstance && wasmInstance.exports; + if (!ex || !ex.FizzyWebKeyBound) return; + const bytes = utf8encode(ev.key); + if (bytes.length === 0 || bytes.length > 16) return; + new Uint8Array(ex.memory.buffer, ex.FizzyWebKeyBuffer(), bytes.length).set(bytes); + const mods = (ev.metaKey << 3) | (ev.altKey << 2) | (ev.ctrlKey << 1) | (ev.shiftKey << 0); + if (ex.FizzyWebKeyBound(bytes.length, mods)) ev.preventDefault(); + }, { capture: true }); + + // Some shortcuts never reach a page at all (⌘W, ⌘T, ⌘N, Ctrl+Tab). The Keyboard Lock API + // hands them over, but only in full screen and only in Chromium; elsewhere this is a no-op + // and those chords stay the browser's. + document.addEventListener("fullscreenchange", () => { + const kb = navigator.keyboard; + if (!kb || !kb.lock) return; + if (document.fullscreenElement) kb.lock().catch(() => {}); + else kb.unlock(); + }); + dvuiCanvas.addEventListener("wheel", (ev) => { + if (ev.ctrlKey && ev.deltaMode === 0) { + ev.preventDefault(); + ev.stopImmediatePropagation(); + if (dvuiApp && dvuiApp.instance.exports.FizzyWebTrackpadMagnification) { + dvuiApp.instance.exports.FizzyWebTrackpadMagnification(-ev.deltaY / 100); + // dvui normally calls `requestRender` from its own wheel listener — since we + // swallowed the event in the capture phase, drive the frame loop ourselves so + // the canvas widget actually drains the accumulator we just wrote. + dvuiApp.requestRender(); + } else if (pinchSink) { + pinchSink(-ev.deltaY / 100); + } + } + }, { capture: true, passive: false }); + + // ---- form factor ---------------------------------------------------------------------- + // + // The device as `core.platform.formFactor()` sees it, for both the worker and the page's + // own loop: a finger with no hover as the primary pointer, and the screen in CSS px (not + // the viewport or canvas, which the soft keyboard and sheets shrink). `opts.form` overrides + // the rule. The extra fields are for the console readout. + const formNames = ["desktop", "tablet", "mobile"]; // the order of `FormFactor` in core/form_factor.zig + const formOverride = formNames.indexOf(opts.form ?? ""); // -1: none + function formMeasure() { + const mq = (q) => window.matchMedia(q).matches; + return { + coarse: mq("(pointer: coarse)") && mq("(hover: none)"), w: screen.width, h: screen.height, + override: formOverride, overrideName: formOverride >= 0 ? opts.form : "", + pointerCoarse: mq("(pointer: coarse)"), hoverHover: mq("(hover: hover)"), + }; + } + // The main-thread loop's twin of the worker's `applyForm`. + let appliedForm = ""; + function applyFormMain() { + const f = formMeasure(); + const exp = dvuiApp && dvuiApp.instance.exports.FizzyWebSetFormFactor; + if (!exp) return; + const key = `${f.coarse}|${f.w}|${f.h}|${f.override}`; + if (key === appliedForm) return; + appliedForm = key; + const chosen = exp(f.coarse, f.w, f.h, f.override); + console.log( + `form-factor: coarse=${f.pointerCoarse} hover=${f.hoverHover} w=${f.w} h=${f.h} ` + + `override=${f.overrideName || "none"} -> ${formNames[chosen]}`, + ); + dvuiApp.requestRender(); + } + + // ---- worker mode: the editor runs in `fizzy-worker.js` -------------------------------- + // + // Chrome on Android throttles a page's own frames to 60 Hz on a 120 Hz screen unless input + // is driving them; a worker's frame loop is not. So the editor, dvui's `web.js` and all, + // runs in a worker drawing to this canvas through an `OffscreenCanvas`, and the page is + // what only a page can be: where input lands (sent on as dvui would have queued it), and + // where the worker's cursor, on-screen keyboard, links, downloads, file pickers, + // clipboard, full screen and OAuth popups happen. + function startWorker(wasmUrl, wasmModule) { + const canvas = dvuiCanvas; + const worker = new Worker(new URL("fizzy-worker.js?cache-buster=TEMPLATE_HASH_WITH_PADDING__ITS_64_BYTES_LONG_THE_SAME_AS_SHA256", import.meta.url), { type: "module" }); + const post = (msg, transfer) => worker.postMessage(msg, transfer || []); + const reply = (request, value, transfer) => post({ type: "reply", request, value }, transfer); + + const media = (q) => window.matchMedia(q).matches; + // `form` is the device, in CSS px, for `core.platform.formFactor()`: a finger with no + // hover as the primary pointer, and the screen (not the viewport or canvas, which the soft keyboard and sheets shrink). + const size = () => ({ + w: canvas.clientWidth, h: canvas.clientHeight, dpr: window.devicePixelRatio, + form: formMeasure(), + }); + const prefs = () => ({ + dark: media("(prefers-color-scheme: dark)"), + light: media("(prefers-color-scheme: light)"), + reduce: media("(prefers-reduced-motion: reduce)"), + }); + const storage = {}; + try { + for (let i = 0; i < store.length; i++) { + const key = store.key(i); + if (key && key.startsWith(storageName + ".")) storage[key] = store.getItem(key); + } + } catch (_) {} + + // dvui's hidden input: where text arrives, and what raises the on-screen keyboard. + const hidden = document.createElement("input"); + hidden.setAttribute("autocapitalize", "none"); + Object.assign(hidden.style, { position: "absolute", left: 0, top: 0, padding: 0, border: 0, margin: 0, opacity: 0, zIndex: -1 }); + document.body.prepend(hidden); + let textInputRect = []; + // Focus never scrolls the page; and a copy embedded in another page (fizzyed.it's + // examples, in an iframe) takes none until it is clicked, tapped or tabbed into. + // Focusing anything inside an iframe scrolls the page holding it to show it, so an + // embed below the fold that grabbed focus as it started pulled the visitor down to it. + let engaged = window.parent === window; + const engage = () => { engaged = true; }; + canvas.addEventListener("pointerdown", engage, { capture: true }); + window.addEventListener("focus", engage); + function focusQuietly(el) { + if (engaged) el.focus({ preventScroll: true }); + } + function oskCheck() { + if (textInputRect.length === 0) { + focusQuietly(canvas); + return; + } + const rect = canvas.getBoundingClientRect(); + const left = window.scrollX + rect.left + textInputRect[0]; + const top = window.scrollY + rect.top + textInputRect[1]; + hidden.style.left = left + "px"; + hidden.style.top = top + "px"; + hidden.style.width = Math.max(0, Math.min(textInputRect[2], canvas.clientWidth - left)) + "px"; + hidden.style.height = Math.max(0, Math.min(textInputRect[2], canvas.clientHeight - top)) + "px"; + focusQuietly(hidden); + } + + let boundKeys = new Set(); + const offscreen = canvas.transferControlToOffscreen(); + // The init waits for the early compile (`wasmModule`), which by now has usually finished. + Promise.resolve(wasmModule).then((module) => post({ + type: "init", + canvas: offscreen, + wasmUrl: new URL(wasmUrl, document.baseURI).href, + wasmModule: module || null, + webJsUrl: new URL("web.js?cache-buster=TEMPLATE_HASH_WITH_PADDING__ITS_64_BYTES_LONG_THE_SAME_AS_SHA256", import.meta.url).href, + size: size(), + prefs: prefs(), + storage, + storageName, + buildStorageName, + startup, + baseURI: document.baseURI, + }, [offscreen])); + + new ResizeObserver(() => post({ type: "size", size: size() })).observe(canvas); + window.addEventListener("resize", () => post({ type: "size", size: size() })); + window.addEventListener("orientationchange", () => post({ type: "size", size: size() })); + for (const q of ["(pointer: coarse)", "(hover: none)"]) { + window.matchMedia(q).addEventListener("change", () => post({ type: "size", size: size() })); + } + for (const q of ["(prefers-color-scheme: dark)", "(prefers-reduced-motion: reduce)"]) { + window.matchMedia(q).addEventListener("change", () => post({ type: "prefs", prefs: prefs() })); + } + + worker.onerror = (err) => { + console.error(err); + onError(`Failed to load the editor (${err.message || "worker error"}).`); + }; + worker.onmessage = (e) => { + const m = e.data; + switch (m.type) { + case "ready": + onReady(); + break; + case "error": + console.error(m.message); + onError(`Failed to load the editor (${m.message}). Try a hard refresh, or use a recent Chrome, Firefox, or Safari.`); + break; + case "alert": + alert(m.msg); + break; + case "cursor": + canvas.style.cursor = m.name; + break; + case "textInput": + textInputRect = m.rect; + oskCheck(); + break; + case "openUrl": + if (m.newWin) window.open(m.url); + else window.location.href = m.url; + break; + case "download": { + const url = URL.createObjectURL(new Blob([m.data], { type: "application/octet-stream" })); + const a = document.createElement("a"); + a.href = url; + a.download = m.name; + a.click(); + a.remove(); + URL.revokeObjectURL(url); + break; + } + case "filePicker": { + const input = document.createElement("input"); + input.type = "file"; + input.accept = m.accept; + if (m.multiple) input.multiple = true; + input.oncancel = () => reply(m.request, []); + input.onchange = () => { + const files = Array.from(input.files || []); + Promise.all(files.map((f) => f.arrayBuffer())).then((datas) => { + const out = files.map((f, i) => ({ name: f.name, size: f.size, data: datas[i] })); + reply(m.request, out, datas); + }); + }; + input.click(); + break; + } + case "clipboard": + if (navigator.clipboard) navigator.clipboard.writeText(m.text); + else { + hidden.value = m.text; + hidden.focus({ preventScroll: true }); + hidden.select(); + document.execCommand("copy"); + hidden.value = ""; + } + break; + case "fullscreen": + if (document.fullscreenElement) document.exitFullscreen().catch(() => {}); + else if (document.documentElement.requestFullscreen) document.documentElement.requestFullscreen().catch((err) => console.warn("fizzy: full screen refused:", err)); + break; + case "oauth": { + let blobUrl = null; + let url = m.url; + if (m.html !== undefined) { + blobUrl = URL.createObjectURL(new Blob([m.html], { type: "text/html" })); + url = blobUrl + (m.hash ? "#" + m.hash : ""); + } + let settled = false; + const finish = (value) => { + if (settled) return; + settled = true; + window.removeEventListener("message", onMessage); + if (blobUrl) URL.revokeObjectURL(blobUrl); + reply(m.request, value); + }; + const onMessage = (ev) => { + if (ev.origin !== location.origin || !ev.data || typeof ev.data.fizzyOAuth !== "string") return; + finish({ text: ev.data.fizzyOAuth }); + }; + window.addEventListener("message", onMessage); + const popup = window.open(url, "fizzy-oauth", "popup,width=520,height=680"); + if (!popup) { + finish(null); + break; + } + const watch = setInterval(() => { + if (settled) return clearInterval(watch); + if (popup.closed) { + clearInterval(watch); + finish(null); + } + }, 500); + break; + } + case "rasterize": + loadImagePixels(m.url).then((img) => { + reply(m.request, { data: img.data, width: img.width, height: img.height }, [img.data.buffer]); + }).catch(() => reply(m.request, null)); + break; + case "storageSet": + try { store.setItem(m.key, m.value); } catch (err) { console.warn("fizzy: could not store", m.key, err); } + break; + case "storageRemove": + try { store.removeItem(m.key); } catch (_) {} + break; + case "persistStorage": + askToPersist(); + break; + case "boundKeys": + boundKeys = new Set(m.keys); + break; + } + }; + + // Input, as dvui's `web.js` would have queued it (its event codes, its wheel heuristics). + const mods = (ev) => (ev.metaKey << 3) | (ev.altKey << 2) | (ev.ctrlKey << 1) | (ev.shiftKey << 0); + const event = (a0, a1, a2, a3, a4) => post({ type: "event", a: [a0, a1, a2, a3, a4] }); + canvas.addEventListener("contextmenu", (ev) => ev.preventDefault()); + canvas.addEventListener("mousemove", (ev) => { + const rect = canvas.getBoundingClientRect(); + post({ type: "mouse", x: (ev.clientX - rect.left) / (rect.right - rect.left), y: (ev.clientY - rect.top) / (rect.bottom - rect.top), mods: mods(ev) }); + }); + canvas.addEventListener("mousedown", (ev) => event(2, ev.button, mods(ev), 0, 0)); + canvas.addEventListener("mouseup", (ev) => event(3, ev.button, mods(ev), 0, 0)); + + const scrollLowest = [99999, 99999]; + let scrollLowestBatch = [99999, 99999]; + let scrollLastMs = Date.now(); + canvas.addEventListener("wheel", (ev) => { + ev.preventDefault(); + if (Date.now() - scrollLastMs > 1000) scrollLowestBatch = [99999, 99999]; + scrollLastMs = Date.now(); + const touchpadAdj = 0.025; + const axis = (i, delta, shiftSizes) => { + if (delta === 0) return; + scrollLowest[i] = Math.min(Math.abs(delta), scrollLowest[i]); + scrollLowestBatch[i] = Math.min(Math.abs(delta), scrollLowestBatch[i]); + let ticks = -delta; + let trackpad = 0; + const low = scrollLowestBatch[i]; + if (ev.deltaMode !== 0) { + ticks /= low; + } else if (low >= 100 || low === 16 || shiftSizes.includes(low) || low === 4.000244140625) { + ticks /= low; + if (low === 4.000244140625) ticks *= touchpadAdj; + } else { + trackpad = 1; + ticks = ticks / scrollLowest[i] * touchpadAdj; + } + event(4, i, trackpad, ticks, 0); + }; + axis(0, ev.deltaX, [9, 40]); + axis(1, ev.deltaY, []); + }, { passive: false }); + + const keydown = (ev) => { + if (ev.key === "Tab") { + if (ev.ctrlKey) return; + ev.preventDefault(); + } + if (boundKeys.has(ev.key + "|" + mods(ev))) ev.preventDefault(); + post({ type: "key", kind: 5, key: ev.key, repeat: ev.repeat ? 1 : 0, mods: mods(ev) }); + }; + const keyup = (ev) => post({ type: "key", kind: 6, key: ev.key, repeat: 0, mods: mods(ev) }); + canvas.addEventListener("keydown", keydown); + hidden.addEventListener("keydown", keydown); + canvas.addEventListener("keyup", keyup); + hidden.addEventListener("keyup", keyup); + hidden.addEventListener("beforeinput", (ev) => { + ev.preventDefault(); + if (ev.data && !ev.isComposing) post({ type: "text", text: ev.data }); + }); + hidden.addEventListener("compositionend", (ev) => { + if (ev.data) post({ type: "text", text: ev.data }); + ev.target.value = ""; + }); + + const touches = []; + const touchIndex = (id) => { + let idx = touches.findIndex((t) => t[0] === id); + if (idx < 0) { + idx = touches.length; + touches.push([id, idx]); + } + return idx; + }; + const touchEvents = (kind, ev, done) => { + ev.preventDefault(); + const rect = canvas.getBoundingClientRect(); + for (const touch of ev.changedTouches) { + const x = (touch.clientX - rect.left) / (rect.right - rect.left); + const y = (touch.clientY - rect.top) / (rect.bottom - rect.top); + const tidx = touchIndex(touch.identifier); + event(kind, touches[tidx][1], 0, x, y); + if (done) touches.splice(tidx, 1); + } + }; + canvas.addEventListener("touchstart", (ev) => touchEvents(8, ev, false), { passive: false }); + canvas.addEventListener("touchend", (ev) => { + touchEvents(9, ev, true); + oskCheck(); + }, { passive: false }); + canvas.addEventListener("touchmove", (ev) => touchEvents(10, ev, false), { passive: false }); + + pinchSink = (amount) => post({ type: "pinch", amount }); + } + + // Where a trackpad pinch goes in worker mode (the capture listener above). + var pinchSink = null; + + // The worker is for Chromium, whose page frames are held to 60 Hz on a phone unless input + // drives them; a worker's are not. Safari drives a worker's frames at about 60 Hz where its + // page's run at the display's 120, so everywhere else the page is the faster place. + // `navigator.userAgentData` is Chromium's alone; `opts.worker` overrides. Never with imports + // of the caller's own: they are the page's functions, which a worker cannot call. + const workerMode = (opts.worker ?? "userAgentData" in navigator) && + !opts.imports && typeof OffscreenCanvas !== "undefined" && "transferControlToOffscreen" in HTMLCanvasElement.prototype; + + if (!hasWebGl()) { + onError("This browser does not support WebGL, which fizzy needs to run."); + } else if (workerMode) { + const wasmUrl = new URL("web.wasm?cache-buster=TEMPLATE_HASH_WITH_PADDING__ITS_64_BYTES_LONG_THE_SAME_AS_SHA256", import.meta.url).href; + // Fetched and compiled now, seen or not. A page opened in a background tab gets no + // animation frames, so `waitForLayout` holds the worker until the tab is shown; the + // editor's download used to wait with it, tens of seconds on a slow link. The worker is + // handed the compiled module and fetches nothing itself, so a page shown at once never + // downloads it twice. Null on failure: the worker then fetches it as before. + const wasmModule = WebAssembly.compileStreaming(fetch(wasmUrl)).catch((err) => { + console.warn("fizzy: compiling the editor early failed; the worker fetches it", err); + return null; + }); + waitForLayout().then(() => startWorker(wasmUrl, wasmModule)).catch((err) => { + console.error(err); + onError(`Failed to load the editor (${err instanceof Error ? err.message : String(err)}).`); + }); + } else { + const wasmUrl = new URL("web.wasm?cache-buster=TEMPLATE_HASH_WITH_PADDING__ITS_64_BYTES_LONG_THE_SAME_AS_SHA256", import.meta.url).href; + waitForLayout() + .then(() => { + const app = new Dvui(); + return WebAssembly.instantiateStreaming(fetch(wasmUrl), { + ...opts.imports, + dvui: app.imports, + fizzy: fizzyImports(() => wasmInstance), + env: { __indirect_function_table: functionTable }, + }).then((result) => { + wasmInstance = result.instance; + if (opts.onInstance) opts.onInstance(result.instance); + app.setInstance(result.instance); + app.setCanvas(dvuiCanvas); + app.run(); + return app; + }); + }) + .then((app) => { + // Late-bind the app reference so the capture-phase wheel listener above can + // forward pinch deltas into the trackpad accumulator and kick a render. + dvuiApp = app; + // The main-thread loop never sent the device to the editor, so Firefox and Safari + // (which take this path) were always `.desktop`: send it now and on change. + applyFormMain(); + for (const ev of ["resize", "orientationchange"]) window.addEventListener(ev, applyFormMain); + for (const q of ["(pointer: coarse)", "(hover: none)"]) { + window.matchMedia(q).addEventListener("change", applyFormMain); + } + window.dispatchEvent(new Event("resize")); + app.requestRender(); + onReady(); + // After the first frame: the editor exists once `dvui_init` has run. + setTimeout(requestUrlPlugins, 0); + }) + .catch((err) => { + console.error(err); + const detail = err instanceof Error ? err.message : String(err); + onError(`Failed to load the editor (${detail}). Try a hard refresh, or use a recent Chrome, Firefox, or Safari.`); + }); + } + } +} diff --git a/web/index.html b/web/index.html index bedd20366..2c850ded2 100644 --- a/web/index.html +++ b/web/index.html @@ -110,1206 +110,44 @@ diff --git a/web/patches/dvui/0001-web-highp-float-in-the-shaders.patch b/web/patches/dvui/0001-web-highp-float-in-the-shaders.patch new file mode 100644 index 000000000..6a6a0dff2 --- /dev/null +++ b/web/patches/dvui/0001-web-highp-float-in-the-shaders.patch @@ -0,0 +1,100 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Michael Belousov +Date: Sat, 24 Oct 2026 16:09:37 -0400 +Subject: [PATCH] web: highp float in the shaders + +On GPUs where mediump is a real 16-bit float (Apple Silicon under ANGLE/Metal, most +mobile), a texture coordinate across a 2048px texture has about 2 texels of resolution, +and BlurBackdrop's kernels sample at sub-texel offsets, so blurs band and shimmer there. +Vertex shaders always have highp; WebGL1 fragment shaders use it where the device has it. +--- + src/backends/web.js | 13 ++++++++++--- + 1 file changed, 10 insertions(+), 3 deletions(-) + +diff --git a/src/backends/web.js b/src/backends/web.js +index 92fbe96..27d22e9 100644 +--- a/src/backends/web.js ++++ b/src/backends/web.js +@@ -41,18 +41,21 @@ async function dvui_open_file_picker(accept, multiple) { + return; + } + res(file_input.files); + }; + file_input.click(); + }); + } + ++// highp, not mediump: on GPUs where mediump is a real 16-bit float (Apple Silicon under ++// ANGLE/Metal, most mobile) a texture coordinate at 2048px wide has ~2 texels of resolution, ++// and dvui's blur kernels are sub-texel offsets. Vertex shaders always have highp. + const vertexShaderSource_webgl = ` +- precision mediump float; ++ precision highp float; + + attribute vec4 aVertexPosition; + attribute vec4 aVertexColor; + attribute vec2 aTextureCoord; + + uniform mat4 uMatrix; + + varying vec4 vColor; +@@ -62,17 +65,17 @@ const vertexShaderSource_webgl = ` + 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; ++ precision highp float; + + in vec4 aVertexPosition; + in vec4 aVertexColor; + in vec2 aTextureCoord; + + uniform mat4 uMatrix; + + out vec4 vColor; +@@ -81,17 +84,21 @@ const vertexShaderSource_webgl2 = `# version 300 es + void main() { + gl_Position = uMatrix * aVertexPosition; + vColor = aVertexColor / 255.0; // normalize u8 colors to 0-1 + vTextureCoord = aTextureCoord; + } + `; + + const fragmentShaderSource_webgl = ` ++#ifdef GL_FRAGMENT_PRECISION_HIGH ++ precision highp float; ++#else + precision mediump float; ++#endif + + varying vec4 vColor; + varying vec2 vTextureCoord; + + uniform sampler2D uSampler; + uniform bool useTex; + + void main() { +@@ -101,17 +108,17 @@ const fragmentShaderSource_webgl = ` + else { + gl_FragColor = vColor; + } + } + `; + + const fragmentShaderSource_webgl2 = `# version 300 es + +- precision mediump float; ++ precision highp float; + + in vec4 vColor; + in vec2 vTextureCoord; + + uniform sampler2D uSampler; + uniform bool useTex; + + out vec4 fragColor; diff --git a/web/patches/dvui/0002-web-mouse-events-carry-their-own-modifier-state.patch b/web/patches/dvui/0002-web-mouse-events-carry-their-own-modifier-state.patch new file mode 100644 index 000000000..2e4f3bad3 --- /dev/null +++ b/web/patches/dvui/0002-web-mouse-events-carry-their-own-modifier-state.patch @@ -0,0 +1,112 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Michael Belousov +Date: Sat, 24 Oct 2026 16:10:14 -0400 +Subject: [PATCH] web: mouse events carry their own modifier state + +A mouse event's modifiers came from the last key event, so a modifier whose keyup was +lost (cmd-tab away, a popup taking focus) stayed held for every click until the next +key event. web.js passes each mouse event's own metaKey/altKey/ctrlKey/shiftKey, and +web.zig sets the window's modifiers from them before adding the event. +--- + src/backends/web.js | 14 +++++++++++--- + src/backends/web.zig | 17 ++++++++++++++--- + 2 files changed, 25 insertions(+), 6 deletions(-) + +diff --git a/src/backends/web.js b/src/backends/web.js +index 27d22e9..3285a64 100644 +--- a/src/backends/web.js ++++ b/src/backends/web.js +@@ -174,16 +174,21 @@ export class Dvui { + }; + }} */ + programInfo; + /** @type {Map} */ + // texture, width, height, interp + textures = new Map(); + newTextureId = 1; + ++ // The modifier bits dvui reads (web_mod_code_to_dvui), from any keyboard or mouse event. ++ modCode(ev) { ++ return (ev.metaKey << 3) + (ev.altKey << 2) + (ev.ctrlKey << 1) + (ev.shiftKey << 0); ++ } ++ + /** @returns {[WebGLTexture, number, 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; +@@ -1376,27 +1381,30 @@ export class Dvui { + } + this.gl.canvas.addEventListener("mousemove", (ev) => { + if (this.stopped) return; + let rect = this.gl.canvas.getBoundingClientRect(); + let x = (ev.clientX - rect.left) / (rect.right - rect.left) * + this.gl.drawingBufferWidth; + let y = (ev.clientY - rect.top) / (rect.bottom - rect.top) * + this.gl.drawingBufferHeight; +- this.instance.exports.add_event(1, 0, 0, x, y); ++ // The event's own modifier state, not the last key event's: a modifier whose keyup ++ // was lost (cmd-tab away, a popup taking focus) would otherwise stick to every ++ // click until the next key event. ++ this.instance.exports.add_event(1, this.modCode(ev), 0, x, y); + this.requestRender(); + }); + this.gl.canvas.addEventListener("mousedown", (ev) => { + if (this.stopped) return; +- this.instance.exports.add_event(2, ev.button, 0, 0, 0); ++ this.instance.exports.add_event(2, ev.button, this.modCode(ev), 0, 0); + this.requestRender(); + }); + this.gl.canvas.addEventListener("mouseup", (ev) => { + if (this.stopped) return; +- this.instance.exports.add_event(3, ev.button, 0, 0, 0); ++ this.instance.exports.add_event(3, ev.button, this.modCode(ev), 0, 0); + this.requestRender(); + }); + this.gl.canvas.addEventListener("wheel", (ev) => { + 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. +diff --git a/src/backends/web.zig b/src/backends/web.zig +index d860ae7..b019009 100644 +--- a/src/backends/web.zig ++++ b/src/backends/web.zig +@@ -293,19 +293,30 @@ fn add_event_raw(w: *dvui.Window, which: u8, int1: u32, int2: u32, float1: f32, + // .int2 = int2, + // .float1 = float1, + // .float2 = float2, + //}) catch |err| { + // const msg = std.fmt.allocPrint(gpa, "{any}", .{err}) catch "allocPrint OOM"; + // wasm.wasm_panic(msg.ptr, msg.len); + //}; + switch (which) { +- 1 => _ = try w.addEventMouseMotion(.{ .pt = .{ .x = float1, .y = float2 } }), +- 2 => _ = try w.addEventMouseButton(buttonFromJS(int1), .press), +- 3 => _ = try w.addEventMouseButton(buttonFromJS(int1), .release), ++ // Mouse events carry the browser's own modifier state (see web.js): the modifiers ++ // dvui remembers from the last key event go stale when a keyup is lost. ++ 1 => { ++ w.modifiers = web_mod_code_to_dvui(@intCast(int1)); ++ _ = try w.addEventMouseMotion(.{ .pt = .{ .x = float1, .y = float2 } }); ++ }, ++ 2 => { ++ w.modifiers = web_mod_code_to_dvui(@intCast(int2)); ++ _ = try w.addEventMouseButton(buttonFromJS(int1), .press); ++ }, ++ 3 => { ++ w.modifiers = web_mod_code_to_dvui(@intCast(int2)); ++ _ = try w.addEventMouseButton(buttonFromJS(int1), .release); ++ }, + 4 => _ = try w.addEventMouseWheel(float1 * dvui.scroll_speed, if (int1 > 0) .vertical else .horizontal, if (int2 == 0) .mouse else .trackpad), + 5 => { + const str = @as([*]u8, @ptrFromInt(int1))[0..int2]; + _ = try w.addEventKey(.{ + .action = if (float1 > 0) .repeat else .down, + .code = web_key_code_to_dvui(str), + .mod = web_mod_code_to_dvui(@trunc(float2)), + }); diff --git a/web/patches/dvui/0003-web-a-pencil-is-the-mouse-and-a-cancelled-touch-ends.patch b/web/patches/dvui/0003-web-a-pencil-is-the-mouse-and-a-cancelled-touch-ends.patch new file mode 100644 index 000000000..6be33fe66 --- /dev/null +++ b/web/patches/dvui/0003-web-a-pencil-is-the-mouse-and-a-cancelled-touch-ends.patch @@ -0,0 +1,185 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Michael Belousov +Date: Sat, 24 Oct 2026 16:10:31 -0400 +Subject: [PATCH] web: a pencil is the mouse, and a cancelled touch ends + +An Apple Pencil on iPad Safari arrives as a touch whose touchType is "stylus". As a +finger it panned and left nothing hovered; it now goes in as the mouse (motion, then +the left button), so dvui counts it as .mouse, as native does with SDL's pen touches. + +A touch the system took (a gesture, an alert) fires touchcancel and no touchend, so +its touch, or the pencil's button, stayed held. touchcancel now ends it the same way. +--- + src/backends/web.js | 61 +++++++++++++++++++++++++++++++++++++++------ + 1 file changed, 54 insertions(+), 7 deletions(-) + +diff --git a/src/backends/web.js b/src/backends/web.js +index 3285a64..368fbfb 100644 +--- a/src/backends/web.js ++++ b/src/backends/web.js +@@ -149,16 +149,29 @@ export function dvui(canvas, wasmRef) { + dvui.run(); + return dvui; + }); + } + + const utf8decoder = new TextDecoder(); + const utf8encoder = new TextEncoder(); + ++/** ++ * What a pencil's touch does to the mouse; the values are `add_event`'s mouse codes. ++ * @enum {number} ++ */ ++const StylusAction = Object.freeze({ ++ /** motion only */ ++ move: 1, ++ /** motion, then the left button pressed */ ++ press: 2, ++ /** motion, then the left button released */ ++ release: 3, ++}); ++ + export class Dvui { + /** @type {WebGL2RenderingContext | WebGLRenderingContext} */ + gl; + /** @type {WebGLBuffer} */ + indexBuffer; + /** @type {WebGLBuffer} */ + vertexBuffer; + /** @type {WebGLProgram} */ +@@ -271,16 +284,39 @@ export class Dvui { + 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; + } + } + ++ /** ++ * An Apple Pencil on iPad Safari arrives as a TouchEvent whose touch has `touchType` ++ * "stylus" (a finger's is "direct"). It is a pen, not a finger, so it goes in as the mouse: ++ * motion to where it is, then the left button pressed or released. dvui then counts it as ++ * `.mouse` (`dvui.pointerKind()`), so what it last touched stays hovered after a lift and a ++ * canvas treats it as a pointer, not a pan; native excludes SDL's pen touches the same way. ++ * The touch handlers' `preventDefault` stops the browser's own mouse events following it. ++ * ++ * @param {TouchEvent} ev ++ * @param {Touch} touch ++ * @param {StylusAction} action ++ */ ++ stylusEvent(ev, touch, action) { ++ const rect = this.gl.canvas.getBoundingClientRect(); ++ const x = (touch.clientX - rect.left) / (rect.right - rect.left) * ++ this.gl.drawingBufferWidth; ++ const y = (touch.clientY - rect.top) / (rect.bottom - rect.top) * ++ this.gl.drawingBufferHeight; ++ const mods = this.modCode(ev); ++ this.instance.exports.add_event(1, mods, 0, x, y); ++ if (action !== StylusAction.move) this.instance.exports.add_event(action, 0, mods, 0, 0); ++ } ++ + 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; +@@ -1578,37 +1614,45 @@ export class Dvui { + ev.target.value = ""; + }); + this.gl.canvas.addEventListener("touchstart", (ev) => { + if (this.stopped) return; + ev.preventDefault(); + let rect = this.gl.canvas.getBoundingClientRect(); + for (let i = 0; i < ev.changedTouches.length; i++) { + let touch = ev.changedTouches[i]; ++ if (touch.touchType === "stylus") { ++ this.stylusEvent(ev, touch, StylusAction.press); ++ continue; ++ } + 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); + this.instance.exports.add_event( + 8, + this.touches[tidx][1], + 0, + x, + y, + ); + } + this.requestRender(); + }); +- this.gl.canvas.addEventListener("touchend", (ev) => { ++ const touchEnd = (ev) => { + if (this.stopped) return; + ev.preventDefault(); + let rect = this.gl.canvas.getBoundingClientRect(); + for (let i = 0; i < ev.changedTouches.length; i++) { + let touch = ev.changedTouches[i]; ++ if (touch.touchType === "stylus") { ++ this.stylusEvent(ev, touch, StylusAction.release); ++ continue; ++ } + 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); + this.instance.exports.add_event( + 9, + this.touches[tidx][1], +@@ -1618,39 +1662,42 @@ export class Dvui { + ); + this.touches.splice(tidx, 1); + } + // This oskCheck 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.requestRender(); +- }); ++ }; ++ this.gl.canvas.addEventListener("touchend", touchEnd); ++ // A cancelled touch (the system took it: a gesture, an alert) ends like a lifted one, ++ // or its button, or the pencil's mouse button, would stay held. ++ this.gl.canvas.addEventListener("touchcancel", touchEnd); + this.gl.canvas.addEventListener("touchmove", (ev) => { + if (this.stopped) return; + ev.preventDefault(); + let rect = this.gl.canvas.getBoundingClientRect(); + for (let i = 0; i < ev.changedTouches.length; i++) { + let touch = ev.changedTouches[i]; ++ if (touch.touchType === "stylus") { ++ this.stylusEvent(ev, touch, StylusAction.move); ++ continue; ++ } + 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); + this.instance.exports.add_event( + 10, + this.touches[tidx][1], + 0, + x, + y, + ); + } + this.requestRender(); + }); +- //canvas.addEventListener("touchcancel", (ev) => { +- // console.log(ev); +- // this.requestRender(); +- //}); +- + // start the first update + this.requestRender(); + } + } diff --git a/web/patches/dvui/0004-web-send-only-the-GL-state-a-draw-changes.patch b/web/patches/dvui/0004-web-send-only-the-GL-state-a-draw-changes.patch new file mode 100644 index 000000000..25da83cee --- /dev/null +++ b/web/patches/dvui/0004-web-send-only-the-GL-state-a-draw-changes.patch @@ -0,0 +1,665 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Michael Belousov +Date: Sat, 24 Oct 2026 16:12:28 -0400 +Subject: [PATCH] web: send only the GL state a draw changes + +Every GL call is a trip to the GPU process in some browsers (Firefox), and each draw +sent the whole state: program, buffers, layout, texture, matrix, scissor, some 25 +calls where 3 or 4 change. The program, buffers and sampler are now bound once, and +cached setters (bindTex, bindFb, setScissor, setViewport, setUseTex, setMatrix, +setLayout) send the rest only when it differs. + +Each render target gets a framebuffer of its own, attached once, instead of the one +shared framebuffer re-attached on every switch, which made the driver check it again +each time. The canvas is resized only when its size changed: setting width or height, +even to the same value, makes a new drawing buffer. +--- + src/backends/web.js | 385 +++++++++++++++++--------------------------- + 1 file changed, 144 insertions(+), 241 deletions(-) + +diff --git a/src/backends/web.js b/src/backends/web.js +index 368fbfb..309c5fa 100644 +--- a/src/backends/web.js ++++ b/src/backends/web.js +@@ -186,28 +186,100 @@ export class Dvui { + useTex: WebGLUniformLocation | null; + }; + }} */ + programInfo; + /** @type {Map} */ + // texture, width, height, interp + textures = new Map(); + newTextureId = 1; ++ /// GL state as last set, so a draw sends only what changed. Every GL call is a trip to the ++ /// GPU process in some browsers (Firefox), and a draw sent the whole state each time — ++ /// program, layout, texture, blend, matrix, scissor — some 25 calls where 3 or 4 change. ++ /// `undefined` is unknown, and sends it. ++ gls = {}; ++ /// One framebuffer per render-target texture, attached once (texture id -> framebuffer): ++ /// re-attaching a texture to the one shared framebuffer on every switch made the driver ++ /// check the framebuffer again each time. ++ targetFramebuffers = new Map(); ++ /// The matrix uniform's storage, filled in place. ++ matrix = new Float32Array(16); + + // The modifier bits dvui reads (web_mod_code_to_dvui), from any keyboard or mouse event. + modCode(ev) { + return (ev.metaKey << 3) + (ev.altKey << 2) + (ev.ctrlKey << 1) + (ev.shiftKey << 0); + } + ++ // The cached setters (`gls`): each sends its state only when it differs from what is set. ++ bindTex(texture) { ++ if (this.gls.tex === texture) return; ++ this.gl.bindTexture(this.gl.TEXTURE_2D, texture); ++ this.gls.tex = texture; ++ } ++ bindFb(framebuffer) { ++ if (this.gls.fb === framebuffer) return; ++ this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, framebuffer); ++ this.gls.fb = framebuffer; ++ } ++ setScissor(x, y, w, h) { ++ const g = this.gls; ++ if (g.sx === x && g.sy === y && g.sw === w && g.sh === h) return; ++ this.gl.scissor(x, y, w, h); ++ g.sx = x; g.sy = y; g.sw = w; g.sh = h; ++ } ++ setViewport(w, h) { ++ if (this.gls.vw === w && this.gls.vh === h) return; ++ this.gl.viewport(0, 0, w, h); ++ this.gls.vw = w; this.gls.vh = h; ++ } ++ setUseTex(on) { ++ if (this.gls.useTex === on) return; ++ this.gl.uniform1i(this.programInfo.uniformLocations.useTex, on); ++ this.gls.useTex = on; ++ } ++ /// The projection for the current render target: its size, and flipped for the screen. ++ setMatrix() { ++ const w = this.renderTargetSize[0], h = this.renderTargetSize[1], fb = this.using_fb; ++ if (this.gls.mw === w && this.gls.mh === h && this.gls.mfb === fb) return; ++ this.fillMatrix(w, h, fb); ++ this.gl.uniformMatrix4fv(this.programInfo.uniformLocations.matrix, false, this.matrix); ++ this.gls.mw = w; this.gls.mh = h; this.gls.mfb = fb; ++ } ++ fillMatrix(w, h, fb) { ++ const m = this.matrix; ++ m.fill(0); ++ m[0] = 2.0 / w; ++ m[5] = (fb ? 2.0 : -2.0) / h; ++ m[10] = 1.0; ++ m[12] = -1.0; ++ m[13] = fb ? -1.0 : 1.0; ++ m[15] = 1.0; ++ } ++ /// The vertex layout, which only changes if the vertex type does. ++ setLayout(sizeof_vertex, offset_pos, offset_col, offset_uv) { ++ const g = this.gls; ++ if (g.ls === sizeof_vertex && g.lp === offset_pos && g.lc === offset_col && g.lu === offset_uv) return; ++ const a = this.programInfo.attribLocations; ++ this.gl.vertexAttribPointer(a.vertexPosition, 2, this.gl.FLOAT, false, sizeof_vertex, offset_pos); ++ this.gl.enableVertexAttribArray(a.vertexPosition); ++ this.gl.vertexAttribPointer(a.vertexColor, 4, this.gl.UNSIGNED_BYTE, false, sizeof_vertex, offset_col); ++ this.gl.enableVertexAttribArray(a.vertexColor); ++ this.gl.vertexAttribPointer(a.textureCoord, 2, this.gl.FLOAT, false, sizeof_vertex, offset_uv); ++ this.gl.enableVertexAttribArray(a.textureCoord); ++ g.ls = sizeof_vertex; g.lp = offset_pos; g.lc = offset_col; g.lu = offset_uv; ++ } ++ + /** @returns {[WebGLTexture, number, number, number] | null} */ + textureEntry(id) { + if (id === 0) return null; + return this.textures.get(id) ?? null; + } + using_fb = false; ++ /** Texture id bound as the render target, 0 for the screen. */ ++ renderTargetId = 0; + /** @type {WebGLFramebuffer | null} */ + frame_buffer = null; + /** @type {[number, number]} */ + renderTargetSize = [0, 0]; + + renderRequested = false; + renderTimeoutId = 0; + +@@ -487,17 +559,17 @@ export class Dvui { + 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, interp]); + +- this.gl.bindTexture(this.gl.TEXTURE_2D, texture); ++ this.bindTex(texture); + + this.gl.texImage2D( + this.gl.TEXTURE_2D, + 0, + this.gl.RGBA, + width, + height, + 0, +@@ -541,59 +613,59 @@ export class Dvui { + 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.bindTex(null); + + return id; + }, + // Replace every pixel of a texture made by wasm_textureCreate. Returns 0 for an + // unknown texture. No mipmaps to regenerate: textures sample NEAREST/LINEAR. + wasm_textureUpdate: (id, pixels) => { + const entry = this.textureEntry(id); + if (entry === null) { + console.warn( + `wasm_textureUpdate: missing texture id ${id}`, + ); + return 0; + } + const [texture, width, height, interp] = entry; +- this.gl.bindTexture(this.gl.TEXTURE_2D, texture); ++ this.bindTex(texture); + this.gl.texSubImage2D( + this.gl.TEXTURE_2D, 0, 0, 0, width, height, + this.gl.RGBA, this.gl.UNSIGNED_BYTE, + this.bytesFromPointer(pixels, width * height * 4), + ); + // mipmaps currently break BlurBackdrop + //if (this.webgl2 && (interp == 1)) { + //this.gl.generateMipmap(this.gl.TEXTURE_2D); + //} +- this.gl.bindTexture(this.gl.TEXTURE_2D, null); ++ this.bindTex(null); + return 1; + }, + // Replace the x,y,w,h rect of a texture made by wasm_textureCreate. `pixels` is the + // whole texture's buffer (see dvui.Backend.textureUpdateSubRect); only the rect is + // uploaded. Returns 0 for an unknown texture. + wasm_textureUpdateSubRect: (id, pixels, x, y, w, h) => { + const entry = this.textureEntry(id); + if (entry === null) { + console.warn( + `wasm_textureUpdateSubRect: missing texture id ${id}`, + ); + return 0; + } + const [texture, width, height, interp] = entry; + if (w === 0 || h === 0) return 1; + const full = this.bytesFromPointer(pixels, width * height * 4); +- this.gl.bindTexture(this.gl.TEXTURE_2D, texture); ++ this.bindTex(texture); + if (this.webgl2) { + // Read the rect in place out of the full buffer. + this.gl.pixelStorei(this.gl.UNPACK_ROW_LENGTH, width); + this.gl.pixelStorei(this.gl.UNPACK_SKIP_PIXELS, x); + this.gl.pixelStorei(this.gl.UNPACK_SKIP_ROWS, y); + this.gl.texSubImage2D( + this.gl.TEXTURE_2D, 0, x, y, w, h, + this.gl.RGBA, this.gl.UNSIGNED_BYTE, full, +@@ -613,27 +685,27 @@ export class Dvui { + const start = ((y + row) * width + x) * 4; + rect.set(full.subarray(start, start + w * 4), row * w * 4); + } + this.gl.texSubImage2D( + this.gl.TEXTURE_2D, 0, x, y, w, h, + this.gl.RGBA, this.gl.UNSIGNED_BYTE, rect, + ); + } +- this.gl.bindTexture(this.gl.TEXTURE_2D, null); ++ this.bindTex(null); + return 1; + }, + 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, interp]); + +- this.gl.bindTexture(this.gl.TEXTURE_2D, texture); ++ this.bindTex(texture); + + this.gl.texImage2D( + this.gl.TEXTURE_2D, + 0, + this.gl.RGBA, + width, + height, + 0, +@@ -677,17 +749,17 @@ export class Dvui { + 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.bindTex(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 +@@ -698,112 +770,78 @@ export class Dvui { + //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, +- ); ++ const prev = this.gls.fb; ++ this.bindFb(this.frame_buffer); + this.gl.framebufferTexture2D( + this.gl.FRAMEBUFFER, + this.gl.COLOR_ATTACHMENT0, + this.gl.TEXTURE_2D, +- texture, ++ entry[0], + 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); ++ this.bindFb(prev); + }, + wasm_renderTarget: (id) => { + //console.log("renderTarget " + id); +- if (id === 0) { ++ const rt = id === 0 ? null : this.textureEntry(id); ++ if (id !== 0 && rt === null) console.warn(`wasm_renderTarget: missing texture id ${id}`); ++ if (rt === null) { ++ this.renderTargetId = 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], +- ); ++ this.bindFb(null); ++ this.renderTargetSize = [this.gl.drawingBufferWidth, this.gl.drawingBufferHeight]; + } else { ++ this.renderTargetId = id; + 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, +- ]; ++ let fb = this.targetFramebuffers.get(id); ++ if (fb === undefined) { ++ fb = this.gl.createFramebuffer(); ++ this.bindFb(fb); ++ this.gl.framebufferTexture2D(this.gl.FRAMEBUFFER, this.gl.COLOR_ATTACHMENT0, this.gl.TEXTURE_2D, rt[0], 0); ++ this.targetFramebuffers.set(id, fb); + } 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.bindFb(fb); + } +- this.gl.viewport( +- 0, +- 0, +- this.renderTargetSize[0], +- this.renderTargetSize[1], +- ); +- this.gl.scissor( +- 0, +- 0, +- this.renderTargetSize[0], +- this.renderTargetSize[1], +- ); ++ this.renderTargetSize = [rt[1], rt[2]]; + } ++ this.setViewport(this.renderTargetSize[0], this.renderTargetSize[1]); ++ this.setScissor(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); ++ const fb = this.targetFramebuffers.get(id); ++ if (fb !== undefined) { ++ if (this.gls.fb === fb) this.bindFb(null); ++ this.gl.deleteFramebuffer(fb); ++ this.targetFramebuffers.delete(id); ++ } ++ if (this.gls.tex === entry[0]) this.bindTex(null); + this.gl.deleteTexture(entry[0]); + }, + wasm_renderGeometry: ( + textureId, + index_ptr, + index_len, + vertex_ptr, + vertex_len, +@@ -812,175 +850,41 @@ export class Dvui { + 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; ++ this.setScissor(x, y, w, h); + } else { +- matrix[13] = 1.0; ++ this.setScissor(0, 0, this.renderTargetSize[0], this.renderTargetSize[1]); + } +- 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, +- ); +- } ++ 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.bufferData(this.gl.ARRAY_BUFFER, this.bytesFromPointer(vertex_ptr, vertex_len), this.gl.DYNAMIC_DRAW); ++ this.setLayout(sizeof_vertex, offset_pos, offset_col, offset_uv); ++ this.setMatrix(); ++ ++ const tex = textureId != 0 ? this.textureEntry(textureId) : null; ++ if (textureId != 0 && tex === null) console.warn(`wasm_renderGeometry: missing texture id ${textureId}`); ++ if (tex !== null) { ++ this.bindTex(tex[0]); ++ this.setUseTex(1); + } else { +- this.gl.bindTexture(this.gl.TEXTURE_2D, null); +- this.gl.uniform1i( +- this.programInfo.uniformLocations.useTex, +- 0, +- ); ++ // Not left bound: a target texture still bound while drawing into it is a ++ // feedback loop to some drivers, sampled or not. ++ this.bindTex(null); ++ this.setUseTex(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], +- ); +- } ++ this.gl.drawElements(this.gl.TRIANGLES, indices.length, this.gl.UNSIGNED_SHORT, 0); + }, + 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]; +@@ -1253,20 +1157,29 @@ export class Dvui { + "useTex", + ), + }, + }; + + this.indexBuffer = this.gl.createBuffer(); + this.vertexBuffer = this.gl.createBuffer(); + ++ // Bound for good: there is one program, one pair of buffers and one sampler, so a draw ++ // sends only its data and whatever else changed (`gls`). ++ this.gl.useProgram(this.shaderProgram); ++ this.gl.bindBuffer(this.gl.ELEMENT_ARRAY_BUFFER, this.indexBuffer); ++ this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.vertexBuffer); ++ this.gl.activeTexture(this.gl.TEXTURE0); ++ this.gl.uniform1i(this.programInfo.uniformLocations.uSampler, 0); ++ ++ this.gls = {}; + 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( ++ this.setScissor( + 0, + 0, + this.gl.canvas.clientWidth, + this.gl.canvas.clientHeight, + ); + } + + init() { +@@ -1334,34 +1247,24 @@ export class Dvui { + + this.renderRequested = false; + + // if the canvas changed size, adjust the backing buffer + const w = this.gl.canvas.clientWidth; + const h = this.gl.canvas.clientHeight; + const scale = window.devicePixelRatio; + //console.log("wxh " + w + "x" + h + " scale " + scale); +- this.gl.canvas.width = Math.round(w * scale); +- this.gl.canvas.height = Math.round(h * scale); +- this.renderTargetSize = [ +- this.gl.drawingBufferWidth, +- this.gl.drawingBufferHeight, +- ]; +- this.gl.viewport( +- 0, +- 0, +- this.gl.drawingBufferWidth, +- this.gl.drawingBufferHeight, +- ); +- this.gl.scissor( +- 0, +- 0, +- this.gl.drawingBufferWidth, +- this.gl.drawingBufferHeight, +- ); ++ // Only when it changed: setting either one, even to what it already is, makes a new ++ // drawing buffer. ++ const cw = Math.round(w * scale); ++ const ch = Math.round(h * scale); ++ if (this.gl.canvas.width !== cw) this.gl.canvas.width = cw; ++ if (this.gl.canvas.height !== ch) this.gl.canvas.height = ch; ++ // Every frame starts on the screen. ++ this.imports.wasm_renderTarget(0); + + this.gl.clearColor(0.0, 0.0, 0.0, 1.0); // Clear to black, fully opaque + this.gl.clear(this.gl.COLOR_BUFFER_BIT); + + let millis_to_wait = this.instance.exports.dvui_update(); + + // This oskCheck is for desktop to get text events. Touch devices will + // show/hide the keyboard (but not all, see touchend handler). diff --git a/web/patches/dvui/0005-web-clearing-a-target-keeps-the-target-that-was-boun.patch b/web/patches/dvui/0005-web-clearing-a-target-keeps-the-target-that-was-boun.patch new file mode 100644 index 000000000..9160ca005 --- /dev/null +++ b/web/patches/dvui/0005-web-clearing-a-target-keeps-the-target-that-was-boun.patch @@ -0,0 +1,45 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Michael Belousov +Date: Sat, 24 Oct 2026 16:12:35 -0400 +Subject: [PATCH] web: clearing a target keeps the target that was bound + +wasm_textureClearTarget bound the target, cleared it, and bound the screen. A target +is often created, and so cleared, while another one is being rendered into (a Picture +drawn inside a frame target), and the rest of that drawing then went to the screen. +It now binds back whatever target was bound before. +--- + src/backends/web.js | 7 ++++++- + 1 file changed, 6 insertions(+), 1 deletion(-) + +diff --git a/src/backends/web.js b/src/backends/web.js +index 309c5fa..200e34f 100644 +--- a/src/backends/web.js ++++ b/src/backends/web.js +@@ -755,21 +755,26 @@ export class Dvui { + } + + this.bindTex(null); + + this.imports.wasm_textureClearTarget(id); + + return id; + }, ++ // Restores whatever target was bound before: a target is often created and ++ // cleared while another one is being rendered into (a Picture inside a frame ++ // target), and landing on the screen there sends the rest of that drawing to the ++ // wrong place. + wasm_textureClearTarget: (textureId) => { ++ const prev = this.renderTargetId; + 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); ++ this.imports.wasm_renderTarget(prev); + }, + 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}`, + ); diff --git a/web/patches/dvui/0006-web-textureBlend.patch b/web/patches/dvui/0006-web-textureBlend.patch new file mode 100644 index 000000000..e4a7a2e02 --- /dev/null +++ b/web/patches/dvui/0006-web-textureBlend.patch @@ -0,0 +1,217 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Michael Belousov +Date: Sat, 24 Oct 2026 16:13:15 -0400 +Subject: [PATCH] web: textureBlend + +The web backend implements Backend.textureBlend: wasm_textureBlend records a +texture's blend (over, add, copy), and each draw of the texture sets it, since WebGL's +blend state is global. web.zig flushes its batch first, so draws already batched keep +the blend they were made with. +--- + src/backends/web.js | 27 +++++++++++++++++++++++++-- + src/backends/web.zig | 10 ++++++++++ + 2 files changed, 35 insertions(+), 2 deletions(-) + +diff --git a/src/backends/web.js b/src/backends/web.js +index 200e34f..dbae62d 100644 +--- a/src/backends/web.js ++++ b/src/backends/web.js +@@ -185,16 +185,19 @@ export class Dvui { + uSampler: WebGLUniformLocation | null; + useTex: WebGLUniformLocation | null; + }; + }} */ + programInfo; + /** @type {Map} */ + // texture, width, height, interp + textures = new Map(); ++ /// texture id -> blend mode set by wasm_textureBlend: 0 source-over (the default), 1 add, ++ /// 2 copy. WebGL blend state is global, so it is applied per draw when the texture binds. ++ textureBlends = new Map(); + newTextureId = 1; + /// GL state as last set, so a draw sends only what changed. Every GL call is a trip to the + /// GPU process in some browsers (Firefox), and a draw sent the whole state each time — + /// program, layout, texture, blend, matrix, scissor — some 25 calls where 3 or 4 change. + /// `undefined` is unknown, and sends it. + gls = {}; + /// One framebuffer per render-target texture, attached once (texture id -> framebuffer): + /// re-attaching a texture to the one shared framebuffer on every switch made the driver +@@ -225,16 +228,28 @@ export class Dvui { + this.gl.scissor(x, y, w, h); + g.sx = x; g.sy = y; g.sw = w; g.sh = h; + } + setViewport(w, h) { + if (this.gls.vw === w && this.gls.vh === h) return; + this.gl.viewport(0, 0, w, h); + this.gls.vw = w; this.gls.vh = h; + } ++ /// 0 source-over, 1 add, 2 copy (`wasm_textureBlend`). ++ setBlend(mode) { ++ if (this.gls.blend === mode) return; ++ if (mode === 2) { ++ this.gl.disable(this.gl.BLEND); ++ } else { ++ if (this.gls.blend === 2 || this.gls.blend === undefined) this.gl.enable(this.gl.BLEND); ++ if (mode === 1) this.gl.blendFunc(this.gl.ONE, this.gl.ONE); ++ else this.gl.blendFunc(this.gl.ONE, this.gl.ONE_MINUS_SRC_ALPHA); ++ } ++ this.gls.blend = mode; ++ } + setUseTex(on) { + if (this.gls.useTex === on) return; + this.gl.uniform1i(this.programInfo.uniformLocations.useTex, on); + this.gls.useTex = on; + } + /// The projection for the current render target: its size, and flipped for the screen. + setMatrix() { + const w = this.renderTargetSize[0], h = this.renderTargetSize[1], fb = this.using_fb; +@@ -755,16 +770,22 @@ export class Dvui { + } + + this.bindTex(null); + + this.imports.wasm_textureClearTarget(id); + + return id; + }, ++ // See dvui.Backend.textureBlend. Returns 0 for an unknown texture. ++ wasm_textureBlend: (id, mode) => { ++ if (this.textureEntry(id) === null) return 0; ++ this.textureBlends.set(id, mode); ++ return 1; ++ }, + // Restores whatever target was bound before: a target is often created and + // cleared while another one is being rendered into (a Picture inside a frame + // target), and landing on the screen there sends the rest of that drawing to the + // wrong place. + wasm_textureClearTarget: (textureId) => { + const prev = this.renderTargetId; + this.imports.wasm_renderTarget(textureId); + this.gl.clearColor(0.0, 0.0, 0.0, 0.0); // fully transparent +@@ -830,16 +851,17 @@ export class Dvui { + this.setViewport(this.renderTargetSize[0], this.renderTargetSize[1]); + this.setScissor(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.textureBlends.delete(id); + const fb = this.targetFramebuffers.get(id); + if (fb !== undefined) { + if (this.gls.fb === fb) this.bindFb(null); + this.gl.deleteFramebuffer(fb); + this.targetFramebuffers.delete(id); + } + if (this.gls.tex === entry[0]) this.bindTex(null); + this.gl.deleteTexture(entry[0]); +@@ -872,21 +894,23 @@ export class Dvui { + this.setLayout(sizeof_vertex, offset_pos, offset_col, offset_uv); + this.setMatrix(); + + const tex = textureId != 0 ? this.textureEntry(textureId) : null; + if (textureId != 0 && tex === null) console.warn(`wasm_renderGeometry: missing texture id ${textureId}`); + if (tex !== null) { + this.bindTex(tex[0]); + this.setUseTex(1); ++ this.setBlend(this.textureBlends.get(textureId) ?? 0); + } else { + // Not left bound: a target texture still bound while drawing into it is a + // feedback loop to some drivers, sampled or not. + this.bindTex(null); + this.setUseTex(0); ++ this.setBlend(0); + } + + this.gl.drawElements(this.gl.TRIANGLES, indices.length, this.gl.UNSIGNED_SHORT, 0); + }, + wasm_cursor: (name_ptr, name_len) => { + const cursor_name = this.stringFromPointer(name_ptr, name_len); + this.gl.canvas.style.cursor = cursor_name; + }, +@@ -1171,18 +1195,17 @@ export class Dvui { + // sends only its data and whatever else changed (`gls`). + this.gl.useProgram(this.shaderProgram); + this.gl.bindBuffer(this.gl.ELEMENT_ARRAY_BUFFER, this.indexBuffer); + this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.vertexBuffer); + this.gl.activeTexture(this.gl.TEXTURE0); + this.gl.uniform1i(this.programInfo.uniformLocations.uSampler, 0); + + this.gls = {}; +- this.gl.enable(this.gl.BLEND); +- this.gl.blendFunc(this.gl.ONE, this.gl.ONE_MINUS_SRC_ALPHA); ++ this.setBlend(0); + this.gl.enable(this.gl.SCISSOR_TEST); + this.setScissor( + 0, + 0, + this.gl.canvas.clientWidth, + this.gl.canvas.clientHeight, + ); + } +diff --git a/src/backends/web.zig b/src/backends/web.zig +index b019009..d05caa4 100644 +--- a/src/backends/web.zig ++++ b/src/backends/web.zig +@@ -57,16 +57,17 @@ pub const wasm = if (!builtin.is_test) struct { + pub extern "dvui" fn wasm_canvas_height() f32; + + pub extern "dvui" fn wasm_frame_buffer() u8; + pub extern "dvui" fn wasm_textureCreate(pixels: [*]const u8, width: u32, height: u32, interp: u8, wrap_u: u8, wrap_v: u8) u32; + pub extern "dvui" fn wasm_textureCreateTarget(width: u32, height: u32, interp: u8, wrap_u: u8, wrap_v: u8) u32; + pub extern "dvui" fn wasm_textureUpdate(texture: u32, pixels: [*]const u8) u8; + pub extern "dvui" fn wasm_textureUpdateSubRect(texture: u32, pixels: [*]const u8, x: u32, y: u32, w: u32, h: u32) u8; + pub extern "dvui" fn wasm_textureClearTarget(u32) void; ++ pub extern "dvui" fn wasm_textureBlend(texture: u32, mode: u8) u8; + pub extern "dvui" fn wasm_textureRead(texture: u32, pixels_out: [*]u8, width: u32, height: u32) void; + 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_cursor(name: [*]const u8, name_len: usize) void; + pub extern "dvui" fn wasm_text_input(x: f32, y: f32, w: f32, h: f32) void; + pub extern "dvui" fn wasm_open_url(ptr: [*]const u8, len: usize, new_window: bool) void; +@@ -122,16 +123,19 @@ pub const wasm = if (!builtin.is_test) struct { + } + pub fn wasm_textureUpdate(_: u32, _: [*]const u8) u8 { + return 0; + } + pub fn wasm_textureUpdateSubRect(_: u32, _: [*]const u8, _: u32, _: u32, _: u32, _: u32) u8 { + return 0; + } + pub fn wasm_textureClearTarget(_: u32) void {} ++ pub fn wasm_textureBlend(_: u32, _: u8) u8 { ++ return 0; ++ } + pub fn wasm_textureRead(_: u32, _: [*]u8, _: u32, _: u32) void {} + 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_cursor(_: [*]const u8, _: usize) void {} + pub fn wasm_text_input(_: f32, _: f32, _: f32, _: f32) void {} + pub fn wasm_open_url(_: [*]const u8, _: usize, _: bool) void {} +@@ -751,16 +755,22 @@ pub fn textureCreateTarget(_: *WebBackend, options: dvui.Texture.CreateOptions) + .height = options.height, + .format = options.format, + .interpolation = options.interpolation, + .wrap_u = options.wrap_u, + .wrap_v = options.wrap_v, + }; + } + ++/// See `dvui.Backend.textureBlend`. Draws already batched keep the blend they were made with. ++pub fn textureBlend(_: *WebBackend, texture: dvui.Texture, blend: dvui.Backend.TextureBlend) !void { ++ flushBatch(); ++ if (wasm.wasm_textureBlend(@intCast(@intFromPtr(texture.ptr)), @intFromEnum(blend)) == 0) return dvui.Backend.TextureError.TextureUpdate; ++} ++ + pub fn textureClearTarget(_: *WebBackend, tex: dvui.TextureTarget) void { + flushBatch(); + wasm.wasm_textureClearTarget(@intCast(@intFromPtr(tex.ptr))); + } + + pub fn textureFromTarget(_: *WebBackend, texture: dvui.TextureTarget) !dvui.Texture { + flushBatch(); + return .cast(texture); diff --git a/web/patches/dvui/0007-web-precise-render-targets.patch b/web/patches/dvui/0007-web-precise-render-targets.patch new file mode 100644 index 000000000..7a4ab6f69 --- /dev/null +++ b/web/patches/dvui/0007-web-precise-render-targets.patch @@ -0,0 +1,339 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Michael Belousov +Date: Sat, 24 Oct 2026 16:14:21 -0400 +Subject: [PATCH] web: precise render targets + +The web backend implements Backend.support_precise_targets: a target created with +precision = .high is RGBA16F, which WebGL2 samples with linear filtering as standard +and renders into with EXT_color_buffer_float (or the half-float one). web.js checks +for the extension at context creation, and the first such framebuffer for +completeness, falling back to the 8-bit target where either fails. BlurBackdrop's +pyramid is the user: at 8 bits, its downsampled levels band. +--- + src/backends/web.js | 165 ++++++++++++++++++++++++++----------------- + src/backends/web.zig | 14 +++- + 2 files changed, 113 insertions(+), 66 deletions(-) + +diff --git a/src/backends/web.js b/src/backends/web.js +index dbae62d..7cd1983 100644 +--- a/src/backends/web.js ++++ b/src/backends/web.js +@@ -188,16 +188,19 @@ export class Dvui { + }} */ + programInfo; + /** @type {Map} */ + // texture, width, height, interp + textures = new Map(); + /// texture id -> blend mode set by wasm_textureBlend: 0 source-over (the default), 1 add, + /// 2 copy. WebGL blend state is global, so it is applied per draw when the texture binds. + textureBlends = new Map(); ++ /// Whether half-float render targets are available (WebGL2 + EXT_color_buffer_float). ++ preciseTargets = false; ++ preciseChecked = false; + newTextureId = 1; + /// GL state as last set, so a draw sends only what changed. Every GL call is a trip to the + /// GPU process in some browsers (Firefox), and a draw sent the whole state each time — + /// program, layout, texture, blend, matrix, scissor — some 25 calls where 3 or 4 change. + /// `undefined` is unknown, and sends it. + gls = {}; + /// One framebuffer per render-target texture, attached once (texture id -> framebuffer): + /// re-attaching a texture to the one shared framebuffer on every switch made the driver +@@ -277,16 +280,100 @@ export class Dvui { + this.gl.enableVertexAttribArray(a.vertexPosition); + this.gl.vertexAttribPointer(a.vertexColor, 4, this.gl.UNSIGNED_BYTE, false, sizeof_vertex, offset_col); + this.gl.enableVertexAttribArray(a.vertexColor); + this.gl.vertexAttribPointer(a.textureCoord, 2, this.gl.FLOAT, false, sizeof_vertex, offset_uv); + this.gl.enableVertexAttribArray(a.textureCoord); + g.ls = sizeof_vertex; g.lp = offset_pos; g.lc = offset_col; g.lu = offset_uv; + } + ++ createTarget(width, height, interp, wrap_u, wrap_v, precise) { ++ const texture = this.gl.createTexture(); ++ const id = this.newTextureId; ++ //console.log("creating texture " + id); ++ this.newTextureId += 1; ++ this.textures.set(id, [texture, width, height, interp]); ++ ++ this.bindTex(texture); ++ ++ this.gl.texImage2D( ++ this.gl.TEXTURE_2D, ++ 0, ++ precise ? this.gl.RGBA16F : this.gl.RGBA, ++ width, ++ height, ++ 0, ++ this.gl.RGBA, ++ precise ? this.gl.HALF_FLOAT : 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 { ++ // mipmaps currently break BlurBackdrop ++ //if (this.webgl2) { ++ //this.gl.generateMipmap(this.gl.TEXTURE_2D); ++ //} ++ ++ this.gl.texParameteri( ++ this.gl.TEXTURE_2D, ++ this.gl.TEXTURE_MIN_FILTER, ++ this.gl.LINEAR, ++ //this.gl.LINEAR_MIPMAP_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.bindTex(null); ++ ++ if (precise && !this.preciseChecked) { ++ // The extension says half-float attachments are renderable; make sure this context ++ // agrees before trusting every later target to it. Checked once. ++ this.preciseChecked = true; ++ const prev = this.renderTargetId; ++ this.imports.wasm_renderTarget(id); ++ const status = this.gl.checkFramebufferStatus(this.gl.FRAMEBUFFER); ++ this.imports.wasm_renderTarget(prev); ++ if (status !== this.gl.FRAMEBUFFER_COMPLETE) { ++ console.warn("dvui: half-float render target incomplete (status " + status + "); falling back to 8-bit targets"); ++ this.preciseTargets = false; ++ this.imports.wasm_textureDestroy(id); ++ return this.createTarget(width, height, interp, wrap_u, wrap_v, false); ++ } ++ } ++ ++ this.imports.wasm_textureClearTarget(id); ++ ++ return id; ++ } ++ + /** @returns {[WebGLTexture, number, number, number] | null} */ + textureEntry(id) { + if (id === 0) return null; + return this.textures.get(id) ?? null; + } + using_fb = false; + /** Texture id bound as the render target, 0 for the screen. */ + renderTargetId = 0; +@@ -704,81 +791,22 @@ export class Dvui { + this.gl.TEXTURE_2D, 0, x, y, w, h, + this.gl.RGBA, this.gl.UNSIGNED_BYTE, rect, + ); + } + this.bindTex(null); + return 1; + }, + 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, interp]); +- +- this.bindTex(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 { +- // mipmaps currently break BlurBackdrop +- //if (this.webgl2) { +- //this.gl.generateMipmap(this.gl.TEXTURE_2D); +- //} +- +- this.gl.texParameteri( +- this.gl.TEXTURE_2D, +- this.gl.TEXTURE_MIN_FILTER, +- this.gl.LINEAR, +- //this.gl.LINEAR_MIPMAP_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.bindTex(null); +- +- this.imports.wasm_textureClearTarget(id); +- +- return id; ++ return this.createTarget(width, height, interp, wrap_u, wrap_v, false); ++ }, ++ // A 16-bit-float-per-channel target where the context has them; the 8-bit one ++ // otherwise. See dvui.Backend.support_precise_targets. ++ wasm_textureCreatePreciseTarget: (width, height, interp, wrap_u, wrap_v) => { ++ return this.createTarget(width, height, interp, wrap_u, wrap_v, this.preciseTargets); + }, + // See dvui.Backend.textureBlend. Returns 0 for an unknown texture. + wasm_textureBlend: (id, mode) => { + if (this.textureEntry(id) === null) return 0; + this.textureBlends.set(id, mode); + return 1; + }, + // Restores whatever target was bound before: a target is often created and +@@ -1091,16 +1119,23 @@ export class Dvui { + this.gl = canvas.getContext("webgl", { alpha: true, antialias: false }); + } + + if (this.gl === null) { + alert("Unable to initialize WebGL."); + return; + } + ++ // Half-float render targets for dvui's blur pyramid (Texture.CreateOptions.precision): ++ // WebGL2 samples RGBA16F with linear filtering as standard; rendering into it needs the ++ // extension. Without it the precise target is the plain 8-bit one. ++ this.preciseTargets = this.webgl2 && ++ (this.gl.getExtension("EXT_color_buffer_float") !== null || ++ this.gl.getExtension("EXT_color_buffer_half_float") !== 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; + } + } + this.frame_buffer = this.gl.createFramebuffer(); +diff --git a/src/backends/web.zig b/src/backends/web.zig +index d05caa4..9a7c9ab 100644 +--- a/src/backends/web.zig ++++ b/src/backends/web.zig +@@ -54,16 +54,17 @@ pub const wasm = if (!builtin.is_test) struct { + pub extern "dvui" fn wasm_pixel_width() f32; + pub extern "dvui" fn wasm_pixel_height() f32; + pub extern "dvui" fn wasm_canvas_width() f32; + pub extern "dvui" fn wasm_canvas_height() f32; + + pub extern "dvui" fn wasm_frame_buffer() u8; + pub extern "dvui" fn wasm_textureCreate(pixels: [*]const u8, width: u32, height: u32, interp: u8, wrap_u: u8, wrap_v: u8) u32; + pub extern "dvui" fn wasm_textureCreateTarget(width: u32, height: u32, interp: u8, wrap_u: u8, wrap_v: u8) u32; ++ pub extern "dvui" fn wasm_textureCreatePreciseTarget(width: u32, height: u32, interp: u8, wrap_u: u8, wrap_v: u8) u32; + pub extern "dvui" fn wasm_textureUpdate(texture: u32, pixels: [*]const u8) u8; + pub extern "dvui" fn wasm_textureUpdateSubRect(texture: u32, pixels: [*]const u8, x: u32, y: u32, w: u32, h: u32) u8; + pub extern "dvui" fn wasm_textureClearTarget(u32) void; + pub extern "dvui" fn wasm_textureBlend(texture: u32, mode: u8) u8; + pub extern "dvui" fn wasm_textureRead(texture: u32, pixels_out: [*]u8, width: u32, height: u32) void; + 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; +@@ -116,16 +117,19 @@ pub const wasm = if (!builtin.is_test) struct { + return undefined; + } + pub fn wasm_textureCreate(_: [*]const u8, _: u32, _: u32, _: u8, _: u8, _: u8) u32 { + return undefined; + } + pub fn wasm_textureCreateTarget(_: u32, _: u32, _: u8, _: u8, _: u8) u32 { + return undefined; + } ++ pub fn wasm_textureCreatePreciseTarget(_: u32, _: u32, _: u8, _: u8, _: u8) u32 { ++ return undefined; ++ } + pub fn wasm_textureUpdate(_: u32, _: [*]const u8) u8 { + return 0; + } + pub fn wasm_textureUpdateSubRect(_: u32, _: [*]const u8, _: u32, _: u32, _: u32, _: u32) u8 { + return 0; + } + pub fn wasm_textureClearTarget(_: u32) void {} + pub fn wasm_textureBlend(_: u32, _: u8) u8 { +@@ -723,16 +727,21 @@ pub fn textureUpdate(_: *WebBackend, texture: dvui.Texture, pixels: [*]const u8) + /// See `dvui.Backend.textureUpdateSubRect`. `pixels` is the full texture's buffer; only the + /// rect is uploaded (WebGL2 reads it in place through the unpack row length and skips; WebGL1 + /// copies the rect's rows out first). + pub fn textureUpdateSubRect(_: *WebBackend, texture: dvui.Texture, pixels: [*]const u8, x: u32, y: u32, w: u32, h: u32) !void { + flushBatch(); + if (wasm.wasm_textureUpdateSubRect(@intCast(@intFromPtr(texture.ptr)), pixels, x, y, w, h) == 0) return dvui.Backend.TextureError.TextureUpdate; + } + ++/// See `dvui.Backend.support_precise_targets`. `precision = .high` is RGBA16F on WebGL2 with ++/// `EXT_color_buffer_float` (or the half-float one); web.js checks at context creation and ++/// verifies the first such framebuffer is complete, falling back to the 8-bit target otherwise. ++pub const support_precise_targets = true; ++ + pub fn textureCreateTarget(_: *WebBackend, options: dvui.Texture.CreateOptions) !dvui.TextureTarget { + if (options.format != .rgba_32) { + log.err("textureCreateTarget currently only supports pixel format .rgba_32", .{}); + return dvui.Backend.TextureError.TextureCreate; + } + + const wasm_interp: u8 = switch (options.interpolation) { + .nearest => 0, +@@ -743,17 +752,20 @@ pub fn textureCreateTarget(_: *WebBackend, options: dvui.Texture.CreateOptions) + .clamp => 0, + .repeat => 1, + }; + const wasm_wrap_v: u8 = switch (options.wrap_v) { + .clamp => 0, + .repeat => 1, + }; + +- const id = wasm.wasm_textureCreateTarget(options.width, options.height, wasm_interp, wasm_wrap_u, wasm_wrap_v); ++ const id = if (options.precision == .high) ++ wasm.wasm_textureCreatePreciseTarget(options.width, options.height, wasm_interp, wasm_wrap_u, wasm_wrap_v) ++ else ++ wasm.wasm_textureCreateTarget(options.width, options.height, wasm_interp, wasm_wrap_u, wasm_wrap_v); + return dvui.TextureTarget{ + .ptr = @ptrFromInt(id), + .width = options.width, + .height = options.height, + .format = options.format, + .interpolation = options.interpolation, + .wrap_u = options.wrap_u, + .wrap_v = options.wrap_v, diff --git a/web/patches/dvui/0008-web-wasm_renderStream-a-flush-s-draws-in-one-call.patch b/web/patches/dvui/0008-web-wasm_renderStream-a-flush-s-draws-in-one-call.patch new file mode 100644 index 000000000..01bc57032 --- /dev/null +++ b/web/patches/dvui/0008-web-wasm_renderStream-a-flush-s-draws-in-one-call.patch @@ -0,0 +1,98 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Michael Belousov +Date: Sat, 24 Oct 2026 16:14:48 -0400 +Subject: [PATCH] web: wasm_renderStream, a flush's draws in one call + +wasm_renderGeometry uploads the geometry and sets the state of one draw per call. A +backend that batches its draws can instead hand web.js the whole flush: the geometry, +uploaded once, and a list of steps, each a draw (texture, clip, blend and its range of +the indices), a switch of render target or a clear of one. A blur pass's dozens of +small draws then cost one call from wasm and one upload. + +wasm_renderGeometry stays, and web.zig still draws through it. +--- + src/backends/web.js | 61 +++++++++++++++++++++++++++++++++++++++++++++ + 1 file changed, 61 insertions(+) + +diff --git a/src/backends/web.js b/src/backends/web.js +index 7cd1983..afc813b 100644 +--- a/src/backends/web.js ++++ b/src/backends/web.js +@@ -933,16 +933,77 @@ export class Dvui { + // feedback loop to some drivers, sampled or not. + this.bindTex(null); + this.setUseTex(0); + this.setBlend(0); + } + + this.gl.drawElements(this.gl.TRIANGLES, indices.length, this.gl.UNSIGNED_SHORT, 0); + }, ++ // A flush's worth of draws, for a backend that batches them: the stream's geometry, ++ // uploaded once, and its steps in order — each `cmd_count` entry eight i32s: texture, ++ // kind, x, y, w, h, and the range of the stream's indices it draws. Kind's bit 0 says ++ // the draw is clipped to x, y, w, h and the bits above it are the draw's blend (0 ++ // source-over, 1 add, 2 copy); -1 is a switch of render target to `texture`, -2 a ++ // clear of it. ++ wasm_renderStream: ( ++ index_ptr, ++ index_len, ++ vertex_ptr, ++ vertex_len, ++ sizeof_vertex, ++ offset_pos, ++ offset_col, ++ offset_uv, ++ cmd_ptr, ++ cmd_count, ++ ) => { ++ // A stream of only switches and clears has no geometry (and an empty slice's ++ // pointer is not one to make a view at). ++ if (index_len > 0) { ++ 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.bufferData(this.gl.ARRAY_BUFFER, this.bytesFromPointer(vertex_ptr, vertex_len), this.gl.DYNAMIC_DRAW); ++ this.setLayout(sizeof_vertex, offset_pos, offset_col, offset_uv); ++ } ++ ++ const cmds = new Int32Array(this.instance.exports.memory.buffer, cmd_ptr, cmd_count * 8); ++ for (let i = 0; i < cmd_count; i += 1) { ++ const c = i * 8; ++ const textureId = cmds[c]; ++ const kind = cmds[c + 1]; ++ if (kind === -1) { ++ // A switch of render target (0 the screen). ++ this.imports.wasm_renderTarget(textureId); ++ continue; ++ } ++ if (kind === -2) { ++ // A clear of a target, wherever drawing is going. ++ this.imports.wasm_textureClearTarget(textureId); ++ continue; ++ } ++ this.setMatrix(); ++ if ((kind & 1) === 1) { ++ this.setScissor(cmds[c + 2], cmds[c + 3], cmds[c + 4], cmds[c + 5]); ++ } else { ++ this.setScissor(0, 0, this.renderTargetSize[0], this.renderTargetSize[1]); ++ } ++ const tex = textureId != 0 ? this.textureEntry(textureId) : null; ++ if (textureId != 0 && tex === null) console.warn(`wasm_renderStream: missing texture id ${textureId}`); ++ if (tex !== null) { ++ this.bindTex(tex[0]); ++ this.setUseTex(1); ++ } else { ++ this.setUseTex(0); ++ } ++ // The draw's blend as it was when drawn (bits above the clip's). ++ this.setBlend(kind >> 1); ++ this.gl.drawElements(this.gl.TRIANGLES, cmds[c + 7], this.gl.UNSIGNED_SHORT, cmds[c + 6] * 2); ++ } ++ }, + 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 { diff --git a/web/patches/dvui/0009-web-custom-fragment-programs-in-the-draw-stream.patch b/web/patches/dvui/0009-web-custom-fragment-programs-in-the-draw-stream.patch new file mode 100644 index 000000000..648d90226 --- /dev/null +++ b/web/patches/dvui/0009-web-custom-fragment-programs-in-the-draw-stream.patch @@ -0,0 +1,369 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Michael Belousov +Date: Sat, 24 Oct 2026 16:15:44 -0400 +Subject: [PATCH] web: custom fragment programs in the draw stream + +A backend can compile a fragment shader of its own (wasm_programCreate) and draw parts +of a stream through it: a -3 step switches to it, with its vec4 uniforms (uData) from +the stream's uniform buffer and up to two more textures at units 1 and 2 (uTex1, +uTex2). It runs on dvui's own vertex shader and vertex layout, so dvui's geometry +feeds it unchanged. A source is written against a short prelude that builds it as +GLSL ES 3.00 or 1.00 (VARYING, TEX, FRAG_COLOR, MAX_VEC4); one starting #version is +taken as it is, on WebGL2 only. + +Programs link while frames go on where the context has KHR_parallel_shader_compile; +wasm_programStatus says when one is ready. wasm_programMaxVec4 says how many uniform +vec4s a program may use, asked of the context once. A punch blend (dst * (1 - src.a)) +joins the texture blends for a program to cut out what it covers. +--- + src/backends/web.js | 181 ++++++++++++++++++++++++++++++++++++++++++-- + 1 file changed, 175 insertions(+), 6 deletions(-) + +diff --git a/src/backends/web.js b/src/backends/web.js +index afc813b..cecf29a 100644 +--- a/src/backends/web.js ++++ b/src/backends/web.js +@@ -203,16 +203,27 @@ export class Dvui { + /// `undefined` is unknown, and sends it. + gls = {}; + /// One framebuffer per render-target texture, attached once (texture id -> framebuffer): + /// re-attaching a texture to the one shared framebuffer on every switch made the driver + /// check the framebuffer again each time. + targetFramebuffers = new Map(); + /// The matrix uniform's storage, filled in place. + matrix = new Float32Array(16); ++ /// Custom programs (`wasm_programCreate`), by id: `{ prog, loc, status, mw, mh, mfb }`. ++ /// Id 0 is dvui's own (`stock`). The program draws go through is `cur`; each keeps its own ++ /// matrix as last set, since a uniform belongs to its program. ++ programs = new Map(); ++ stock = null; ++ cur = null; ++ newProgramId = 1; ++ /// KHR_parallel_shader_compile, where there is one: a program compiles while frames go on. ++ parallelCompile = null; ++ /// The most uniform vec4s a program may have, asked of the context once. ++ programMaxVec4 = undefined; + + // The modifier bits dvui reads (web_mod_code_to_dvui), from any keyboard or mouse event. + modCode(ev) { + return (ev.metaKey << 3) + (ev.altKey << 2) + (ev.ctrlKey << 1) + (ev.shiftKey << 0); + } + + // The cached setters (`gls`): each sends its state only when it differs from what is set. + bindTex(texture) { +@@ -231,51 +242,136 @@ export class Dvui { + this.gl.scissor(x, y, w, h); + g.sx = x; g.sy = y; g.sw = w; g.sh = h; + } + setViewport(w, h) { + if (this.gls.vw === w && this.gls.vh === h) return; + this.gl.viewport(0, 0, w, h); + this.gls.vw = w; this.gls.vh = h; + } +- /// 0 source-over, 1 add, 2 copy (`wasm_textureBlend`). ++ /// 0 source-over, 1 add, 2 copy (`wasm_textureBlend`), 3 punch — `dst · (1 − src.a)`, a ++ /// program's. + setBlend(mode) { + if (this.gls.blend === mode) return; + if (mode === 2) { + this.gl.disable(this.gl.BLEND); + } else { + if (this.gls.blend === 2 || this.gls.blend === undefined) this.gl.enable(this.gl.BLEND); + if (mode === 1) this.gl.blendFunc(this.gl.ONE, this.gl.ONE); ++ else if (mode === 3) this.gl.blendFunc(this.gl.ZERO, this.gl.ONE_MINUS_SRC_ALPHA); + else this.gl.blendFunc(this.gl.ONE, this.gl.ONE_MINUS_SRC_ALPHA); + } + this.gls.blend = mode; + } + setUseTex(on) { + if (this.gls.useTex === on) return; + this.gl.uniform1i(this.programInfo.uniformLocations.useTex, on); + this.gls.useTex = on; + } ++ /// Draw through program `id` (0 dvui's own): its uniforms `data`, and `tex1`, `tex2` (texture ++ /// ids, 0 none) at units 1 and 2. Leaving a program unbinds what it read there: a texture ++ /// still bound to a unit while it is the target being drawn into is a feedback loop to some ++ /// drivers. ++ useProgram(id, data, tex1, tex2) { ++ const p = id === 0 ? this.stock : this.programs.get(id); ++ if (p === undefined || p === null || p.status !== 2) return false; ++ const gl = this.gl; ++ if (this.cur !== p) { ++ if (this.cur !== this.stock && this.cur !== null) { ++ for (const unit of [1, 2]) { ++ gl.activeTexture(gl.TEXTURE0 + unit); ++ gl.bindTexture(gl.TEXTURE_2D, null); ++ } ++ gl.activeTexture(gl.TEXTURE0); ++ } ++ gl.useProgram(p.prog); ++ this.cur = p; ++ } ++ if (p === this.stock) return true; ++ if (data !== null && data.length > 0 && p.loc.data !== null) gl.uniform4fv(p.loc.data, data); ++ const units = [tex1, tex2]; ++ for (let u = 0; u < 2; u += 1) { ++ if (units[u] === 0) continue; ++ const entry = this.textureEntry(units[u]); ++ gl.activeTexture(gl.TEXTURE1 + u); ++ gl.bindTexture(gl.TEXTURE_2D, entry === null ? null : entry[0]); ++ } ++ gl.activeTexture(gl.TEXTURE0); ++ return true; ++ } + /// The projection for the current render target: its size, and flipped for the screen. + setMatrix() { + const w = this.renderTargetSize[0], h = this.renderTargetSize[1], fb = this.using_fb; ++ const p = this.cur; ++ if (p !== this.stock && p !== null) { ++ if (p.mw === w && p.mh === h && p.mfb === fb) return; ++ this.fillMatrix(w, h, fb); ++ this.gl.uniformMatrix4fv(p.loc.matrix, false, this.matrix); ++ p.mw = w; p.mh = h; p.mfb = fb; ++ return; ++ } + if (this.gls.mw === w && this.gls.mh === h && this.gls.mfb === fb) return; + this.fillMatrix(w, h, fb); + this.gl.uniformMatrix4fv(this.programInfo.uniformLocations.matrix, false, this.matrix); + this.gls.mw = w; this.gls.mh = h; this.gls.mfb = fb; + } + fillMatrix(w, h, fb) { + const m = this.matrix; + m.fill(0); + m[0] = 2.0 / w; + m[5] = (fb ? 2.0 : -2.0) / h; + m[10] = 1.0; + m[12] = -1.0; + m[13] = fb ? -1.0 : 1.0; + m[15] = 1.0; + } ++ queryProgramMaxVec4() { ++ if (!this.webgl2) { ++ // Pixel positions need a full float: no program where the fragment ++ // shader has no highp. ++ const hp = this.gl.getShaderPrecisionFormat(this.gl.FRAGMENT_SHADER, this.gl.HIGH_FLOAT); ++ if (hp === null || hp.precision < 23) return 0; ++ } ++ // What dvui's own uniforms leave of the guaranteed fragment budget. ++ return Math.max(0, this.gl.getParameter(this.gl.MAX_FRAGMENT_UNIFORM_VECTORS) - 8); ++ } ++ /// The prelude a program's source is written against: one source, built as GLSL ES 3.00 or ++ /// 1.00. ++ programPrelude(uniform_vec4s) { ++ if (this.webgl2) { ++ return "#version 300 es\nprecision highp float;\n#define VARYING in\n#define TEX texture\n" + ++ "out vec4 dvui_frag_color;\n#define FRAG_COLOR dvui_frag_color\n#define MAX_VEC4 " + uniform_vec4s + "\n"; ++ } ++ return "precision highp float;\n#define VARYING varying\n#define TEX texture2D\n" + ++ "#define FRAG_COLOR gl_FragColor\n#define MAX_VEC4 " + uniform_vec4s + "\n"; ++ } ++ /// A program's link has finished (or there is no way to ask, and it is done): look up what it ++ /// needs, or log why it failed. ++ programLinked(p) { ++ const gl = this.gl; ++ if (!gl.getProgramParameter(p.prog, gl.LINK_STATUS)) { ++ console.error("dvui: program failed: " + gl.getProgramInfoLog(p.prog) + "\n" + ++ (gl.getShaderInfoLog(p.fs) || "")); ++ p.status = 0; ++ return; ++ } ++ p.loc = { ++ matrix: gl.getUniformLocation(p.prog, "uMatrix"), ++ data: gl.getUniformLocation(p.prog, "uData"), ++ }; ++ const prev = this.cur; ++ gl.useProgram(p.prog); ++ const units = ["uSampler", "uTex1", "uTex2"]; ++ for (let u = 0; u < units.length; u += 1) { ++ const l = gl.getUniformLocation(p.prog, units[u]); ++ if (l !== null) gl.uniform1i(l, u); ++ } ++ gl.useProgram(prev === null ? this.stock.prog : prev.prog); ++ p.status = 2; ++ } + /// The vertex layout, which only changes if the vertex type does. + setLayout(sizeof_vertex, offset_pos, offset_col, offset_uv) { + const g = this.gls; + if (g.ls === sizeof_vertex && g.lp === offset_pos && g.lc === offset_col && g.lu === offset_uv) return; + const a = this.programInfo.attribLocations; + this.gl.vertexAttribPointer(a.vertexPosition, 2, this.gl.FLOAT, false, sizeof_vertex, offset_pos); + this.gl.enableVertexAttribArray(a.vertexPosition); + this.gl.vertexAttribPointer(a.vertexColor, 4, this.gl.UNSIGNED_BYTE, false, sizeof_vertex, offset_col); +@@ -585,16 +681,70 @@ export class Dvui { + 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 = { ++ // Custom programs: a fragment shader drawn with dvui's own vertex shader and ++ // vertex layout, and up to `wasm_programMaxVec4` vec4s of uniforms (`uData`). ++ // Asked once: `getParameter` is a synchronous trip to the GPU process in Firefox, ++ // some 3.5 ms a call on a phone — asked every frame, it was the frame's worst cost. ++ wasm_programMaxVec4: () => { ++ if (this.programMaxVec4 !== undefined) return this.programMaxVec4; ++ this.programMaxVec4 = this.queryProgramMaxVec4(); ++ return this.programMaxVec4; ++ }, ++ wasm_programCreate: (ptr, len, textures, uniform_vec4s) => { ++ const gl = this.gl; ++ const body = this.stringFromPointer(ptr, len); ++ // A complete source (one starting `#version`, GLSL ES 3.00) is compiled as it is, ++ // on WebGL2 only. On WebGL1 it fails, and whoever drew with it falls back. ++ const complete = body.startsWith("#version"); ++ if (complete && !this.webgl2) { ++ const id = this.newProgramId; ++ this.newProgramId += 1; ++ this.programs.set(id, { prog: null, fs: null, loc: null, status: 0 }); ++ return id; ++ } ++ const src = complete ? body : this.programPrelude(uniform_vec4s) + body; ++ const vs = gl.createShader(gl.VERTEX_SHADER); ++ gl.shaderSource(vs, this.webgl2 ? vertexShaderSource_webgl2 : vertexShaderSource_webgl); ++ gl.compileShader(vs); ++ const fs = gl.createShader(gl.FRAGMENT_SHADER); ++ gl.shaderSource(fs, src); ++ gl.compileShader(fs); ++ const prog = gl.createProgram(); ++ gl.attachShader(prog, vs); ++ gl.attachShader(prog, fs); ++ // The stock program's attribute slots, so the vertex layout set once ++ // (`setLayout`) feeds every program. ++ const a = this.programInfo.attribLocations; ++ gl.bindAttribLocation(prog, a.vertexPosition, "aVertexPosition"); ++ gl.bindAttribLocation(prog, a.vertexColor, "aVertexColor"); ++ gl.bindAttribLocation(prog, a.textureCoord, "aTextureCoord"); ++ gl.linkProgram(prog); ++ const id = this.newProgramId; ++ this.newProgramId += 1; ++ const p = { prog, fs, loc: null, status: 1, mw: undefined, mh: undefined, mfb: undefined }; ++ this.programs.set(id, p); ++ if (this.parallelCompile === null) this.programLinked(p); ++ return id; ++ }, ++ // 0 failed, 1 compiling, 2 ready to draw with. ++ wasm_programStatus: (id) => { ++ const p = this.programs.get(id); ++ if (p === undefined) return 0; ++ if (p.status === 1 && this.gl.getProgramParameter(p.prog, this.parallelCompile.COMPLETION_STATUS_KHR)) { ++ this.programLinked(p); ++ } ++ return p.status; ++ }, + wasm_about_webgl2: () => { + if (this.webgl2) { + return 1; + } else { + return 0; + } + }, + wasm_panic: (ptr, len) => { +@@ -937,30 +1087,35 @@ export class Dvui { + } + + this.gl.drawElements(this.gl.TRIANGLES, indices.length, this.gl.UNSIGNED_SHORT, 0); + }, + // A flush's worth of draws, for a backend that batches them: the stream's geometry, + // uploaded once, and its steps in order — each `cmd_count` entry eight i32s: texture, + // kind, x, y, w, h, and the range of the stream's indices it draws. Kind's bit 0 says + // the draw is clipped to x, y, w, h and the bits above it are the draw's blend (0 +- // source-over, 1 add, 2 copy); -1 is a switch of render target to `texture`, -2 a +- // clear of it. ++ // source-over, 1 add, 2 copy, 3 punch); -1 is a switch of render target to `texture`, ++ // -2 a clear of it, -3 a switch of program to `texture` (0 dvui's own), x and y the ++ // offset and count of its vec4s in the stream's uniforms, w and h the textures it ++ // reads at units 1 and 2. Each stream starts and ends in dvui's own program. + wasm_renderStream: ( + index_ptr, + index_len, + vertex_ptr, + vertex_len, + sizeof_vertex, + offset_pos, + offset_col, + offset_uv, + cmd_ptr, + cmd_count, ++ uniform_ptr, ++ uniform_floats, + ) => { ++ const uniforms = uniform_floats > 0 ? new Float32Array(this.instance.exports.memory.buffer, uniform_ptr, uniform_floats) : null; + // A stream of only switches and clears has no geometry (and an empty slice's + // pointer is not one to make a view at). + if (index_len > 0) { + 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.bufferData(this.gl.ARRAY_BUFFER, this.bytesFromPointer(vertex_ptr, vertex_len), this.gl.DYNAMIC_DRAW); + this.setLayout(sizeof_vertex, offset_pos, offset_col, offset_uv); + } +@@ -975,34 +1130,44 @@ export class Dvui { + this.imports.wasm_renderTarget(textureId); + continue; + } + if (kind === -2) { + // A clear of a target, wherever drawing is going. + this.imports.wasm_textureClearTarget(textureId); + continue; + } ++ if (kind === -3) { ++ // A switch of program, with its uniforms and textures. ++ const at = cmds[c + 2] * 4, n = cmds[c + 3] * 4; ++ const data = textureId !== 0 && uniforms !== null ? uniforms.subarray(at, at + n) : null; ++ if (!this.useProgram(textureId, data, cmds[c + 4], cmds[c + 5])) this.useProgram(0, null, 0, 0); ++ continue; ++ } + this.setMatrix(); + if ((kind & 1) === 1) { + this.setScissor(cmds[c + 2], cmds[c + 3], cmds[c + 4], cmds[c + 5]); + } else { + this.setScissor(0, 0, this.renderTargetSize[0], this.renderTargetSize[1]); + } + const tex = textureId != 0 ? this.textureEntry(textureId) : null; + if (textureId != 0 && tex === null) console.warn(`wasm_renderStream: missing texture id ${textureId}`); ++ const stock = this.cur === this.stock; + if (tex !== null) { + this.bindTex(tex[0]); +- this.setUseTex(1); +- } else { ++ if (stock) this.setUseTex(1); ++ } else if (stock) { + this.setUseTex(0); + } +- // The draw's blend as it was when drawn (bits above the clip's). ++ // The draw's blend as it was when drawn (bits above the clip's): its ++ // texture's, or the program's. + this.setBlend(kind >> 1); + this.gl.drawElements(this.gl.TRIANGLES, cmds[c + 7], this.gl.UNSIGNED_SHORT, cmds[c + 6] * 2); + } ++ if (this.cur !== this.stock) this.useProgram(0, null, 0, 0); + }, + 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]; +@@ -1290,16 +1455,20 @@ export class Dvui { + // Bound for good: there is one program, one pair of buffers and one sampler, so a draw + // sends only its data and whatever else changed (`gls`). + this.gl.useProgram(this.shaderProgram); + this.gl.bindBuffer(this.gl.ELEMENT_ARRAY_BUFFER, this.indexBuffer); + this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.vertexBuffer); + this.gl.activeTexture(this.gl.TEXTURE0); + this.gl.uniform1i(this.programInfo.uniformLocations.uSampler, 0); + ++ this.stock = { prog: this.shaderProgram, loc: null, status: 2 }; ++ this.cur = this.stock; ++ this.parallelCompile = this.gl.getExtension("KHR_parallel_shader_compile"); ++ + this.gls = {}; + this.setBlend(0); + this.gl.enable(this.gl.SCISSOR_TEST); + this.setScissor( + 0, + 0, + this.gl.canvas.clientWidth, + this.gl.canvas.clientHeight, diff --git a/web/web.js b/web/web.js deleted file mode 100644 index 31b169a43..000000000 --- a/web/web.js +++ /dev/null @@ -1,1813 +0,0 @@ -/// - -/**@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()); -} - -/** - * @param {string} accept Maps to the accept attribute of the file input element - * @param {boolean} multiple - * @returns {Promise} - */ -async function dvui_open_file_picker(accept, multiple) { - return new Promise((res, rej) => { - const file_input = document.createElement("input"); - file_input.setAttribute("type", "file"); - file_input.setAttribute("accept", accept); - if (multiple) { - file_input.toggleAttribute("multiple", true); - } - file_input.oncancel = () => { - console.trace("File picking cancelled"); - rej("canceled"); - }; - file_input.onchange = () => { - if (file_input.files.length === 0) { - console.error("File picker picked no files"); - file_input.oncancel(); - } - if (!multiple && file_input.files.length > 1) { - console.error("Picked multiple files in single file picker"); - rej("Too many files"); - return; - } - res(file_input.files); - }; - file_input.click(); - }); -} - -// highp, not mediump: on GPUs where mediump is a real 16-bit float (Apple Silicon under -// ANGLE/Metal, most mobile) a texture coordinate at 2048px wide has ~2 texels of resolution, -// and dvui's blur kernels are sub-texel offsets. Vertex shaders always have highp. -const vertexShaderSource_webgl = ` - precision highp 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 highp 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 = ` -#ifdef GL_FRAGMENT_PRECISION_HIGH - precision highp float; -#else - precision mediump float; -#endif - - 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 highp 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` - * @returns {Promise} - */ -export function dvui(canvas, wasmRef) { - const dvui = new Dvui(); - const wasmPromise = typeof wasmRef === "string" - ? WebAssembly.instantiateStreaming(fetch(wasmRef), { dvui: dvui.imports }) - : Promise.resolve(wasmRef); - return wasmPromise.then((result) => { - dvui.setInstance(result.instance); - dvui.setCanvas(canvas); - dvui.run(); - return dvui; - }); -} - -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(); - /// texture id -> blend mode set by wasm_textureBlend: 0 source-over (the default), 1 add, - /// 2 copy. WebGL blend state is global, so it is applied per draw when the texture binds. - textureBlends = new Map(); - /// Whether half-float render targets are available (WebGL2 + EXT_color_buffer_float). - preciseTargets = false; - preciseChecked = false; - newTextureId = 1; - /// GL state as last set, so a draw sends only what changed. Every GL call is a trip to the - /// GPU process in some browsers (Firefox), and a draw sent the whole state each time — - /// program, layout, texture, blend, matrix, scissor — some 25 calls where 3 or 4 change. - /// `undefined` is unknown, and sends it. - gls = {}; - /// One framebuffer per render-target texture, attached once (texture id -> framebuffer): - /// re-attaching a texture to the one shared framebuffer on every switch made the driver - /// check the framebuffer again each time. - targetFramebuffers = new Map(); - /// The matrix uniform's storage, filled in place. - matrix = new Float32Array(16); - /// Custom programs (`wasm_programCreate`), by id: `{ prog, loc, status, mw, mh, mfb }`. - /// Id 0 is dvui's own (`stock`). The program draws go through is `cur`; each keeps its own - /// matrix as last set, since a uniform belongs to its program. - programs = new Map(); - stock = null; - cur = null; - newProgramId = 1; - /// KHR_parallel_shader_compile, where there is one: a program compiles while frames go on. - parallelCompile = null; - /// The most uniform vec4s a program may have, asked of the context once. - programMaxVec4 = undefined; - - /** @returns {[WebGLTexture, number, number] | null} */ - // The modifier bits dvui reads (web_mod_code_to_dvui), from any keyboard or mouse event. - modCode(ev) { - return (ev.metaKey << 3) + (ev.altKey << 2) + (ev.ctrlKey << 1) + (ev.shiftKey << 0); - } - - // The cached setters (`gls`): each sends its state only when it differs from what is set. - bindTex(texture) { - if (this.gls.tex === texture) return; - this.gl.bindTexture(this.gl.TEXTURE_2D, texture); - this.gls.tex = texture; - } - bindFb(framebuffer) { - if (this.gls.fb === framebuffer) return; - this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, framebuffer); - this.gls.fb = framebuffer; - } - setScissor(x, y, w, h) { - const g = this.gls; - if (g.sx === x && g.sy === y && g.sw === w && g.sh === h) return; - this.gl.scissor(x, y, w, h); - g.sx = x; g.sy = y; g.sw = w; g.sh = h; - } - setViewport(w, h) { - if (this.gls.vw === w && this.gls.vh === h) return; - this.gl.viewport(0, 0, w, h); - this.gls.vw = w; this.gls.vh = h; - } - /// 0 source-over, 1 add, 2 copy (`wasm_textureBlend`), 3 punch — `dst · (1 − src.a)`, a - /// program's (`core.gfx.programs.Blend`). - setBlend(mode) { - if (this.gls.blend === mode) return; - if (mode === 2) { - this.gl.disable(this.gl.BLEND); - } else { - if (this.gls.blend === 2 || this.gls.blend === undefined) this.gl.enable(this.gl.BLEND); - if (mode === 1) this.gl.blendFunc(this.gl.ONE, this.gl.ONE); - else if (mode === 3) this.gl.blendFunc(this.gl.ZERO, this.gl.ONE_MINUS_SRC_ALPHA); - else this.gl.blendFunc(this.gl.ONE, this.gl.ONE_MINUS_SRC_ALPHA); - } - this.gls.blend = mode; - } - setUseTex(on) { - if (this.gls.useTex === on) return; - this.gl.uniform1i(this.programInfo.uniformLocations.useTex, on); - this.gls.useTex = on; - } - /// Draw through program `id` (0 dvui's own): its uniforms `data`, and `tex1`, `tex2` (texture - /// ids, 0 none) at units 1 and 2. Leaving a program unbinds what it read there: a texture - /// still bound to a unit while it is the target being drawn into is a feedback loop to some - /// drivers, and the next frost capture draws into exactly those. - useProgram(id, data, tex1, tex2) { - const p = id === 0 ? this.stock : this.programs.get(id); - if (p === undefined || p === null || p.status !== 2) return false; - const gl = this.gl; - if (this.cur !== p) { - if (this.cur !== this.stock && this.cur !== null) { - for (const unit of [1, 2]) { - gl.activeTexture(gl.TEXTURE0 + unit); - gl.bindTexture(gl.TEXTURE_2D, null); - } - gl.activeTexture(gl.TEXTURE0); - } - gl.useProgram(p.prog); - this.cur = p; - } - if (p === this.stock) return true; - if (data !== null && data.length > 0 && p.loc.data !== null) gl.uniform4fv(p.loc.data, data); - const units = [tex1, tex2]; - for (let u = 0; u < 2; u += 1) { - if (units[u] === 0) continue; - const entry = this.textureEntry(units[u]); - gl.activeTexture(gl.TEXTURE1 + u); - gl.bindTexture(gl.TEXTURE_2D, entry === null ? null : entry[0]); - } - gl.activeTexture(gl.TEXTURE0); - return true; - } - /// The projection for the current render target: its size, and flipped for the screen. - setMatrix() { - const w = this.renderTargetSize[0], h = this.renderTargetSize[1], fb = this.using_fb; - const p = this.cur; - if (p !== this.stock && p !== null) { - if (p.mw === w && p.mh === h && p.mfb === fb) return; - this.fillMatrix(w, h, fb); - this.gl.uniformMatrix4fv(p.loc.matrix, false, this.matrix); - p.mw = w; p.mh = h; p.mfb = fb; - return; - } - if (this.gls.mw === w && this.gls.mh === h && this.gls.mfb === fb) return; - this.fillMatrix(w, h, fb); - this.gl.uniformMatrix4fv(this.programInfo.uniformLocations.matrix, false, this.matrix); - this.gls.mw = w; this.gls.mh = h; this.gls.mfb = fb; - } - fillMatrix(w, h, fb) { - const m = this.matrix; - m.fill(0); - m[0] = 2.0 / w; - m[5] = (fb ? 2.0 : -2.0) / h; - m[10] = 1.0; - m[12] = -1.0; - m[13] = fb ? -1.0 : 1.0; - m[15] = 1.0; - } - queryProgramMaxVec4() { - if (!this.webgl2) { - // Pixel positions need a full float: no program where the fragment - // shader has no highp. - const hp = this.gl.getShaderPrecisionFormat(this.gl.FRAGMENT_SHADER, this.gl.HIGH_FLOAT); - if (hp === null || hp.precision < 23) return 0; - } - // What dvui's own uniforms leave of the guaranteed fragment budget. - return Math.max(0, this.gl.getParameter(this.gl.MAX_FRAGMENT_UNIFORM_VECTORS) - 8); - } - /// The prelude a program's source is written against (`core.gfx.programs`): one source, - /// built as GLSL ES 3.00 or 1.00. - programPrelude(uniform_vec4s) { - if (this.webgl2) { - return "#version 300 es\nprecision highp float;\n#define VARYING in\n#define TEX texture\n" + - "out vec4 fizzy_frag_color;\n#define FRAG_COLOR fizzy_frag_color\n#define MAX_VEC4 " + uniform_vec4s + "\n"; - } - return "precision highp float;\n#define VARYING varying\n#define TEX texture2D\n" + - "#define FRAG_COLOR gl_FragColor\n#define MAX_VEC4 " + uniform_vec4s + "\n"; - } - /// A program's link has finished (or there is no way to ask, and it is done): look up what it - /// needs, or log why it failed. - programLinked(p) { - const gl = this.gl; - if (!gl.getProgramParameter(p.prog, gl.LINK_STATUS)) { - console.error("fizzy: program failed: " + gl.getProgramInfoLog(p.prog) + "\n" + - (gl.getShaderInfoLog(p.fs) || "")); - p.status = 0; - return; - } - p.loc = { - matrix: gl.getUniformLocation(p.prog, "uMatrix"), - data: gl.getUniformLocation(p.prog, "uData"), - }; - const prev = this.cur; - gl.useProgram(p.prog); - const units = ["uSampler", "uTex1", "uTex2"]; - for (let u = 0; u < units.length; u += 1) { - const l = gl.getUniformLocation(p.prog, units[u]); - if (l !== null) gl.uniform1i(l, u); - } - gl.useProgram(prev === null ? this.stock.prog : prev.prog); - p.status = 2; - } - /// The vertex layout, which only changes if the vertex type does. - setLayout(sizeof_vertex, offset_pos, offset_col, offset_uv) { - const g = this.gls; - if (g.ls === sizeof_vertex && g.lp === offset_pos && g.lc === offset_col && g.lu === offset_uv) return; - const a = this.programInfo.attribLocations; - this.gl.vertexAttribPointer(a.vertexPosition, 2, this.gl.FLOAT, false, sizeof_vertex, offset_pos); - this.gl.enableVertexAttribArray(a.vertexPosition); - this.gl.vertexAttribPointer(a.vertexColor, 4, this.gl.UNSIGNED_BYTE, false, sizeof_vertex, offset_col); - this.gl.enableVertexAttribArray(a.vertexColor); - this.gl.vertexAttribPointer(a.textureCoord, 2, this.gl.FLOAT, false, sizeof_vertex, offset_uv); - this.gl.enableVertexAttribArray(a.textureCoord); - g.ls = sizeof_vertex; g.lp = offset_pos; g.lc = offset_col; g.lu = offset_uv; - } - - createTarget(width, height, interp, wrap_u, wrap_v, precise) { - 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.bindTex(texture); - - this.gl.texImage2D( - this.gl.TEXTURE_2D, - 0, - precise ? this.gl.RGBA16F : this.gl.RGBA, - width, - height, - 0, - this.gl.RGBA, - precise ? this.gl.HALF_FLOAT : 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.bindTex(null); - - if (precise && !this.preciseChecked) { - // The extension says half-float attachments are renderable; make sure this context - // agrees before trusting every later target to it. Checked once. - this.preciseChecked = true; - const prev = this.renderTargetId; - this.imports.wasm_renderTarget(id); - const status = this.gl.checkFramebufferStatus(this.gl.FRAMEBUFFER); - this.imports.wasm_renderTarget(prev); - if (status !== this.gl.FRAMEBUFFER_COMPLETE) { - console.warn("dvui: half-float render target incomplete (status " + status + "); falling back to 8-bit targets"); - this.preciseTargets = false; - this.textures.delete(id); - this.gl.deleteTexture(texture); - return this.createTarget(width, height, interp, wrap_u, wrap_v, false); - } - } - - this.imports.wasm_textureClearTarget(id); - - return id; - } - - textureEntry(id) { - if (id === 0) return null; - return this.textures.get(id) ?? null; - } - using_fb = false; - /** Texture id bound as the render target, 0 for the screen. */ - renderTargetId = 0; - /** @type {WebGLFramebuffer | null} */ - frame_buffer = null; - /** @type {[number, number]} */ - renderTargetSize = [0, 0]; - - renderRequested = false; - renderTimeoutId = 0; - - /** @type {WebAssembly.ModuleImports} */ - imports; - - /** @type {WebAssembly.Instance} */ - instance; - stopped = false; - console_string = ""; - /** @type {HTMLInputElement} */ - hidden_input; - /** - * 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 = []; - - // 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; - } - } - - /** - * An Apple Pencil on iPad Safari arrives as a TouchEvent whose touch has `touchType` - * "stylus" (a finger's is "direct"). It is a pen, not a finger, so it goes in as the mouse: - * motion to where it is, then the left button pressed or released. dvui then counts it as - * `.mouse` (`dvui.pointerKind()`), so what it last touched stays hovered after a lift and a - * canvas treats it as a pointer, not a pan; native excludes SDL's pen touches the same way. - * The touch handlers' `preventDefault` stops the browser's own mouse events following it. - * - * @param {TouchEvent} ev - * @param {Touch} touch - * @param {1 | 2 | 3} which 1 motion only, 2 press, 3 release (`add_event`'s mouse codes) - */ - stylusEvent(ev, touch, which) { - const rect = this.gl.canvas.getBoundingClientRect(); - const x = (touch.clientX - rect.left) / (rect.right - rect.left) * - this.gl.drawingBufferWidth; - const y = (touch.clientY - rect.top) / (rect.bottom - rect.top) * - this.gl.drawingBufferHeight; - const mods = this.modCode(ev); - this.instance.exports.add_event(1, mods, 0, x, y); - if (which !== 1) this.instance.exports.add_event(which, 0, mods, 0, 0); - } - - 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 = { - // Custom programs (`WebBackend.program_api`, `core.gfx.programs`). - // Asked once: `getParameter` is a synchronous trip to the GPU process in Firefox, - // some 3.5 ms a call on a phone — asked every frame, it was the frame's worst cost. - wasm_programMaxVec4: () => { - if (this.programMaxVec4 !== undefined) return this.programMaxVec4; - this.programMaxVec4 = this.queryProgramMaxVec4(); - return this.programMaxVec4; - }, - wasm_programCreate: (ptr, len, textures, uniform_vec4s) => { - const gl = this.gl; - const body = this.stringFromPointer(ptr, len); - // A complete source — one generated from a program's HLSL, GLSL ES 3.00 - // (`scripts/compile-shaders.sh`) — is compiled as it is, on WebGL2 only. On WebGL1 - // it fails, and whoever drew with it falls back (the glass to its meshes). - const complete = body.startsWith("#version"); - if (complete && !this.webgl2) { - const id = this.newProgramId; - this.newProgramId += 1; - this.programs.set(id, { prog: null, fs: null, loc: null, status: 0 }); - return id; - } - const src = complete ? body : this.programPrelude(uniform_vec4s) + body; - const vs = gl.createShader(gl.VERTEX_SHADER); - gl.shaderSource(vs, this.webgl2 ? vertexShaderSource_webgl2 : vertexShaderSource_webgl); - gl.compileShader(vs); - const fs = gl.createShader(gl.FRAGMENT_SHADER); - gl.shaderSource(fs, src); - gl.compileShader(fs); - const prog = gl.createProgram(); - gl.attachShader(prog, vs); - gl.attachShader(prog, fs); - // The stock program's attribute slots, so the vertex layout set once - // (`setLayout`) feeds every program. - const a = this.programInfo.attribLocations; - gl.bindAttribLocation(prog, a.vertexPosition, "aVertexPosition"); - gl.bindAttribLocation(prog, a.vertexColor, "aVertexColor"); - gl.bindAttribLocation(prog, a.textureCoord, "aTextureCoord"); - gl.linkProgram(prog); - const id = this.newProgramId; - this.newProgramId += 1; - const p = { prog, fs, loc: null, status: 1, mw: undefined, mh: undefined, mfb: undefined }; - this.programs.set(id, p); - if (this.parallelCompile === null) this.programLinked(p); - return id; - }, - wasm_programStatus: (id) => { - const p = this.programs.get(id); - if (p === undefined) return 0; - if (p.status === 1 && this.gl.getProgramParameter(p.prog, this.parallelCompile.COMPLETION_STATUS_KHR)) { - this.programLinked(p); - } - return p.status; - }, - 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.bindTex(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.bindTex(null); - - return id; - }, - // Replace every pixel of a texture made by wasm_textureCreate. Returns 0 for an - // unknown texture. No mipmaps to regenerate: textures sample NEAREST/LINEAR. - wasm_textureUpdate: (id, pixels) => { - const entry = this.textures.get(id); - if (!entry) return 0; - const [texture, width, height] = entry; - this.bindTex(texture); - this.gl.texSubImage2D( - this.gl.TEXTURE_2D, 0, 0, 0, width, height, - this.gl.RGBA, this.gl.UNSIGNED_BYTE, - this.bytesFromPointer(pixels, width * height * 4), - ); - this.bindTex(null); - return 1; - }, - // Replace the x,y,w,h rect of a texture made by wasm_textureCreate. `pixels` is the - // whole texture's buffer (see dvui.Backend.textureUpdateSubRect); only the rect is - // uploaded. Returns 0 for an unknown texture. - wasm_textureUpdateSubRect: (id, pixels, x, y, w, h) => { - const entry = this.textures.get(id); - if (!entry) return 0; - const [texture, width, height] = entry; - if (w === 0 || h === 0) return 1; - const full = this.bytesFromPointer(pixels, width * height * 4); - this.bindTex(texture); - if (this.webgl2) { - // Read the rect in place out of the full buffer. - this.gl.pixelStorei(this.gl.UNPACK_ROW_LENGTH, width); - this.gl.pixelStorei(this.gl.UNPACK_SKIP_PIXELS, x); - this.gl.pixelStorei(this.gl.UNPACK_SKIP_ROWS, y); - this.gl.texSubImage2D( - this.gl.TEXTURE_2D, 0, x, y, w, h, - this.gl.RGBA, this.gl.UNSIGNED_BYTE, full, - ); - this.gl.pixelStorei(this.gl.UNPACK_ROW_LENGTH, 0); - this.gl.pixelStorei(this.gl.UNPACK_SKIP_PIXELS, 0); - this.gl.pixelStorei(this.gl.UNPACK_SKIP_ROWS, 0); - } else { - // WebGL1 has no unpack row length: copy the rect's rows out tightly. - const rect = new Uint8Array(w * h * 4); - for (let row = 0; row < h; row += 1) { - const start = ((y + row) * width + x) * 4; - rect.set(full.subarray(start, start + w * 4), row * w * 4); - } - this.gl.texSubImage2D( - this.gl.TEXTURE_2D, 0, x, y, w, h, - this.gl.RGBA, this.gl.UNSIGNED_BYTE, rect, - ); - } - this.bindTex(null); - return 1; - }, - wasm_textureCreateTarget: (width, height, interp, wrap_u, wrap_v) => { - return this.createTarget(width, height, interp, wrap_u, wrap_v, false); - }, - // A 16-bit-float-per-channel target where the context has them; the 8-bit one - // otherwise. See dvui.Backend.support_precise_targets. - wasm_textureCreatePreciseTarget: (width, height, interp, wrap_u, wrap_v) => { - return this.createTarget(width, height, interp, wrap_u, wrap_v, this.preciseTargets); - }, - // See dvui.Backend.textureBlend. Returns 0 for an unknown texture. - wasm_textureBlend: (id, mode) => { - if (this.textureEntry(id) === null) return 0; - this.textureBlends.set(id, mode); - return 1; - }, - // Restores whatever target was bound before: a target is often created and - // cleared while another one is being rendered into (a Picture inside a frame - // target), and landing on the screen there sends the rest of that drawing to the - // wrong place. - wasm_textureClearTarget: (textureId) => { - const prev = this.renderTargetId; - 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(prev); - }, - 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 prev = this.gls.fb; - this.bindFb(this.frame_buffer); - this.gl.framebufferTexture2D( - this.gl.FRAMEBUFFER, - this.gl.COLOR_ATTACHMENT0, - this.gl.TEXTURE_2D, - entry[0], - 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.bindFb(prev); - }, - wasm_renderTarget: (id) => { - //console.log("renderTarget " + id); - const rt = id === 0 ? null : this.textureEntry(id); - if (id !== 0 && rt === null) console.warn(`wasm_renderTarget: missing texture id ${id}`); - if (rt === null) { - this.renderTargetId = 0; - this.using_fb = false; - this.bindFb(null); - this.renderTargetSize = [this.gl.drawingBufferWidth, this.gl.drawingBufferHeight]; - } else { - this.renderTargetId = id; - this.using_fb = true; - let fb = this.targetFramebuffers.get(id); - if (fb === undefined) { - fb = this.gl.createFramebuffer(); - this.bindFb(fb); - this.gl.framebufferTexture2D(this.gl.FRAMEBUFFER, this.gl.COLOR_ATTACHMENT0, this.gl.TEXTURE_2D, rt[0], 0); - this.targetFramebuffers.set(id, fb); - } else { - this.bindFb(fb); - } - this.renderTargetSize = [rt[1], rt[2]]; - } - this.setViewport(this.renderTargetSize[0], this.renderTargetSize[1]); - this.setScissor(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.textureBlends.delete(id); - const fb = this.targetFramebuffers.get(id); - if (fb !== undefined) { - if (this.gls.fb === fb) this.bindFb(null); - this.gl.deleteFramebuffer(fb); - this.targetFramebuffers.delete(id); - } - if (this.gls.tex === entry[0]) this.bindTex(null); - this.gl.deleteTexture(entry[0]); - }, - // A flush's worth of draws (`WebBackend.flushBatch`): the stream's geometry, uploaded - // once, and its steps in order — each `cmd_count` entry eight i32s: texture, clip, - // x, y, w, h, and the range of the stream's indices it draws. Clip's bit 0 says the - // draw is clipped and the bits above it are the texture's blend (0 source-over, - // 1 add, 2 copy); -1 is a switch of render target to `texture`, -2 a clear of it. - wasm_renderStream: ( - index_ptr, - index_len, - vertex_ptr, - vertex_len, - sizeof_vertex, - offset_pos, - offset_col, - offset_uv, - cmd_ptr, - cmd_count, - uniform_ptr, - uniform_floats, - ) => { - const uniforms = uniform_floats > 0 ? new Float32Array(this.instance.exports.memory.buffer, uniform_ptr, uniform_floats) : null; - // A stream of only switches and clears has no geometry (and an empty slice's - // pointer is not one to make a view at). - if (index_len > 0) { - 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.bufferData(this.gl.ARRAY_BUFFER, this.bytesFromPointer(vertex_ptr, vertex_len), this.gl.DYNAMIC_DRAW); - this.setLayout(sizeof_vertex, offset_pos, offset_col, offset_uv); - } - - const cmds = new Int32Array(this.instance.exports.memory.buffer, cmd_ptr, cmd_count * 8); - for (let i = 0; i < cmd_count; i += 1) { - const c = i * 8; - const textureId = cmds[c]; - const kind = cmds[c + 1]; - if (kind === -1) { - // A switch of render target (0 the screen). - this.imports.wasm_renderTarget(textureId); - continue; - } - if (kind === -2) { - // A clear of a target, wherever drawing is going. - this.imports.wasm_textureClearTarget(textureId); - continue; - } - if (kind === -3) { - // A switch of program, with its uniforms and textures. - const at = cmds[c + 2] * 4, n = cmds[c + 3] * 4; - const data = textureId !== 0 && uniforms !== null ? uniforms.subarray(at, at + n) : null; - if (!this.useProgram(textureId, data, cmds[c + 4], cmds[c + 5])) this.useProgram(0, null, 0, 0); - continue; - } - this.setMatrix(); - if ((kind & 1) === 1) { - this.setScissor(cmds[c + 2], cmds[c + 3], cmds[c + 4], cmds[c + 5]); - } else { - this.setScissor(0, 0, this.renderTargetSize[0], this.renderTargetSize[1]); - } - const tex = textureId != 0 ? this.textureEntry(textureId) : null; - if (textureId != 0 && tex === null) console.warn(`wasm_renderStream: missing texture id ${textureId}`); - const stock = this.cur === this.stock; - if (tex !== null) { - this.bindTex(tex[0]); - if (stock) this.setUseTex(1); - } else if (stock) { - this.setUseTex(0); - } - // The draw's blend as it was when drawn (bits above the clip's): its - // texture's, or the program's. - this.setBlend(kind >> 1); - this.gl.drawElements(this.gl.TRIANGLES, cmds[c + 7], this.gl.UNSIGNED_SHORT, cmds[c + 6] * 2); - } - // Each stream starts in dvui's own program (`WebBackend.flushBatch`). - if (this.cur !== this.stock) this.useProgram(0, null, 0, 0); - }, - 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, - ); - }); - }, - }; - } - - setInstance(instance) { - this.instance = instance; - } - - setCanvas(canvasSelectorOrCanvasElement) { - /** @type {HTMLCanvasElement | null} */ - const canvas = - canvasSelectorOrCanvasElement instanceof HTMLCanvasElement - ? canvasSelectorOrCanvasElement - : document.querySelector(canvasSelectorOrCanvasElement); - - if (!canvas) { - alert("Could not find canvas element."); - return; - } - - if (!canvas.style.width || !canvas.style.height) { - // Needed so that the canvas element can scale and report its size correctly. - // Absolute and relative length are both valid. Setting both to 100% is - // probably what you want. - console.error( - "Canvas element does not have defined width and height inline styles", - ); - } - - // 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.gl === null) { - alert("Unable to initialize WebGL."); - return; - } - - // Half-float render targets for dvui's blur pyramid (Texture.CreateOptions.precision): - // WebGL2 samples RGBA16F with linear filtering as standard; rendering into it needs the - // extension. Without it the precise target is the plain 8-bit one. - this.preciseTargets = this.webgl2 && - (this.gl.getExtension("EXT_color_buffer_float") !== null || - this.gl.getExtension("EXT_color_buffer_half_float") !== null); - console.log("dvui: precise (half-float) render targets " + (this.preciseTargets ? "available" : "unavailable (" + (this.webgl2 ? "no EXT_color_buffer_(half_)float" : "WebGL1") + ")")); - - 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; - } - } - this.frame_buffer = this.gl.createFramebuffer(); - - 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; - } - - const fragmentShader = this.gl.createShader(this.gl.FRAGMENT_SHADER); - if (this.webgl2) { - this.gl.shaderSource(fragmentShader, fragmentShaderSource_webgl2); - } else { - this.gl.shaderSource(fragmentShader, fragmentShaderSource_webgl); - } - this.gl.compileShader(fragmentShader); - if ( - !this.gl.getShaderParameter(fragmentShader, this.gl.COMPILE_STATUS) - ) { - alert( - `Error compiling fragment shader: ${this.gl.getShaderInfoLog(fragmentShader) - }`, - ); - this.gl.deleteShader(fragmentShader); - return null; - } - - this.shaderProgram = this.gl.createProgram(); - this.gl.attachShader(this.shaderProgram, vertexShader); - this.gl.attachShader(this.shaderProgram, fragmentShader); - this.gl.linkProgram(this.shaderProgram); - - if ( - !this.gl.getProgramParameter( - this.shaderProgram, - this.gl.LINK_STATUS, - ) - ) { - alert( - `Error initializing shader program: ${this.gl.getProgramInfoLog(this.shaderProgram) - }`, - ); - return null; - } - - 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", - ), - }, - }; - - this.indexBuffer = this.gl.createBuffer(); - this.vertexBuffer = this.gl.createBuffer(); - - // Bound for good: there is one program, one pair of buffers and one sampler, so a draw - // sends only its data and whatever else changed (`gls`). - this.gl.useProgram(this.shaderProgram); - this.gl.bindBuffer(this.gl.ELEMENT_ARRAY_BUFFER, this.indexBuffer); - this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.vertexBuffer); - this.gl.activeTexture(this.gl.TEXTURE0); - this.gl.uniform1i(this.programInfo.uniformLocations.uSampler, 0); - - this.stock = { prog: this.shaderProgram, loc: null, status: 2 }; - this.cur = this.stock; - this.parallelCompile = this.gl.getExtension("KHR_parallel_shader_compile"); - - this.gls = {}; - this.setBlend(0); - this.gl.enable(this.gl.SCISSOR_TEST); - this.setScissor( - 0, - 0, - this.gl.canvas.clientWidth, - this.gl.canvas.clientHeight, - ); - } - - init() { - let dvui_init_return = 0; - let str = utf8encoder.encode(navigator.platform); - if (str.length > 0) { - const ptr = this.allocBuffer(this.instance.exports.gpa_u8, str); - dvui_init_return = this.instance.exports.dvui_init( - ptr, - str.length, - ); - this.instance.exports.gpa_free(ptr, str.length); - } else { - dvui_init_return = this.instance.exports.dvui_init( - 0, - 0, - ); - } - - if (dvui_init_return != 0) { - throw new Error("ERROR: dvui_init returned " + dvui_init_return); - } - } - - requestRender() { - if (this.stopped) return; - - if (this.renderTimeoutId > 0) { - // we got called before the timeout happened - clearTimeout(this.renderTimeoutId); - this.renderTimeoutId = 0; - } - - if (!this.renderRequested) { - // multiple events could call requestRender multiple times, and - // we only want a single requestAnimationFrame to happen before - // each call to dvui_update - this.renderRequested = true; - requestAnimationFrame(this.render.bind(this)); - } - } - - // For debugging. Turns off dvui rendering (zig code stops running). - stop() { - if (this.renderTimeoutId > 0) { - clearTimeout(this.renderTimeoutId); - this.renderTimeoutId = 0; - } - - this.renderRequested = false; - this.stopped = true; - } - - // For debugging. Turn back on dvui rendering (zig code starts running). - restart() { - if (!this.stopped) { - console.log("dvui.restart() called when not stopped"); - } - this.stopped = false; - this.requestRender(); - } - - render() { - if (this.stopped) return; - - this.renderRequested = false; - - // if the canvas changed size, adjust the backing buffer - const w = this.gl.canvas.clientWidth; - const h = this.gl.canvas.clientHeight; - const scale = window.devicePixelRatio; - //console.log("wxh " + w + "x" + h + " scale " + scale); - // Only when it changed: setting either one, even to what it already is, makes a new - // drawing buffer. - const cw = Math.round(w * scale); - const ch = Math.round(h * scale); - if (this.gl.canvas.width !== cw) this.gl.canvas.width = cw; - if (this.gl.canvas.height !== ch) this.gl.canvas.height = ch; - // Every frame starts on the screen; the backend's stream counts on it. - this.imports.wasm_renderTarget(0); - - this.gl.clearColor(0.0, 0.0, 0.0, 1.0); // Clear to black, fully opaque - this.gl.clear(this.gl.COLOR_BUFFER_BIT); - - let millis_to_wait = this.instance.exports.dvui_update(); - - // This oskCheck is for desktop to get text events. Touch devices will - // show/hide the keyboard (but not all, see touchend handler). - this.oskCheck(); - - if (!this.filesCacheModified) { - // Only clear if we didn't add anything this frame. Async could add items after they were requested - // in the frame, so keep if for two frames - this.filesCache.clear(); - } - this.filesCacheModified = false; - - if (millis_to_wait < 0) { - this.stop(); - } else if (millis_to_wait == 0) { - this.requestRender(); - } else if (millis_to_wait > 0) { - this.renderTimeoutId = setTimeout( - function() { - this.renderTimeoutId = 0; - this.requestRender(); - }.bind(this), - millis_to_wait, - ); - } - } - - run() { - if (!this.instance) { - throw new Error( - "Missing wasm instance, did you forget to call `setInstance`?", - ); - } - if (!this.gl) { - throw new Error( - "Missing rendering context, did you forget to call `setCanvas`?", - ); - } - this.init(); - - // event listeners - this.gl.canvas.addEventListener("contextmenu", (ev) => { - ev.preventDefault(); - }); - window.addEventListener("resize", (ev) => { - this.requestRender(); - }); - if (this.gl.canvas instanceof HTMLCanvasElement) { - const resizeObserver = new ResizeObserver(() => { - this.requestRender(); - }); - resizeObserver.observe(this.gl.canvas); - } - this.gl.canvas.addEventListener("mousemove", (ev) => { - if (this.stopped) return; - let rect = this.gl.canvas.getBoundingClientRect(); - let x = (ev.clientX - rect.left) / (rect.right - rect.left) * - this.gl.drawingBufferWidth; - let y = (ev.clientY - rect.top) / (rect.bottom - rect.top) * - this.gl.drawingBufferHeight; - // The event's own modifier state, not the last key event's: a modifier whose keyup - // was lost (cmd-tab away, a popup taking focus) would otherwise stick to every - // click until the next key event. - this.instance.exports.add_event(1, this.modCode(ev), 0, x, y); - this.requestRender(); - }); - this.gl.canvas.addEventListener("mousedown", (ev) => { - if (this.stopped) return; - this.instance.exports.add_event(2, ev.button, this.modCode(ev), 0, 0); - this.requestRender(); - }); - this.gl.canvas.addEventListener("mouseup", (ev) => { - if (this.stopped) return; - this.instance.exports.add_event(3, ev.button, this.modCode(ev), 0, 0); - this.requestRender(); - }); - this.gl.canvas.addEventListener("wheel", (ev) => { - 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); - } - this.instance.exports.add_event( - 4, - 1, - trackpad, - ticks, - 0, - ); - } - this.requestRender(); - }); - - let keydown = (ev) => { - if (this.stopped) return; - if (ev.key == "Tab") { - - // In most browsers we don't even see a control-tab, the - // browser uses it to switch tabs. But Vivaldi sends it to us - // first, so bail (before preventDefault) so that Vivaldi - // switches tabs and we don't do anything. - if (ev.ctrlKey) return; - - // stop tab from tabbing away from the canvas - ev.preventDefault(); - } - - let str = utf8encoder.encode(ev.key); - if (str.length > 0) { - const ptr = this.allocBuffer(this.instance.exports.arena_u8, str) - this.instance.exports.add_event( - 5, - ptr, - str.length, - ev.repeat, - (ev.metaKey << 3) + (ev.altKey << 2) + - (ev.ctrlKey << 1) + (ev.shiftKey << 0), - ); - this.requestRender(); - } - }; - this.gl.canvas.addEventListener("keydown", keydown.bind(this)); - this.hidden_input.addEventListener("keydown", keydown.bind(this)); - - let keyup = (ev) => { - if (this.stopped) return; - const str = utf8encoder.encode(ev.key); - const ptr = this.allocBuffer(this.instance.exports.arena_u8, str); - this.instance.exports.add_event( - 6, - ptr, - str.length, - 0, - (ev.metaKey << 3) + (ev.altKey << 2) + (ev.ctrlKey << 1) + - (ev.shiftKey << 0), - ); - this.requestRender(); - }; - this.gl.canvas.addEventListener("keyup", keyup.bind(this)); - this.hidden_input.addEventListener("keyup", keyup.bind(this)); - - this.hidden_input.addEventListener("beforeinput", (ev) => { - if (this.stopped) return; - ev.preventDefault(); - if (ev.data && !ev.isComposing) { - const str = utf8encoder.encode(ev.data); - const ptr = this.allocBuffer(this.instance.exports.arena_u8, str); - this.instance.exports.add_event( - 7, - ptr, - str.length, - 0, - 0, - ); - this.requestRender(); - } - }); - this.hidden_input.addEventListener("compositionend", (ev) => { - if (this.stopped) return; - if (ev.data) { - const str = utf8encoder.encode(ev.data); - const ptr = this.allocBuffer(this.instance.exports.arena_u8, str); - this.instance.exports.add_event( - 7, - ptr, - str.length, - 0, - 0, - ); - this.requestRender(); - } - // Reset value to empty after composition maybe put text there - ev.target.value = ""; - }); - this.gl.canvas.addEventListener("touchstart", (ev) => { - if (this.stopped) return; - ev.preventDefault(); - let rect = this.gl.canvas.getBoundingClientRect(); - for (let i = 0; i < ev.changedTouches.length; i++) { - let touch = ev.changedTouches[i]; - if (touch.touchType === "stylus") { - this.stylusEvent(ev, touch, 2); - continue; - } - 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); - this.instance.exports.add_event( - 8, - this.touches[tidx][1], - 0, - x, - y, - ); - } - this.requestRender(); - }); - const touchEnd = (ev) => { - if (this.stopped) return; - ev.preventDefault(); - let rect = this.gl.canvas.getBoundingClientRect(); - for (let i = 0; i < ev.changedTouches.length; i++) { - let touch = ev.changedTouches[i]; - if (touch.touchType === "stylus") { - this.stylusEvent(ev, touch, 3); - continue; - } - 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); - this.instance.exports.add_event( - 9, - this.touches[tidx][1], - 0, - x, - y, - ); - this.touches.splice(tidx, 1); - } - // This oskCheck 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.requestRender(); - }; - this.gl.canvas.addEventListener("touchend", touchEnd); - // A cancelled touch (the system took it: a gesture, an alert) ends like a lifted one, - // or its button, or the pencil's mouse button, would stay held. - this.gl.canvas.addEventListener("touchcancel", touchEnd); - this.gl.canvas.addEventListener("touchmove", (ev) => { - if (this.stopped) return; - ev.preventDefault(); - let rect = this.gl.canvas.getBoundingClientRect(); - for (let i = 0; i < ev.changedTouches.length; i++) { - let touch = ev.changedTouches[i]; - if (touch.touchType === "stylus") { - this.stylusEvent(ev, touch, 1); - continue; - } - 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); - this.instance.exports.add_event( - 10, - this.touches[tidx][1], - 0, - x, - y, - ); - } - this.requestRender(); - }); - // start the first update - this.requestRender(); - } -}