GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
W3: run an Application in a browser page
src/web/shell.rs is the browser shell, the Wayland shell's counterpart
over a <canvas>, on the same Driver and Pacer. Pointer (captured on
press), wheel, key and focus events are mapped into driver calls by
backend::dom (portable and tested natively): a key carries the text
xkb's utf8 would — Tab "\t", Ctrl+letter its control code — since that
is what widgets have always been handed; a whole notch-sized pixel
delta is a wheel notch, anything else a finger, whose lift is
synthesized after 120 ms. The browser's key repeats are dropped (the
driver repeats). Turns run on animation frames at the pacer's active
cadence and on a timer at its idle one; any event, and any
AppSender::send (backend::app::set_wake), wakes the loop. Sizing::App
sizes the canvas from the app, Sizing::Page leaves it to the page.
A page has no font directory and no fontconfig, so web::Fonts carries
the files and the generic families, and on wasm every font database
the toolkit builds loads them (lib::page_fonts): the shell's, the
widget-geometry one and resvg's measuring one. cosmic-text has no
family fallback list on wasm, so the families the toolkit names are
given the faces Linux's list would fall back to.
web::capture() reads the next frame back from the GPU: headless
Chromium leaves a WebGPU canvas out of its screenshots.
examples/demo_web.rs runs src/main.rs's DemoApp in a page, and
scripts/web-probe/demo replays the native harness's 24-step input
script against it. Against the native run (lavapipe vs SwiftShader,
same fonts and generic families): steps 00-18 differ only at the
slider band's two pointed tips, a rasterizer tie-break; idle is 5
turns in 5 s, as native. Held-key steps get fewer repeats, as
SwiftShader takes ~250 ms a frame and the driver repeats once a turn.
Native is unchanged: the same two known test failures (527 pass with
three new), the plate golden byte-identical, the 24-step run identical
to the pixel.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WjL3pejMNY95NHv9BcmXaZ
CLAUDE.md | 69 ++++-
Cargo.toml | 17 ++
examples/demo_web.rs | 41 +++
scripts/check-wasm | 5 +-
scripts/web-probe/browser.mjs | 43 +++
scripts/web-probe/capture.mjs | 47 +--
scripts/web-probe/compare.py | 26 +-
scripts/web-probe/demo | 32 ++
scripts/web-probe/demo.html | 24 ++
scripts/web-probe/drive.mjs | 64 ++++
src/backend/app.rs | 32 +-
src/backend/dom.rs | 152 ++++++++++
src/backend/mod.rs | 1 +
src/lib.rs | 187 ++++++++++--
src/main.rs | 8 +-
src/web/mod.rs | 9 +-
src/web/renderer.rs | 57 +++-
src/web/shell.rs | 675 ++++++++++++++++++++++++++++++++++++++++++
src/widget/input/text_box.rs | 10 +-
19 files changed, 1392 insertions(+), 107 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 9547604..111663f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -76,8 +76,58 @@ and image queue. web-sys still ships its WebGPU bindings behind `--cfg=web_sys_u
`.cargo/config.toml` sets it for the wasm target, and **cargo reads that file from the
directory it is run in** — so build the browser half from inside `cce-ui` (as
`check-wasm` does), and a client crate that builds cce-ui for the browser needs the same line
-in its own config. There is no browser shell yet (W3): nothing drives `Pacer::turn` or feeds
-the page's events in.
+in its own config.
+
+**And an `Application` runs in a page** (`web::run::<App>(canvas, fonts, sizing).await`, since
+2026-10-05): the browser shell (`src/web/shell.rs`) is the Wayland shell's counterpart over a
+`<canvas>`, on the same `Driver` and `Pacer`. What it does in the page's terms:
+
+- **Events**: pointer (captured on press, so a drag outside the canvas still ends), wheel,
+ key and focus events on the canvas, mapped by `backend::dom` — `map_key` gives a key the
+ TEXT xkb's `utf8` would (Tab "\t", Enter "\r", Ctrl+letter its control code: the Wayland
+ shell hands widgets exactly that, and a focused text box inserts Tab's), `wheel_frame`
+ reads a whole notch-sized pixel delta (Chromium's 100 px) or a line / page delta as a wheel
+ notch and anything else as a finger, and a finger gesture's lift is synthesized after
+ 120 ms without a frame (`FINGER_LIFT`), since a page reports none. The browser's own key
+ repeats are dropped: the driver repeats, as on Wayland. On a Mac, Command is the shortcut
+ key (⌘Z is undo). A page has no grabs, so a press on a CSD border is the app's.
+- **Pacing**: a turn per animation frame at the pacer's ACTIVE cadence, a timer at its idle
+ one; any event, and any `AppSender::send` (through `backend::app::set_wake`), wakes the
+ loop for the next frame. Measured idle: 5 turns in 5 s, the native count.
+- **Size**: `Sizing::App` sizes the canvas from `WindowSettings` and `desired_size` (CSS px),
+ as a window; `Sizing::Page` leaves it to the page's CSS and ignores size requests (a tiling
+ compositor's answer); a ResizeObserver wakes a turn on relayout, and `devicePixelRatio` is
+ the scale. The context menu is drawn in the canvas and kept inside it, as on a layer surface.
+- **Fonts** (`web::Fonts`): the files, and the generic serif / sans / mono families — a page
+ has no font directory and no fontconfig, so it says both. `lib::page_fonts` holds them, and
+ on wasm EVERY font database the toolkit builds loads them: the shell's, the widget-geometry
+ one (`geometry_font_system`) and the text-measurement one (`widget::input::get_font_db`,
+ resvg's — the toggle's label is centred by it). cosmic-text has no family fallback list on
+ wasm (`fallback/other.rs` is empty), where on Linux it walks Noto Sans → DejaVu Sans → …,
+ so `page_fonts::stand_in_for_missing` gives the families the toolkit names (the configured
+ fonts, "Berkeley Mono") the faces of the first family of that Linux list the set has; a
+ family an app names itself must be in the set. Order matters: the measuring fallback is the
+ first face with the glyph.
+- **`web::capture().await`**: the next frame, read back from the GPU. Headless Chromium
+ composites in software and leaves a WebGPU canvas out of its screenshots and `toDataURL`.
+
+Not there yet: the clipboard, IME composition, drag and drop, file dialogs, and an app whose
+text is not the display list's (`display_list_text` false — it stages its own through the
+native-only `stage_renderer`, so draws no text here).
+
+**The reference app runs on both, through one input script.** `examples/demo_web.rs` is
+`src/main.rs`'s `DemoApp` (included by `#[path]`, hence `pub(crate)`) in a page;
+`scripts/web-probe/demo <dir>` builds it, serves it with the machine's fonts and replays the
+native harness's 24 steps (`drive.mjs`: moves, clicks, a drag, a wheel, typing, undo, the
+menu, Tab, held keys, the CSD bands), one captured frame per step, which `compare.py` diffs
+against the native run's screenshots (`--mask` the cursor's box; never sway's
+`hide_cursor`, which clears pointer focus, so the native app drops its hover). Measured
+2026-10-05 against lavapipe: steps 00–18 differ only at the slider band's two pointed tips,
+1 px of rasterizer tie-break (≤ 63 channels beyond 8 levels; everything else within 2),
+with `DEMO_FAMILIES=FreeSerif,FreeSans,FreeMono` — what native's fontdb made of this
+machine's fontconfig, not `fc-match`'s DejaVu. Steps 19–20 hold a key, and the driver
+repeats once per turn: SwiftShader takes ~250 ms a frame, so the page gets fewer repeats
+than native in the same 1.5 s. Timing, not routing.
**The renderer probe holds the two renderers to each other.** `examples/probe/scene.rs`
is one 1280x800 frame of nearly every prim — root, pane and frosted plates, every control
@@ -93,7 +143,7 @@ DejaVu files (`$PROBE_FONTS_DIR`) — and the screenshot must not carry a cursor
Lavapipe against SwiftShader, 2026-10-04: 96.8% of channels equal, every other within 2
levels but ONE at 3 — rounding at antialiased edges and in the blur, no shading difference.
Chromium needs `--use-angle=swiftshader --enable-unsafe-swiftshader
---disable-gpu-compositing` beside the WebGPU flags (`capture.mjs`): headless, with GPU
+--disable-gpu-compositing` beside the WebGPU flags (`browser.mjs`): headless, with GPU
compositing it has no shared-image backing for a WebGPU canvas and loses the device on the
first present ("A valid external Instance reference no longer exists").
@@ -1133,7 +1183,8 @@ cce-system-interface) to confirm behavior, not just the test suite.
key repeat, the undo/redo and plate-navigation chords, the CSD hit zones, the
outside-press popover close, held-button release on a lost pointer, the scroll phase,
the pinch fallback — fed in cce-ui's own terms and unit-tested with no compositor),
- `frame.rs` (`build_frame`: the app's display list, damage, custom vertices and overlays,
+ `dom.rs` (the DOM's key and wheel vocabulary as the driver's: `map_key`, `wheel_frame` —
+ portable, so tested natively), `frame.rs` (`build_frame`: the app's display list, damage, custom vertices and overlays,
widget shaping, text and the popover-occlusion rects, tessellated into a `BuiltFrame` the
renderer draws — no window system in it, tested with no GPU), `shell.rs` (the `Shell`
trait — a window system's side of the run loop: exit, size requests, per-turn sync,
@@ -1168,10 +1219,12 @@ cce-system-interface) to confirm behavior, not just the test suite.
non-uniform blur branch (the backdrop has one level, so the texel is the same —
`frost_pair` is identical to the pixel either way).
- `web/` — wasm32 only: `WebRenderer` (`new(canvas).await`, `resize`, `prepare_text`,
- `draw_frame_2d`, and `capture_next_frame` / `take_capture().await` to read a frame back).
- Its module doc lists what differs from the Vulkan path: an sRGB VIEW of the canvas's
- unorm format, the parameter block as a dynamic-offset uniform, a 1x1 backdrop, the blur
- snapshot as end-pass / copy / resume, every frame drawn whole.
+ `draw_frame_2d`, and `capture_next_frame` / `take_capture().await` or
+ `take_pending_capture` to read a frame back). Its module doc lists what differs from the
+ Vulkan path: an sRGB VIEW of the canvas's unorm format, the parameter block as a
+ dynamic-offset uniform, a 1x1 backdrop, the blur snapshot as end-pass / copy / resume,
+ every frame drawn whole. And `shell.rs`, the browser shell: `run`, `Fonts`, `Sizing`,
+ `capture` (see "And an `Application` runs in a page" above).
- `protocol.rs` — inline-generated Wayland protocol bindings.
- `ipc.rs` — the `/tmp/<prefix>-<WAYLAND_DISPLAY>.sock` helpers (`socket_path`, `send_command`,
the bounded `read_request_line`, `focus_window`), and `ipc::instance`: single-instance
diff --git a/Cargo.toml b/Cargo.toml
index 797f7c0..b76fe95 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -78,6 +78,17 @@ web-sys = { version = "0.3", features = [
"Document",
"Element",
"HtmlCanvasElement",
+ "HtmlElement",
+ "CssStyleDeclaration",
+ "EventTarget",
+ "Event",
+ "AddEventListenerOptions",
+ "MouseEvent",
+ "PointerEvent",
+ "WheelEvent",
+ "KeyboardEvent",
+ "FocusEvent",
+ "ResizeObserver",
"Gpu",
"GpuAdapter",
"GpuDevice",
@@ -156,3 +167,9 @@ web-sys = { version = "0.3", features = [
[[example]]
name = "probe_web"
crate-type = ["cdylib"]
+
+# The reference app (src/main.rs) in a browser, on the browser shell; a
+# cdylib for wasm-bindgen, empty natively.
+[[example]]
+name = "demo_web"
+crate-type = ["cdylib"]
diff --git a/examples/demo_web.rs b/examples/demo_web.rs
new file mode 100644
index 0000000..27ad2d3
--- /dev/null
+++ b/examples/demo_web.rs
@@ -0,0 +1,41 @@
+//! The reference app, `src/main.rs`, in a browser: the same `DemoApp` the
+//! Wayland shell runs, run on a canvas by `cce_ui::web::run`. Built for
+//! wasm32 as a cdylib and bound with wasm-bindgen; a page calls
+//! `start(canvas, fonts, families, fill)` — `fonts` each a font file's bytes,
+//! `families` the generic serif / sans-serif / monospace families as
+//! "serif,sans,mono" (empty for the defaults; any part may be empty), `fill`
+//! true to let the page size the canvas (`Sizing::Page`) rather than the app
+//! (`Sizing::App`) — and `capture()` for the next frame's pixels.
+//! `scripts/web-probe/demo` serves it and drives it.
+//!
+//! On a native target this example is empty.
+
+#[cfg(target_arch = "wasm32")]
+#[allow(dead_code)]
+#[path = "../src/main.rs"]
+mod demo;
+
+#[cfg(target_arch = "wasm32")]
+mod web {
+ use cce_ui::web::{Fonts, Sizing};
+ use wasm_bindgen::prelude::*;
+
+ #[wasm_bindgen]
+ pub async fn start(canvas: web_sys::HtmlCanvasElement, fonts: js_sys::Array, families: String, fill: bool) -> Result<(), JsValue> {
+ std::panic::set_hook(Box::new(|info| web_sys::console::error_1(&info.to_string().into())));
+ let mut fonts = Fonts::new(fonts.iter().map(|f| js_sys::Uint8Array::new(&f).to_vec()).collect());
+ let mut names = families.split(',').map(|f| Some(f.trim()).filter(|f| !f.is_empty()).map(String::from));
+ fonts.serif = names.next().flatten();
+ fonts.sans_serif = names.next().flatten();
+ fonts.monospace = names.next().flatten();
+ let sizing = if fill { Sizing::Page } else { Sizing::App };
+ cce_ui::web::run::<super::demo::DemoApp>(canvas, fonts, sizing).await
+ }
+
+ /// The next frame, read back from the GPU as RGBA8 (`cce_ui::web::capture`).
+ #[wasm_bindgen]
+ pub async fn capture() -> Result<js_sys::Uint8Array, JsValue> {
+ let frame = cce_ui::web::capture().await?;
+ Ok(js_sys::Uint8Array::from(&frame.rgba[..]))
+ }
+}
diff --git a/scripts/check-wasm b/scripts/check-wasm
index 079f187..8696f84 100755
--- a/scripts/check-wasm
+++ b/scripts/check-wasm
@@ -4,7 +4,8 @@
# optional features. The native shell and renderer (Wayland, Vulkan, the
# compositor IPC) are cfg'd out there; everything else must compile, and a
# change that reaches for one of them from portable code fails here first.
-# The WebGPU renderer (src/web) and the probe's browser half build here too;
+# The browser shell and renderer (src/web), the probe's browser half and the
+# reference app's (examples/demo_web.rs) build here too;
# they need web-sys's unstable WebGPU bindings, switched on for this crate in
# .cargo/config.toml — which cargo reads from the directory it runs in, hence
# the cd below.
@@ -17,5 +18,5 @@ if ! rustup target list --installed 2>/dev/null | grep -qx wasm32-unknown-unknow
fi
cargo check --lib --target wasm32-unknown-unknown
cargo check --lib --target wasm32-unknown-unknown --features markdown,doc_editor
-cargo check --example probe_web --target wasm32-unknown-unknown
+cargo check --example probe_web --example demo_web --target wasm32-unknown-unknown
echo "check-wasm: ok"
diff --git a/scripts/web-probe/browser.mjs b/scripts/web-probe/browser.mjs
new file mode 100644
index 0000000..b38a467
--- /dev/null
+++ b/scripts/web-probe/browser.mjs
@@ -0,0 +1,43 @@
+// browser.mjs: what capture.mjs and drive.mjs share — serve a site directory
+// on localhost (WebGPU needs a secure context) and open it in headless
+// Chromium with WebGPU on SwiftShader.
+//
+// Playwright is found as `playwright` on the module path, or at
+// $PLAYWRIGHT_MODULE (a package directory) when it is installed elsewhere.
+import { createRequire } from 'module';
+import http from 'http';
+import fs from 'fs';
+import path from 'path';
+
+const require = createRequire(import.meta.url);
+const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
+const types = { '.html': 'text/html', '.js': 'text/javascript', '.wasm': 'application/wasm', '.json': 'application/json' };
+
+/// Open `page` (a file in `root`) at `width` x `height`. Resolves to
+/// { page, logs, close }; `logs` collects the page's console and errors.
+export async function open(root, file, width, height) {
+ const server = http.createServer((q, r) => {
+ const p = decodeURIComponent(q.url.split('?')[0]);
+ const f = path.join(root, p === '/' ? file : p);
+ fs.readFile(f, (e, d) => {
+ if (e) { r.writeHead(404); r.end(); return; }
+ r.writeHead(200, { 'content-type': types[path.extname(f)] || 'application/octet-stream' });
+ r.end(d);
+ });
+ });
+ await new Promise(res => server.listen(0, '127.0.0.1', res));
+ // SwiftShader for WebGPU, and for the compositor too: a headless shell with
+ // GPU compositing has no shared-image backing for a WebGPU canvas, and the
+ // device is lost on the first present ("A valid external Instance reference
+ // no longer exists").
+ const browser = await chromium.launch({ args: [
+ '--enable-unsafe-webgpu', '--use-webgpu-adapter=swiftshader', '--enable-features=Vulkan',
+ '--use-angle=swiftshader', '--enable-unsafe-swiftshader', '--disable-gpu-compositing',
+ ] });
+ const page = await browser.newPage({ viewport: { width, height }, deviceScaleFactor: Number(process.env.PROBE_DPR || 1) });
+ const logs = [];
+ page.on('console', m => logs.push(`[${m.type()}] ${m.text()}`));
+ page.on('pageerror', e => logs.push(`[pageerror] ${e}`));
+ await page.goto(`http://127.0.0.1:${server.address().port}/`);
+ return { page, logs, close: async () => { await browser.close(); server.close(); } };
+}
diff --git a/scripts/web-probe/capture.mjs b/scripts/web-probe/capture.mjs
index 9952619..a1aa9d8 100644
--- a/scripts/web-probe/capture.mjs
+++ b/scripts/web-probe/capture.mjs
@@ -1,46 +1,12 @@
-// capture.mjs <site-dir> <out.rgba>: serve <site-dir> on localhost (WebGPU
-// needs a secure context), open it in headless Chromium with WebGPU on
-// SwiftShader, run the probe, and write the frame it read back from the GPU
-// to <out.rgba> (premultiplied RGBA8), with a page screenshot beside it.
-//
-// Playwright is found as `playwright` on the module path, or at
-// $PLAYWRIGHT_MODULE (a package directory) when it is installed elsewhere.
-import { createRequire } from 'module';
-import http from 'http';
+// capture.mjs <site-dir> <out.rgba>: open the probe page (browser.mjs), run
+// the probe, and write the frame it read back from the GPU to <out.rgba>
+// (premultiplied RGBA8), with a page screenshot beside it.
import fs from 'fs';
-import path from 'path';
+import { open } from './browser.mjs';
const [root, out] = process.argv.slice(2);
if (!root || !out) { console.error('usage: capture.mjs <site-dir> <out.rgba>'); process.exit(2); }
-const require = createRequire(import.meta.url);
-const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
-
-const types = { '.html': 'text/html', '.js': 'text/javascript', '.wasm': 'application/wasm', '.json': 'application/json', '.ttf': 'font/ttf' };
-const server = http.createServer((q, r) => {
- const p = decodeURIComponent(q.url.split('?')[0]);
- const f = path.join(root, p === '/' ? 'index.html' : p);
- fs.readFile(f, (e, d) => {
- if (e) { r.writeHead(404); r.end(); return; }
- r.writeHead(200, { 'content-type': types[path.extname(f)] || 'application/octet-stream' });
- r.end(d);
- });
-});
-await new Promise(res => server.listen(0, '127.0.0.1', res));
-const port = server.address().port;
-
-// SwiftShader for WebGPU, and for the compositor too: a headless shell with
-// GPU compositing has no shared-image backing for a WebGPU canvas, and the
-// device is lost on the first present ("A valid external Instance reference
-// no longer exists").
-const browser = await chromium.launch({ args: [
- '--enable-unsafe-webgpu', '--use-webgpu-adapter=swiftshader', '--enable-features=Vulkan',
- '--use-angle=swiftshader', '--enable-unsafe-swiftshader', '--disable-gpu-compositing',
-] });
-const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
-const logs = [];
-page.on('console', m => logs.push(`[${m.type()}] ${m.text()}`));
-page.on('pageerror', e => logs.push(`[pageerror] ${e}`));
-await page.goto(`http://127.0.0.1:${port}/`);
+const { page, logs, close } = await open(root, 'index.html', 1280, 800);
await page.waitForFunction(() => window.probeReady === true);
let ok = true;
try {
@@ -49,6 +15,5 @@ try {
await page.screenshot({ path: out + '.screenshot.png' });
} catch (e) { ok = false; logs.push('[probe] ' + String(e)); }
for (const l of logs) console.log(l);
-await browser.close();
-server.close();
+await close();
process.exit(ok ? 0 : 1);
diff --git a/scripts/web-probe/compare.py b/scripts/web-probe/compare.py
index fe355c0..5fb1e89 100755
--- a/scripts/web-probe/compare.py
+++ b/scripts/web-probe/compare.py
@@ -1,22 +1,34 @@
#!/usr/bin/env python3
-"""compare.py native.png web.rgba W H [bg]: composite the web capture
+"""compare.py native.png web.rgba W H [bg] [--mask X,Y,W,H …]: composite the web capture
(premultiplied RGBA8, as a WebGPU canvas with alpha_mode=premultiplied holds
it) over the compositor background the native screenshot shows behind the
window (a grey level, default 63: headless sway's #3f3f3f), and report how far
the two frames are apart, per channel.
-The native screenshot must be the window alone at its origin, with the pointer
-off it: a cursor sprite in the screenshot is a difference this cannot tell
-from the renderer's. Needs ImageMagick's `convert`."""
+The native screenshot must be the window alone at its origin. A cursor sprite
+in it is a difference this cannot tell from the renderer's: keep the pointer
+off the window, or `--mask` the box the sprite covers (repeatable). Hiding
+the cursor in the compositor is no answer — sway's `hide_cursor` clears the
+pointer focus, so the app sees a leave and drops its hover. Needs
+ImageMagick's `convert`."""
import subprocess, sys
-native_png, web_path, W, H = sys.argv[1], sys.argv[2], int(sys.argv[3]), int(sys.argv[4])
-bg = int(sys.argv[5]) if len(sys.argv) > 5 else 63
+args = sys.argv[1:]
+masks = []
+while '--mask' in args:
+ i = args.index('--mask')
+ masks.append(tuple(int(v) for v in args[i + 1].split(',')))
+ del args[i:i + 2]
+native_png, web_path, W, H = args[0], args[1], int(args[2]), int(args[3])
+bg = int(args[4]) if len(args) > 4 else 63
+masked = lambda x, y: any(mx <= x < mx + mw and my <= y < my + mh for mx, my, mw, mh in masks)
native = subprocess.run(['convert', native_png, '-alpha', 'off', 'rgb:-'], capture_output=True, check=True).stdout
web = open(web_path, 'rb').read()
assert len(native) == W * H * 3 and len(web) == W * H * 4, (len(native), len(web))
hist = {}
worst = (0, None)
for i in range(W * H):
+ if masks and masked(i % W, i // W):
+ continue
a = web[4 * i + 3]
for c in range(3):
w = web[4 * i + c] + bg * (255 - a) / 255.0
@@ -25,7 +37,7 @@ for i in range(W * H):
if d > worst[0]:
worst = (d, (i % W, i // W))
px_over = lambda t: sum(n for d, n in hist.items() if d > t)
-print(f"channels compared: {3 * W * H}")
+print(f"channels compared: {sum(hist.values())}")
print("difference histogram (levels of 255):", dict(sorted(hist.items())))
print(f"channels off by >1: {px_over(1)}, >2: {px_over(2)}, >4: {px_over(4)}, >8: {px_over(8)}")
print(f"worst: {worst[0]} at {worst[1]}")
diff --git a/scripts/web-probe/demo b/scripts/web-probe/demo
new file mode 100755
index 0000000..48600bf
--- /dev/null
+++ b/scripts/web-probe/demo
@@ -0,0 +1,32 @@
+#!/usr/bin/env bash
+# web-probe/demo <out-dir> — run the reference app (src/main.rs) on the
+# browser shell, in headless Chromium, through the native harness's input
+# script (drive.mjs), one screenshot per step into <out-dir>.
+#
+# Fonts: the native demo, run with CCE_LOAD_SYSTEM_FONTS=1, sees every system
+# font, and the generic families fontdb reads from fontconfig; the page is
+# handed the same files in the order fontdb's directory scan finds them
+# ($DEMO_FONTS_DIR, default /usr/share/fonts) and the same generic families
+# ($DEMO_FAMILIES, "serif,sans,mono" — what fontdb's fontconfig reading made
+# of this machine's config, which is not always what `fc-match` says; empty
+# for the shell's defaults). A family the app names and the set lacks falls
+# back through them — the text measurement to the serif family — so with
+# either different, text is the difference you measure. Needs what `run`
+# needs.
+set -euo pipefail
+here=$(cd "$(dirname "$0")" && pwd)
+out=$(realpath -m "${1:?usage: web-probe/demo <out-dir>}")
+fonts=${DEMO_FONTS_DIR:-/usr/share/fonts}
+cd "$here/../.."
+
+cargo build --release --target wasm32-unknown-unknown --example demo_web
+site=$(mktemp -d)
+trap 'rm -rf "$site"' EXIT
+wasm-bindgen --target web --no-typescript --out-dir "$site" \
+ target/wasm32-unknown-unknown/release/examples/demo_web.wasm
+cp "$here/demo.html" "$site/demo.html"
+ln -s "$fonts" "$site/fonts"
+(cd "$fonts" && find . \( -type f -o -type l \) \( -iname '*.ttf' -o -iname '*.otf' -o -iname '*.ttc' -o -iname '*.otc' \) \
+ | sed 's|^\./||' | DEMO_FAMILIES="${DEMO_FAMILIES:-}" python3 -c 'import json,os,sys; print(json.dumps({"files": sys.stdin.read().split(), "families": os.environ["DEMO_FAMILIES"]}))') > "$site/fonts.json"
+node "$here/drive.mjs" "$site" "$out"
+echo "web-probe: screenshots in $out"
diff --git a/scripts/web-probe/demo.html b/scripts/web-probe/demo.html
new file mode 100644
index 0000000..a88f642
--- /dev/null
+++ b/scripts/web-probe/demo.html
@@ -0,0 +1,24 @@
+<!doctype html>
+<meta charset="utf-8">
+<title>cce-ui</title>
+<!-- The reference app on the browser shell (examples/demo_web.rs). The page
+ owns the canvas's size (fill = Sizing::Page). window.capture() reads the
+ next frame back from the GPU, base64 RGBA8: a headless Chromium leaves
+ a WebGPU canvas out of its screenshots. -->
+<style>html,body{margin:0;background:#3f3f3f;overflow:hidden}canvas{display:block;width:100vw;height:100vh}</style>
+<canvas id="c"></canvas>
+<script type="module">
+import init, { start, capture } from './demo_web.js';
+await init();
+const { files, families } = await (await fetch('fonts.json')).json();
+const fonts = [];
+for (const n of files) fonts.push(new Uint8Array(await (await fetch('fonts/' + n)).arrayBuffer()));
+await start(document.getElementById('c'), fonts, families, true);
+window.capture = async () => {
+ const rgba = await capture();
+ let s = '';
+ for (let i = 0; i < rgba.length; i += 0x8000) s += String.fromCharCode.apply(null, rgba.subarray(i, i + 0x8000));
+ return btoa(s);
+};
+window.demoReady = true;
+</script>
diff --git a/scripts/web-probe/drive.mjs b/scripts/web-probe/drive.mjs
new file mode 100644
index 0000000..e0cae84
--- /dev/null
+++ b/scripts/web-probe/drive.mjs
@@ -0,0 +1,64 @@
+// drive.mjs <site-dir> <out-dir>: open the demo page (browser.mjs) and run
+// the native harness's input script against it — the same pointer moves,
+// presses, wheel notches and keys, with the same waits, and one screenshot
+// per step (00-start.rgba … 23-titlebar-band.rgba: premultiplied RGBA8, the
+// frame read back from the GPU — compare.py composites it). A native run of the same
+// script under headless sway (the pointer parked at the corner, the cursor
+// hidden, CCE_UI_MENU_POPUP=0 so the menu is drawn in the window as it is
+// here) is what each screenshot is compared with.
+import fs from 'fs';
+import { open } from './browser.mjs';
+
+const [root, out] = process.argv.slice(2);
+if (!root || !out) { console.error('usage: drive.mjs <site-dir> <out-dir>'); process.exit(2); }
+fs.mkdirSync(out, { recursive: true });
+const { page, logs, close } = await open(root, 'demo.html', 1280, 800);
+const m = page.mouse, k = page.keyboard;
+const wait = ms => page.waitForTimeout(ms);
+// One pointer command, then the 150 ms the native script's `v` waits.
+const v = async f => { await f(); await wait(150); };
+const shot = async name => {
+ await wait(900);
+ const b64 = await page.evaluate(() => window.capture());
+ fs.writeFileSync(`${out}/${name}.rgba`, Buffer.from(b64, 'base64'));
+};
+const BTN = { 272: 'left', 273: 'right' };
+const click = b => v(async () => { await m.down({ button: BTN[b] }); await m.up({ button: BTN[b] }); });
+const abs = (x, y) => v(() => m.move(x, y));
+
+let ok = true;
+try {
+ await abs(1279, 799);
+ await page.waitForFunction(() => window.demoReady === true, null, { timeout: 120000 });
+ await wait(5000);
+ await shot('00-start');
+ await abs(85, 91); await shot('01-hover-button');
+ await click(272); await shot('02-click-button');
+ await abs(202, 91); await click(272); await shot('03-toggle');
+ await abs(332, 91); await click(272); await shot('04-dropdown-open');
+ await abs(332, 122); await click(272); await shot('05-dropdown-pick');
+ await abs(640, 168); await click(272); await shot('06-textbox-focus');
+ await k.type('hello web'); await shot('07-typed');
+ await k.down('Control'); await k.press('z'); await k.up('Control'); await shot('08-undo');
+ for (let i = 0; i < 3; i++) await k.press('Backspace');
+ await shot('09-backspace');
+ await abs(900, 130); await v(() => m.down()); await abs(1000, 130); await abs(1100, 131); await v(() => m.up());
+ await shot('10-slider-drag');
+ await abs(517, 131); await v(() => m.wheel(0, -300)); await shot('11-slider-wheel');
+ await abs(600, 500); await click(273); await shot('12-context-menu');
+ await abs(640, 525); await shot('13-menu-hover');
+ await abs(1200, 750); await click(272); await shot('14-menu-dismiss');
+ await abs(600, 500); await click(273); await abs(640, 525); await click(272); await shot('15-menu-run');
+ await k.press('Tab'); await shot('16-tab');
+ await k.press('Escape'); await shot('17-esc');
+ await abs(640, 168); await click(272); await k.type('xyzxyz'); await shot('18-refocus-typed');
+ await k.down('Backspace'); await wait(1500); await k.up('Backspace'); await shot('19-hold-backspace');
+ await k.type('abcdef'); await k.down('ArrowLeft'); await wait(1500); await k.up('ArrowLeft'); await k.type('Z');
+ await shot('20-hold-left-then-type');
+ await abs(3, 400); await click(272); await shot('21-csd-left-edge');
+ await abs(640, 3); await click(272); await shot('22-csd-top-edge');
+ await abs(300, 20); await click(272); await shot('23-titlebar-band');
+} catch (e) { ok = false; logs.push('[drive] ' + String(e)); }
+for (const l of logs) console.log(l);
+await close();
+process.exit(ok ? 0 : 1);
diff --git a/src/backend/app.rs b/src/backend/app.rs
index 60e4b3f..7c2bc4a 100644
--- a/src/backend/app.rs
+++ b/src/backend/app.rs
@@ -102,7 +102,8 @@ pub struct RenderContext<'a> {
/// for a client that still keeps a `calloop::channel::Sender` somewhere. In
/// the browser it is a `std::sync::mpsc` sender whose receiver the shell
/// drains every turn — still `Send`, so app code that hands it to a worker
-/// compiles unchanged.
+/// compiles unchanged — and a send wakes the browser shell's loop through
+/// [`set_wake`], as a calloop channel wakes the Wayland one.
pub struct AppSender<M> {
#[cfg(not(target_arch = "wasm32"))]
inner: calloop::channel::Sender<M>,
@@ -114,10 +115,37 @@ impl<M> AppSender<M> {
/// Post `msg` to the app. Fails, handing it back, only once the loop is
/// gone: the app has exited.
pub fn send(&self, msg: M) -> Result<(), std::sync::mpsc::SendError<M>> {
- self.inner.send(msg)
+ self.inner.send(msg)?;
+ #[cfg(target_arch = "wasm32")]
+ wake();
+ Ok(())
}
}
+/// Call the hook [`set_wake`] installed, if any.
+#[cfg(target_arch = "wasm32")]
+pub(crate) fn wake() {
+ WAKE.with(|w| {
+ if let Some(wake) = w.borrow().as_ref() {
+ wake();
+ }
+ });
+}
+
+#[cfg(target_arch = "wasm32")]
+thread_local! {
+ static WAKE: std::cell::RefCell<Option<Box<dyn Fn()>>> = const { std::cell::RefCell::new(None) };
+}
+
+/// What an [`AppSender::send`] on this thread calls after posting: the
+/// browser shell's "turn the loop soon". Per thread, and the page's app runs
+/// on the one thread a page has; a send from a worker thread posts without
+/// waking, and is drained at the next turn the page takes.
+#[cfg(target_arch = "wasm32")]
+pub fn set_wake(wake: Option<Box<dyn Fn()>>) {
+ WAKE.with(|w| *w.borrow_mut() = wake);
+}
+
impl<M> Clone for AppSender<M> {
fn clone(&self) -> Self {
Self { inner: self.inner.clone() }
diff --git a/src/backend/dom.rs b/src/backend/dom.rs
new file mode 100644
index 0000000..e0a3d10
--- /dev/null
+++ b/src/backend/dom.rs
@@ -0,0 +1,152 @@
+//! The DOM's input vocabulary in cce-ui's terms: a `KeyboardEvent.key` as a
+//! [`Key`] and the text it types, a `WheelEvent` as a [`ScrollFrame`]. The
+//! browser shell (`web::shell`) feeds these to the [`Driver`](super::driver::Driver),
+//! as the Wayland shell maps xkb keysyms and `wl_pointer` axis frames. Plain
+//! functions of strings and numbers, so they build and are tested natively.
+
+use super::driver::{ScrollFrame, ScrollSource};
+use crate::widget::{Key, NamedKey};
+
+/// A `KeyboardEvent.key` as cce-ui's key and the text it types — the
+/// browser's half of the Wayland shell's keysym map, text included: what
+/// xkb's `utf8` would carry for the same key, since that is what widgets
+/// have always been handed (Tab types "\t" and Enter "\r"; with Ctrl, or
+/// Command on a Mac, a letter types its control code and is matched by the
+/// letter). `None` for a key the toolkit has no use for (an IME's `Process`,
+/// a dead key).
+pub fn map_key(key: &str, accel: bool) -> Option<(Key, Option<String>)> {
+ let named = |n| Some((Key::Named(n), None));
+ let typing = |n, text: &str| Some((Key::Named(n), Some(text.to_string())));
+ match key {
+ "Escape" => typing(NamedKey::Escape, "\u{1b}"),
+ "Enter" => typing(NamedKey::Enter, "\r"),
+ "Backspace" => typing(NamedKey::Backspace, "\u{8}"),
+ "Tab" => typing(NamedKey::Tab, "\t"),
+ "Delete" => typing(NamedKey::Delete, "\u{7f}"),
+ " " => typing(NamedKey::Space, if accel { "\0" } else { " " }),
+ "ArrowDown" => named(NamedKey::ArrowDown),
+ "ArrowUp" => named(NamedKey::ArrowUp),
+ "ArrowLeft" => named(NamedKey::ArrowLeft),
+ "ArrowRight" => named(NamedKey::ArrowRight),
+ "PageUp" => named(NamedKey::PageUp),
+ "PageDown" => named(NamedKey::PageDown),
+ "Home" => named(NamedKey::Home),
+ "End" => named(NamedKey::End),
+ "Meta" | "OS" => named(NamedKey::Super),
+ "Alt" => named(NamedKey::Alt),
+ "Control" => named(NamedKey::Control),
+ "Shift" => named(NamedKey::Shift),
+ "F1" => named(NamedKey::F1),
+ "F2" => named(NamedKey::F2),
+ "F3" => named(NamedKey::F3),
+ "F4" => named(NamedKey::F4),
+ "F5" => named(NamedKey::F5),
+ "F6" => named(NamedKey::F6),
+ "F7" => named(NamedKey::F7),
+ "F8" => named(NamedKey::F8),
+ "F9" => named(NamedKey::F9),
+ "F10" => named(NamedKey::F10),
+ "F11" => named(NamedKey::F11),
+ "F12" => named(NamedKey::F12),
+ // A key that types: one character (a named key's name is longer).
+ k if k.chars().count() == 1 => {
+ let c = k.chars().next().unwrap();
+ let text = match c {
+ // xkb's control transformation: @, A–Z, [ \ ] ^ _ (either
+ // case of the letters) to 0x00–0x1f.
+ '@'..='_' | 'a'..='z' if accel => char::from(c.to_ascii_uppercase() as u8 & 0x1f).to_string(),
+ _ => k.to_string(),
+ };
+ Some((Key::Character(k.into()), Some(text)))
+ }
+ _ => None,
+ }
+}
+
+/// A `WheelEvent`'s deltas as a scroll frame in the units the driver takes
+/// (a `wl_pointer` axis frame's: a finger in px, a wheel notch as a discrete
+/// step plus ten units, positive down and right — the DOM's signs too).
+///
+/// A page cannot ask which device scrolled. A line or page delta is a wheel
+/// (Firefox's notches); a pixel delta is a wheel when it comes in whole
+/// notch-sized steps on one axis (Chromium's 100 px a notch, or 120), and a
+/// finger otherwise — a trackpad's deltas are small and fractional.
+pub fn wheel_frame(mode: u32, dx: f64, dy: f64) -> ScrollFrame {
+ // Whole notches, at least one each way a delta goes.
+ let notches = |d: f64, per: f64| -> i32 {
+ match (d / per).round() as i32 {
+ _ if d == 0.0 => 0,
+ 0 => d.signum() as i32,
+ n => n.clamp(-100, 100),
+ }
+ };
+ let wheel = |per: f64| {
+ let (nh, nv) = (notches(dx, per), notches(dy, per));
+ ScrollFrame {
+ h: nh as f64 * 10.0,
+ v: nv as f64 * 10.0,
+ discrete_h: nh,
+ discrete_v: nv,
+ source: Some(ScrollSource::Wheel),
+ stop: false,
+ }
+ };
+ match mode {
+ // DOM_DELTA_LINE: Firefox gives three lines a notch.
+ 1 => wheel(3.0),
+ // DOM_DELTA_PAGE: a notch is a page.
+ 2 => wheel(1.0),
+ _ => {
+ let notch_sized = |d: f64| d == 0.0 || (d.fract() == 0.0 && d.abs() >= 50.0);
+ if (dx == 0.0 || dy == 0.0) && notch_sized(dx) && notch_sized(dy) {
+ wheel(100.0)
+ } else {
+ ScrollFrame { h: dx, v: dy, source: Some(ScrollSource::Finger), ..Default::default() }
+ }
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn named_keys_type_what_xkb_types() {
+ assert_eq!(map_key("Tab", false), Some((Key::Named(NamedKey::Tab), Some("\t".into()))));
+ assert_eq!(map_key("Enter", false), Some((Key::Named(NamedKey::Enter), Some("\r".into()))));
+ assert_eq!(map_key("Backspace", false), Some((Key::Named(NamedKey::Backspace), Some("\u{8}".into()))));
+ assert_eq!(map_key(" ", false), Some((Key::Named(NamedKey::Space), Some(" ".into()))));
+ assert_eq!(map_key("ArrowLeft", false), Some((Key::Named(NamedKey::ArrowLeft), None)));
+ assert_eq!(map_key("Dead", false), None);
+ assert_eq!(map_key("Process", false), None);
+ }
+
+ #[test]
+ fn a_shortcut_is_matched_by_its_letter_and_types_its_control_code() {
+ assert_eq!(map_key("a", false), Some((Key::Character("a".into()), Some("a".into()))));
+ assert_eq!(map_key("z", true), Some((Key::Character("z".into()), Some("\u{1a}".into()))));
+ // Ctrl+Shift+Z: the shifted letter, the same control code.
+ assert_eq!(map_key("Z", true), Some((Key::Character("Z".into()), Some("\u{1a}".into()))));
+ assert_eq!(map_key("1", true), Some((Key::Character("1".into()), Some("1".into()))));
+ assert_eq!(map_key("é", false), Some((Key::Character("é".into()), Some("é".into()))));
+ }
+
+ #[test]
+ fn notches_are_discrete_and_a_trackpad_is_a_finger() {
+ // Chromium's mouse wheel: 100 px a notch, three notches up.
+ let f = wheel_frame(0, 0.0, -300.0);
+ assert_eq!((f.discrete_v, f.discrete_h, f.source), (-3, 0, Some(ScrollSource::Wheel)));
+ assert_eq!(f.v, -30.0);
+ // Firefox's: three lines a notch.
+ let f = wheel_frame(1, 0.0, 3.0);
+ assert_eq!((f.discrete_v, f.source), (1, Some(ScrollSource::Wheel)));
+ // A page delta is a notch.
+ assert_eq!(wheel_frame(2, 0.0, -1.0).discrete_v, -1);
+ // A trackpad: small, fractional, often on both axes.
+ let f = wheel_frame(0, 1.5, -4.25);
+ assert_eq!((f.discrete_v, f.h, f.v, f.source), (0, 1.5, -4.25, Some(ScrollSource::Finger)));
+ // Whole but notch-sized on both axes at once: a finger too.
+ assert_eq!(wheel_frame(0, 100.0, 100.0).source, Some(ScrollSource::Finger));
+ }
+}
diff --git a/src/backend/mod.rs b/src/backend/mod.rs
index d4d4b4a..ea8c729 100644
--- a/src/backend/mod.rs
+++ b/src/backend/mod.rs
@@ -1,4 +1,5 @@
pub mod app;
+pub mod dom;
pub mod driver;
pub mod frame;
pub mod shell;
diff --git a/src/lib.rs b/src/lib.rs
index 6b62710..5773daf 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -208,6 +208,7 @@ pub fn create_font_system_with_system_fonts() -> cosmic_text::FontSystem {
/// list of well-known files keeps startup cheap while giving cosmic-text's
/// unix script fallback (family names "Noto Sans CJK *", "Noto Color Emoji")
/// real faces to land on. `$CCE_NO_FALLBACK_FONTS` opts out.
+#[cfg(not(target_arch = "wasm32"))]
fn load_fallback_fonts(db: &mut cosmic_text::fontdb::Database) {
if std::env::var("CCE_NO_FALLBACK_FONTS").is_ok() {
return;
@@ -231,38 +232,52 @@ fn load_fallback_fonts(db: &mut cosmic_text::fontdb::Database) {
}
}
-fn build_font_system(load_system_fonts: bool) -> cosmic_text::FontSystem {
- let mut db = cosmic_text::fontdb::Database::new();
- db.load_fonts_dir(fonts_dir());
- load_fallback_fonts(&mut db);
- if load_system_fonts || std::env::var("CCE_LOAD_SYSTEM_FONTS").is_ok() {
- db.load_system_fonts();
- }
- // An empty database guarantees a panic on the first shaped glyph
- // (cosmic-text: "no default font found"), so if the bundled dir yielded
- // nothing (missing $HOME/Dropbox/Fonts — e.g. the greeter running as
- // root), fall back to system fonts rather than crash.
- if db.faces().next().is_none() {
- db.load_system_fonts();
- }
-
- // Pin the generic families to faces that actually exist. fontdb's defaults
- // name Windows faces ("Arial"/"Times New Roman"), so Family::SansSerif /
- // Monospace never resolved here and every glyph of generic-family text
- // dropped into the per-glyph fallback chain — where Noto Color Emoji sits
- // high (cosmic-text common_fallback) and hijacked spaces and digits with
- // emoji metrics. Berkeley Mono is the house mono; Noto Sans CJK SC (the
- // targeted fallback face above) doubles as a full Latin sans.
+/// Pin the generic families to faces that actually exist. fontdb's defaults
+/// name Windows faces ("Arial"/"Times New Roman"), so Family::SansSerif /
+/// Monospace never resolved here and every glyph of generic-family text
+/// dropped into the per-glyph fallback chain — where Noto Color Emoji sits
+/// high (cosmic-text common_fallback) and hijacked spaces and digits with
+/// emoji metrics. Berkeley Mono is the house mono; Noto Sans CJK SC (the
+/// targeted fallback face) doubles as a full Latin sans. Shared by every
+/// shell's font system, the browser's included, so a font set resolves the
+/// same families wherever it is loaded.
+fn pin_generic_families(db: &mut cosmic_text::fontdb::Database) {
fn has_family(db: &cosmic_text::fontdb::Database, fam: &str) -> bool {
db.faces()
.any(|f| f.families.iter().any(|(n, _)| n == fam))
}
- if has_family(&db, "Berkeley Mono") {
+ if has_family(db, "Berkeley Mono") {
db.set_monospace_family("Berkeley Mono");
}
- if has_family(&db, "Noto Sans CJK SC") {
+ if has_family(db, "Noto Sans CJK SC") {
db.set_sans_serif_family("Noto Sans CJK SC");
}
+}
+
+fn build_font_system(load_system_fonts: bool) -> cosmic_text::FontSystem {
+ let mut db = cosmic_text::fontdb::Database::new();
+ #[cfg(not(target_arch = "wasm32"))]
+ {
+ db.load_fonts_dir(fonts_dir());
+ load_fallback_fonts(&mut db);
+ if load_system_fonts || std::env::var("CCE_LOAD_SYSTEM_FONTS").is_ok() {
+ db.load_system_fonts();
+ }
+ // An empty database guarantees a panic on the first shaped glyph
+ // (cosmic-text: "no default font found"), so if the bundled dir yielded
+ // nothing (missing $HOME/Dropbox/Fonts — e.g. the greeter running as
+ // root), fall back to system fonts rather than crash.
+ if db.faces().next().is_none() {
+ db.load_system_fonts();
+ }
+ }
+ #[cfg(target_arch = "wasm32")]
+ {
+ let _ = load_system_fonts;
+ page_fonts::load_into(&mut db);
+ }
+
+ pin_generic_families(&mut db);
// Validate configured custom fonts
let font_getters = vec![
@@ -301,5 +316,129 @@ fn build_font_system(load_system_fonts: bool) -> cosmic_text::FontSystem {
}
}
+ #[cfg(target_arch = "wasm32")]
+ page_fonts::stand_in_for_missing(&mut db);
+
cosmic_text::FontSystem::new_with_locale_and_db("en-US".to_string(), db)
}
+
+/// The fonts a page hands the browser shell (`web::run`). A page has no font
+/// directory and no fontconfig, so on wasm every font database the toolkit
+/// builds — the shell's, its own for widget geometry
+/// ([`geometry_font_system`]) and the text-measurement one
+/// (`widget::input::get_font_db`) — is loaded from these, one shared copy of
+/// each file, with the generic families the page named.
+#[cfg(target_arch = "wasm32")]
+pub(crate) mod page_fonts {
+ use std::sync::{Arc, Mutex};
+
+ use cosmic_text::fontdb::{Database, Language};
+
+ type Font = Arc<dyn AsRef<[u8]> + Send + Sync>;
+
+ struct Provided {
+ files: Vec<Font>,
+ serif: Option<String>,
+ sans_serif: Option<String>,
+ monospace: Option<String>,
+ }
+
+ static PROVIDED: Mutex<Provided> =
+ Mutex::new(Provided { files: Vec::new(), serif: None, sans_serif: None, monospace: None });
+
+ /// What every font database built from now on loads: `files` (each a
+ /// font file's bytes), in order, and the families the generic ones name —
+ /// a fontconfig's answer, which on Linux `load_system_fonts` reads.
+ pub(crate) fn provide(files: Vec<Vec<u8>>, serif: Option<String>, sans_serif: Option<String>, monospace: Option<String>) {
+ let mut p = PROVIDED.lock().unwrap();
+ p.files.extend(files.into_iter().map(|f| Arc::new(f) as Font));
+ p.serif = serif.or(p.serif.take());
+ p.sans_serif = sans_serif.or(p.sans_serif.take());
+ p.monospace = monospace.or(p.monospace.take());
+ }
+
+ /// Load the page's fonts into `db`, as `load_system_fonts` loads the
+ /// system's: the files, then the generic families.
+ pub(crate) fn load_into(db: &mut Database) {
+ let p = PROVIDED.lock().unwrap();
+ for font in &p.files {
+ db.load_font_source(cosmic_text::fontdb::Source::Binary(font.clone()));
+ }
+ if let Some(f) = &p.serif {
+ db.set_serif_family(f.clone());
+ }
+ if let Some(f) = &p.sans_serif {
+ db.set_sans_serif_family(f.clone());
+ }
+ if let Some(f) = &p.monospace {
+ db.set_monospace_family(f.clone());
+ }
+ }
+
+ fn has(db: &Database, family: &str) -> bool {
+ db.faces().any(|f| f.families.iter().any(|(n, _)| n.eq_ignore_ascii_case(family)))
+ }
+
+ /// cosmic-text falls back from a family the database lacks through a
+ /// list of well-known families per OS — on Linux "Noto Sans", then
+ /// "DejaVu Sans", "FreeSans", … (its `fallback/unix.rs`) — and has no
+ /// list at all on wasm, where such text lands on whichever face happens
+ /// to come first (Noto Color Emoji, in a set loaded as Linux loads it).
+ /// So here the families the toolkit itself names — the configured
+ /// fonts, and the house default "Berkeley Mono" — are given, when absent,
+ /// the faces of the first family of that Linux list the page provided,
+ /// and the generic sans and serif families likewise unless the page named
+ /// them; "monospace" (the name `fc-match` would have resolved) is given
+ /// the first monospaced one.
+ /// Text then resolves to the face it would on a Linux box with the same
+ /// fonts. A family an app names itself must be among the page's fonts.
+ pub(crate) fn stand_in_for_missing(db: &mut Database) {
+ const SANS: [&str; 6] = ["Noto Sans", "DejaVu Sans", "FreeSans", "Noto Sans Mono", "DejaVu Sans Mono", "FreeMono"];
+ const MONO: [&str; 4] = ["Noto Sans Mono", "DejaVu Sans Mono", "FreeMono", "Liberation Mono"];
+ let first = |db: &Database, list: &[&str]| list.iter().find(|f| has(db, f)).map(|f| f.to_string());
+ let alias = |db: &mut Database, name: &str, to: &str| {
+ if name.is_empty() || has(db, name) {
+ return;
+ }
+ let faces: Vec<_> =
+ db.faces().filter(|f| f.families.iter().any(|(n, _)| n == to)).cloned().collect();
+ for mut face in faces {
+ face.families = vec![(name.to_string(), Language::English_UnitedStates)];
+ db.push_face_info(face);
+ }
+ };
+ if let Some(sans) = first(db, &SANS) {
+ let mut names: Vec<String> = vec!["Berkeley Mono".into()];
+ names.extend(
+ [
+ crate::layout::list_font_parsed().0,
+ crate::layout::menubar_font_parsed().0,
+ crate::layout::statusbar_font_parsed().0,
+ crate::layout::font_selector_font_parsed().0,
+ crate::layout::button_strip_font_parsed().0,
+ crate::layout::control_label_font_parsed().0,
+ crate::layout::control_label_font_detached_parsed().0,
+ crate::layout::tree_font_parsed().0,
+ crate::layout::graph_font_parsed().0,
+ crate::layout::graph_node_font_parsed().0,
+ ]
+ .into_iter()
+ .filter(|f| !matches!(f.as_str(), "sans-serif" | "serif" | "monospace")),
+ );
+ for name in &names {
+ alias(db, name, &sans);
+ }
+ for generic in [cosmic_text::Family::SansSerif, cosmic_text::Family::Serif] {
+ if !has(db, db.family_name(&generic)) {
+ match generic {
+ cosmic_text::Family::SansSerif => db.set_sans_serif_family(sans.clone()),
+ _ => db.set_serif_family(sans.clone()),
+ }
+ }
+ }
+ }
+ if let Some(mono) = first(db, &MONO) {
+ alias(db, "monospace", &mono);
+ }
+ }
+}
diff --git a/src/main.rs b/src/main.rs
index 9c5cf5e..c6455e9 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -31,7 +31,7 @@ use cce_ui::widget::{
};
#[derive(Debug, Clone)]
-enum DemoMessage {
+pub(crate) enum DemoMessage {
Exit,
}
@@ -47,7 +47,7 @@ fn text_leaf_height(font_size: f32) -> f32 {
(font_size * 1.2).ceil()
}
-struct DemoApp {
+pub(crate) struct DemoApp {
// ── Widgets: app-owned values on the narrow-trait adapter. Their addresses must be
// stable across frames (plain struct fields, not Vec elements): the UiContext
// registry and the router's drag-target bookkeeping hold pointers to them.
@@ -159,7 +159,7 @@ impl Application for DemoApp {
gradient.push(255);
}
}
- let gradient_id = cce_ui::vk::upload_rgba(gradient, GRADIENT_W, GRADIENT_H);
+ let gradient_id = cce_ui::draw::upload_rgba(gradient, GRADIENT_W, GRADIENT_H);
Self {
// Relief styling (raised buttons/toggles/dropdowns, recessed
// wells) is the `control_relief` config default — no opt-in.
@@ -563,5 +563,7 @@ impl Application for DemoApp {
}
fn main() {
+ // In a browser the same app is run by `examples/demo_web.rs`.
+ #[cfg(not(target_arch = "wasm32"))]
cce_ui::engine::run::<DemoApp>();
}
diff --git a/src/web/mod.rs b/src/web/mod.rs
index c71e053..6644aff 100644
--- a/src/web/mod.rs
+++ b/src/web/mod.rs
@@ -1,12 +1,15 @@
//! The browser's side of cce-ui: a WebGPU renderer that draws the same
//! [`Frame2D`](crate::draw::Frame2D)s the Vulkan renderer does, from the same
//! shaders (`draw::shaders`), atlas (`draw::glyphs`) and image queue
-//! (`draw::images`). The browser shell that drives it — the page's events in,
-//! `Pacer::turn` on animation frames — is the next step of the port (W3).
+//! (`draw::images`), and the shell that runs an `Application` on a canvas
+//! with it ([`run`]) — the page's events in through the shared `Driver`,
+//! `Pacer::turn` on animation frames.
//!
//! wasm32 only, and it needs web-sys's WebGPU bindings switched on
//! (`--cfg=web_sys_unstable_apis`, set for this crate in `.cargo/config.toml`).
mod renderer;
+mod shell;
-pub use renderer::{Capture, WebRenderer};
+pub use renderer::{Capture, PendingCapture, WebRenderer};
+pub use shell::{capture, run, Fonts, Sizing};
diff --git a/src/web/renderer.rs b/src/web/renderer.rs
index 3f7d7aa..d9354fc 100644
--- a/src/web/renderer.rs
+++ b/src/web/renderer.rs
@@ -110,6 +110,38 @@ pub struct Capture {
pub rgba: Vec<u8>,
}
+/// A captured frame still on the GPU: [`read`](Self::read) waits for it.
+pub struct PendingCapture {
+ buffer: GpuBuffer,
+ width: u32,
+ height: u32,
+ /// Bytes per row in the buffer: a copy's rows are 256-byte aligned.
+ row: u32,
+ /// The canvas is BGRA: swap to RGBA on the way out.
+ bgra: bool,
+}
+
+impl PendingCapture {
+ pub async fn read(self) -> Result<Capture, JsValue> {
+ let Self { buffer, width, height, row, bgra } = self;
+ buffer.map_async(map_mode::READ).await?;
+ let mapped = js_sys::Uint8Array::new(&JsValue::from(buffer.get_mapped_range()?));
+ let padded = mapped.to_vec();
+ buffer.unmap();
+ buffer.destroy();
+ let mut rgba = Vec::with_capacity((width * height * 4) as usize);
+ for y in 0..height as usize {
+ rgba.extend_from_slice(&padded[y * row as usize..y * row as usize + width as usize * 4]);
+ }
+ if bgra {
+ for px in rgba.chunks_exact_mut(4) {
+ px.swap(0, 2);
+ }
+ }
+ Ok(Capture { width, height, rgba })
+ }
+}
+
pub struct WebRenderer {
/// Held for the device's lifetime: the device is the adapter's, and a
/// browser may tear the instance under it down once nothing holds it.
@@ -488,22 +520,17 @@ impl WebRenderer {
/// The captured frame, once the GPU has finished it.
pub async fn take_capture(&mut self) -> Result<Option<Capture>, JsValue> {
- let Some((buffer, width, height, row)) = self.capture.take() else { return Ok(None) };
- buffer.map_async(map_mode::READ).await?;
- let mapped = js_sys::Uint8Array::new(&JsValue::from(buffer.get_mapped_range()?));
- let padded = mapped.to_vec();
- buffer.unmap();
- buffer.destroy();
- let mut rgba = Vec::with_capacity((width * height * 4) as usize);
- for y in 0..height as usize {
- rgba.extend_from_slice(&padded[y * row as usize..y * row as usize + width as usize * 4]);
- }
- if matches!(self.canvas_format, GpuTextureFormat::Bgra8unorm) {
- for px in rgba.chunks_exact_mut(4) {
- px.swap(0, 2);
- }
+ match self.take_pending_capture() {
+ Some(pending) => pending.read().await.map(Some),
+ None => Ok(None),
}
- Ok(Some(Capture { width, height, rgba }))
+ }
+
+ /// The captured frame's buffer, to be read without holding the renderer
+ /// across the wait (the browser shell reads it after the turn that drew it).
+ pub fn take_pending_capture(&mut self) -> Option<PendingCapture> {
+ let (buffer, width, height, row) = self.capture.take()?;
+ Some(PendingCapture { buffer, width, height, row, bgra: matches!(self.canvas_format, GpuTextureFormat::Bgra8unorm) })
}
/// Apply the image queue: uploads, in-place updates, frees.
diff --git a/src/web/shell.rs b/src/web/shell.rs
new file mode 100644
index 0000000..00b7096
--- /dev/null
+++ b/src/web/shell.rs
@@ -0,0 +1,675 @@
+//! The browser shell: an [`Application`] run on a `<canvas>`, the way the
+//! Wayland shell runs one on a surface. It is the third piece of the run
+//! loop over the shared two — [`Driver`] routes what the page's events mean,
+//! [`Pacer::turn`] decides what a turn does — and what is left here is the
+//! page's side of both:
+//!
+//! - **Events in.** Pointer, wheel, key and focus events on the canvas,
+//! mapped into driver calls in cce-ui's terms (`map_key`, `wheel_frame`).
+//! A page has no grabs, so a press on what would be a CSD border is the
+//! app's ([`PressSite::can_grab`] false).
+//! - **Turns.** One turn per animation frame while anything moves (the
+//! pacer's ACTIVE cadence), a timer while idle; any event, and any message
+//! on the app's [`AppSender`](crate::engine::AppSender), wakes the loop
+//! for the next frame. Presenting happens inside an animation-frame
+//! callback, which is the browser's own frame pacing — there is no
+//! outstanding frame to wait on ([`Shell::frame_pending`] is false).
+//! - **Frames out.** [`build_frame`] at the canvas's CSS size and the page's
+//! `devicePixelRatio`, drawn by the [`WebRenderer`].
+//!
+//! The page owns the canvas's place in it; [`Sizing`] says who owns its
+//! size. There is no context-menu popup surface here: the menu is drawn in
+//! the canvas and kept inside it (`context_menu::constrain_to`), as on a
+//! layer surface.
+
+use std::cell::{Cell, RefCell};
+use std::rc::Rc;
+use std::sync::mpsc::Receiver;
+use std::time::Duration;
+
+use cursor_icon::CursorIcon;
+use wasm_bindgen::prelude::*;
+use wasm_bindgen::JsCast;
+use web_sys::{AddEventListenerOptions, FocusEvent, HtmlCanvasElement, KeyboardEvent, PointerEvent, WheelEvent};
+
+use super::renderer::{Capture, WebRenderer};
+use crate::backend::app::{set_wake, AppSender, Application, LogicalPosition, LogicalSize};
+use crate::backend::dom::{map_key, wheel_frame};
+use crate::backend::driver::{Driver, Modifiers, PressSite, ScrollFrame, ScrollSource, Turn};
+use crate::backend::frame::build_frame;
+use crate::backend::shell::{Pacer, Shell, Step, ACTIVE_DISPATCH};
+use crate::widget::{context_menu, ElementState, MouseButton, TextItem};
+
+/// Who decides the canvas's size.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Sizing {
+ /// The app does, as it sizes a window: the canvas is given the app's
+ /// `WindowSettings` size at start and its `desired_size` after (CSS px).
+ App,
+ /// The page does: the canvas is wherever its CSS box puts it, the app's
+ /// size requests are not honoured (a tiling compositor's answer), and a
+ /// change of the box is a resize.
+ Page,
+}
+
+/// The fonts an app is run with: a page has no font directory and no
+/// fontconfig, so it says both what there is and what the generic families
+/// are. A generic left `None` is the first family the set has of a list of
+/// well-known ones (DejaVu, Noto, FreeFont, …).
+#[derive(Debug, Clone, Default)]
+pub struct Fonts {
+ /// Font files' bytes (TrueType, OpenType, collections), in the order a
+ /// directory scan would find them: where text falls back to the first
+ /// face holding a glyph, order decides it.
+ pub files: Vec<Vec<u8>>,
+ pub serif: Option<String>,
+ pub sans_serif: Option<String>,
+ pub monospace: Option<String>,
+}
+
+impl Fonts {
+ pub fn new(files: Vec<Vec<u8>>) -> Self {
+ Self { files, ..Default::default() }
+ }
+}
+
+/// Run `A` on `canvas`, its text set in `fonts`. Returns once the app is
+/// up; the loop runs on the page's callbacks from then on, until the app
+/// asks to exit.
+///
+/// One app per page: the toolkit's context menu is one per thread, and so is
+/// [`set_wake`].
+pub async fn run<A: Application>(canvas: HtmlCanvasElement, fonts: Fonts, sizing: Sizing) -> Result<(), JsValue> {
+ // Every font database the toolkit builds from here on — this one, its
+ // own for widget geometry, and the measuring one — loads the page's.
+ crate::page_fonts::provide(fonts.files, fonts.serif, fonts.sans_serif, fonts.monospace);
+ let fs = crate::create_font_system();
+
+ let renderer = WebRenderer::new(canvas.clone()).await?;
+ // The scale is known before the app is built, as the Wayland shell sets
+ // it before `create`: a widget may read it as it is made.
+ let dpr = device_pixel_ratio();
+ crate::scale::set_scale_factor(dpr as f32);
+ let (tx, rx) = std::sync::mpsc::channel();
+ let app = A::create(AppSender::from(tx));
+ let settings = app.settings();
+ crate::scale::set_app_id(settings.app_id.clone());
+ if let Some(doc) = web_sys::window().and_then(|w| w.document()) {
+ doc.set_title(&settings.title);
+ }
+
+ let style = canvas.style();
+ if sizing == Sizing::App {
+ set_css_size(&canvas, settings.width, settings.height);
+ }
+ // The canvas takes the keyboard (it is focused on a press), draws no
+ // focus outline of its own, and keeps touches for the app rather than
+ // panning the page.
+ canvas.set_tab_index(0);
+ let _ = style.set_property("outline", "none");
+ let _ = style.set_property("touch-action", "none");
+
+ let shell = WebShell {
+ app,
+ driver: Driver::new(),
+ redraw: true,
+ exit: false,
+ rx,
+ renderer,
+ fs,
+ swash: cosmic_text::SwashCache::new(),
+ items: Vec::new(),
+ damage_owed: true,
+ canvas: canvas.clone(),
+ sizing,
+ logical: (0.0, 0.0),
+ scale: dpr,
+ just_configured: false,
+ cursor: CursorIcon::Default,
+ mac: is_mac(),
+ };
+ let lp = Rc::new(Loop {
+ shell: RefCell::new(shell),
+ pacer: RefCell::new(Pacer::new(settings.title)),
+ sched: RefCell::new(Sched::default()),
+ frame_cb: RefCell::new(None),
+ timer_cb: RefCell::new(None),
+ finger_end_cb: RefCell::new(None),
+ finger_timer: Cell::new(None),
+ });
+ lp.shell.borrow_mut().measure();
+ lp.shell.borrow_mut().just_configured = true;
+ Loop::install(&lp, &canvas)?;
+ lp.wake();
+ Ok(())
+}
+
+thread_local! {
+ /// Callers of [`capture`] waiting on the next frame: each a promise's resolve.
+ static CAPTURES: RefCell<Vec<js_sys::Function>> = const { RefCell::new(Vec::new()) };
+}
+
+/// The next frame the shell draws, read back from the GPU. A fresh frame is
+/// asked for, as an input event would ask, and this resolves once it is
+/// drawn. For screenshot tests: a headless browser compositing in software
+/// leaves a WebGPU canvas out of its page screenshots (and `toDataURL`).
+pub async fn capture() -> Result<Capture, JsValue> {
+ let promise = js_sys::Promise::new(&mut |resolve, _reject| CAPTURES.with(|c| c.borrow_mut().push(resolve)));
+ crate::backend::app::wake();
+ let got = wasm_bindgen_futures::JsFuture::from(promise).await?;
+ let got: js_sys::Array = got.dyn_into().map_err(|_| JsValue::from_str("cce-ui: the frame was not captured"))?;
+ Ok(Capture {
+ width: got.get(0).as_f64().unwrap_or(0.0) as u32,
+ height: got.get(1).as_f64().unwrap_or(0.0) as u32,
+ rgba: js_sys::Uint8Array::new(&got.get(2)).to_vec(),
+ })
+}
+
+/// The page's side of the run loop, as the Wayland shell's `EngineState` is
+/// the compositor's.
+struct WebShell<A: Application> {
+ app: A,
+ driver: Driver,
+ redraw: bool,
+ exit: bool,
+ rx: Receiver<A::Message>,
+ renderer: WebRenderer,
+ fs: cosmic_text::FontSystem,
+ swash: cosmic_text::SwashCache,
+ /// The frame's display-list text, shaped by [`build_frame`].
+ items: Vec<TextItem>,
+ damage_owed: bool,
+ canvas: HtmlCanvasElement,
+ sizing: Sizing,
+ /// The canvas's CSS size, and `devicePixelRatio`, as last measured.
+ logical: (f32, f32),
+ scale: f64,
+ just_configured: bool,
+ cursor: CursorIcon,
+ /// Command is the shortcut key here: ⌘Z is undo, as every Mac app has it.
+ mac: bool,
+}
+
+impl<A: Application> WebShell<A> {
+ /// Read the canvas's box and the pixel ratio, and size the drawing
+ /// buffer to them. Whether either changed.
+ fn measure(&mut self) -> bool {
+ let dpr = device_pixel_ratio();
+ let w = self.canvas.client_width().max(0) as f32;
+ let h = self.canvas.client_height().max(0) as f32;
+ if (w, h) == self.logical && dpr == self.scale {
+ return false;
+ }
+ self.logical = (w, h);
+ if dpr != self.scale {
+ self.scale = dpr;
+ crate::scale::set_scale_factor(dpr as f32);
+ }
+ let (pw, ph) = self.physical();
+ if self.renderer.size() != (pw, ph) {
+ self.renderer.resize(pw, ph);
+ }
+ self.redraw = true;
+ true
+ }
+
+ fn physical(&self) -> (u32, u32) {
+ let s = self.scale as f32;
+ ((self.logical.0 * s).round().max(1.0) as u32, (self.logical.1 * s).round().max(1.0) as u32)
+ }
+
+ fn size(&self) -> LogicalSize {
+ LogicalSize::new(self.logical.0, self.logical.1)
+ }
+
+ /// Hand the messages posted since the last turn to `update`, as the
+ /// Wayland loop's channel source does between dispatches.
+ fn drain_messages(&mut self) {
+ while let Ok(msg) = self.rx.try_recv() {
+ let mut rebuild = false;
+ self.app.update(msg, &mut rebuild, &mut self.exit);
+ self.redraw |= rebuild;
+ }
+ }
+
+ fn mods_from(&self, ctrl: bool, shift: bool, alt: bool, meta: bool) -> Modifiers {
+ if self.mac {
+ Modifiers { ctrl: ctrl || meta, shift, alt, logo: false }
+ } else {
+ Modifiers { ctrl, shift, alt, logo: meta }
+ }
+ }
+
+ fn sync_mods(&mut self, mods: Modifiers) {
+ if mods != self.driver.mods {
+ self.driver.set_modifiers(&mut self.app, mods);
+ }
+ }
+
+ fn pointer_pos(&mut self, e: &PointerEvent) -> LogicalPosition {
+ let (x, y) = (e.offset_x() as f32, e.offset_y() as f32);
+ self.driver.cursor_pos = (x, y);
+ LogicalPosition::new(x, y)
+ }
+
+ fn update_cursor(&mut self) {
+ let (x, y) = self.driver.cursor_pos;
+ let icon = self.driver.cursor_icon_at(&self.app, x, y, self.size());
+ if icon != self.cursor {
+ self.cursor = icon;
+ let _ = self.canvas.style().set_property("cursor", icon.name());
+ }
+ }
+}
+
+impl<A: Application> Shell for WebShell<A> {
+ type App = A;
+
+ fn turn(&mut self) -> (&mut Driver, Turn<'_, A>) {
+ (&mut self.driver, Turn { app: &mut self.app, redraw: &mut self.redraw, exit: &mut self.exit })
+ }
+
+ fn app(&self) -> &A {
+ &self.app
+ }
+
+ fn redraw(&mut self) -> &mut bool {
+ &mut self.redraw
+ }
+
+ fn exit_requested(&self) -> bool {
+ self.exit
+ }
+
+ fn take_just_configured(&mut self) -> bool {
+ std::mem::replace(&mut self.just_configured, false)
+ }
+
+ fn request_size(&mut self, w: u32, h: u32) {
+ if self.sizing == Sizing::App && (w as f32, h as f32) != self.logical {
+ set_css_size(&self.canvas, w, h);
+ self.measure();
+ }
+ }
+
+ fn sync(&mut self) {
+ // The page laid the canvas out anew, or moved it to another
+ // display: a configure, which wins the next turn over the app's size.
+ if self.measure() {
+ self.just_configured = true;
+ }
+ if CAPTURES.with(|c| !c.borrow().is_empty()) {
+ self.redraw = true;
+ }
+ // No popup surface to host the menu: it is drawn in the canvas and
+ // kept inside it, frames asked for while a page turn animates.
+ if context_menu::is_visible() {
+ if context_menu::is_turning() {
+ self.redraw = true;
+ }
+ context_menu::set_hosted(false);
+ context_menu::constrain_to(0.0, 0.0, self.logical.0, self.logical.1);
+ }
+ }
+
+ fn set_title(&mut self, title: &str) {
+ if let Some(doc) = web_sys::window().and_then(|w| w.document()) {
+ doc.set_title(title);
+ }
+ }
+
+ fn frame_pending(&mut self) -> bool {
+ false
+ }
+
+ fn configured(&self) -> bool {
+ self.logical.0 > 0.0 && self.logical.1 > 0.0
+ }
+
+ fn present(&mut self, _fresh: bool) {
+ let size = self.size();
+ let frame = build_frame(&mut self.app, &mut self.fs, size, self.scale, &mut self.damage_owed, &mut self.items);
+ if frame.dl_text {
+ let spans = frame.text_spans(&self.items);
+ self.renderer.prepare_text(&mut self.fs, &mut self.swash, &spans);
+ }
+ let waiting = CAPTURES.with(|c| std::mem::take(&mut *c.borrow_mut()));
+ if !waiting.is_empty() {
+ self.renderer.capture_next_frame();
+ }
+ match self.renderer.draw_frame_2d(frame.frame2d()) {
+ Ok(()) => self.damage_owed = false,
+ Err(e) => {
+ web_sys::console::error_2(&"cce-ui: frame not drawn:".into(), &e);
+ self.redraw = true;
+ }
+ }
+ if waiting.is_empty() {
+ return;
+ }
+ let pending = self.renderer.take_pending_capture();
+ wasm_bindgen_futures::spawn_local(async move {
+ let got = match pending {
+ Some(p) => p.read().await.ok(),
+ None => None,
+ };
+ let value: JsValue = match got {
+ Some(c) => js_sys::Array::of3(&c.width.into(), &c.height.into(), &js_sys::Uint8Array::from(&c.rgba[..])).into(),
+ None => JsValue::NULL,
+ };
+ for resolve in waiting {
+ let _ = resolve.call1(&JsValue::NULL, &value);
+ }
+ });
+ }
+}
+
+/// When the next turn is due.
+#[derive(Default)]
+struct Sched {
+ /// An animation frame is requested: the next turn is in it.
+ frame: Option<i32>,
+ /// An idle timer is set; it requests the frame when it fires.
+ timer: Option<i32>,
+ /// The app exited: nothing turns again.
+ stopped: bool,
+}
+
+/// The shell, the pacer and the schedule, shared by the page's callbacks.
+/// The schedule is its own cell, borrowed only briefly and never across a
+/// call into the app, so a message the app sends mid-turn can wake the loop.
+struct Loop<A: Application> {
+ shell: RefCell<WebShell<A>>,
+ pacer: RefCell<Pacer>,
+ sched: RefCell<Sched>,
+ frame_cb: RefCell<Option<Closure<dyn FnMut(f64)>>>,
+ timer_cb: RefCell<Option<Closure<dyn FnMut()>>>,
+ finger_end_cb: RefCell<Option<Closure<dyn FnMut()>>>,
+ /// The lift timer the last finger frame set; the next frame cancels it.
+ finger_timer: Cell<Option<i32>>,
+}
+
+/// A browser reports no lift for a two-finger scroll: this long without a
+/// finger frame is one, and ends the gesture (`ScrollPhase::FingerEnd`) so a
+/// flick coasts and a side swipe readies its next turn.
+const FINGER_LIFT: Duration = Duration::from_millis(120);
+
+impl<A: Application> Loop<A> {
+ /// Turn the loop at the next animation frame.
+ fn wake(&self) {
+ let mut s = self.sched.borrow_mut();
+ if s.stopped || s.frame.is_some() {
+ return;
+ }
+ let win = window();
+ if let Some(t) = s.timer.take() {
+ win.clear_timeout_with_handle(t);
+ }
+ let cb = self.frame_cb.borrow();
+ if let Some(cb) = cb.as_ref() {
+ s.frame = win.request_animation_frame(cb.as_ref().unchecked_ref()).ok();
+ }
+ }
+
+ /// One turn, in an animation frame.
+ fn on_frame(&self) {
+ self.sched.borrow_mut().frame = None;
+ let step = {
+ let mut shell = self.shell.borrow_mut();
+ shell.drain_messages();
+ self.pacer.borrow_mut().turn(&mut *shell)
+ };
+ match step {
+ Step::Exit => {
+ self.sched.borrow_mut().stopped = true;
+ set_wake(None);
+ self.shell.borrow_mut().app.on_exit();
+ }
+ Step::Sleep(d) if d <= ACTIVE_DISPATCH => self.wake(),
+ Step::Sleep(d) => {
+ let mut s = self.sched.borrow_mut();
+ let cb = self.timer_cb.borrow();
+ // A frame already requested (the app posted itself a message
+ // mid-turn) turns sooner than any timer would.
+ if let (None, None, Some(cb)) = (s.frame, s.timer, cb.as_ref()) {
+ s.timer = window()
+ .set_timeout_with_callback_and_timeout_and_arguments_0(cb.as_ref().unchecked_ref(), d.as_millis() as i32)
+ .ok();
+ }
+ }
+ }
+ }
+
+ /// Dispatch an event to the shell, then turn soon. Input after the exit
+ /// is dropped.
+ fn event(&self, f: impl FnOnce(&mut WebShell<A>)) {
+ if self.sched.borrow().stopped {
+ return;
+ }
+ // A page event fired from inside a turn (a focus change the app's
+ // own DOM call caused) finds the shell borrowed: it is dropped
+ // rather than panicking the page.
+ let Ok(mut shell) = self.shell.try_borrow_mut() else { return };
+ f(&mut shell);
+ drop(shell);
+ self.wake();
+ }
+
+ /// The callbacks: the loop's own two, the wake hook, and the canvas's events.
+ fn install(lp: &Rc<Self>, canvas: &HtmlCanvasElement) -> Result<(), JsValue> {
+ let l = lp.clone();
+ *lp.frame_cb.borrow_mut() = Some(Closure::new(move |_t: f64| l.on_frame()));
+ let l = lp.clone();
+ *lp.timer_cb.borrow_mut() = Some(Closure::new(move || {
+ l.sched.borrow_mut().timer = None;
+ l.wake();
+ }));
+ let l = lp.clone();
+ *lp.finger_end_cb.borrow_mut() = Some(Closure::new(move || l.finger_lift()));
+ let l = Rc::downgrade(lp);
+ set_wake(Some(Box::new(move || {
+ if let Some(l) = l.upgrade() {
+ l.wake();
+ }
+ })));
+
+ let target: &web_sys::EventTarget = canvas.as_ref();
+ let l = lp.clone();
+ listen(target, "pointermove", false, move |e: PointerEvent| {
+ l.event(|s| {
+ let pos = s.pointer_pos(&e);
+ let (driver, t) = s.turn();
+ driver.pointer_motion(t, pos);
+ s.update_cursor();
+ })
+ })?;
+ let l = lp.clone();
+ listen(target, "pointerenter", false, move |e: PointerEvent| {
+ l.event(|s| {
+ let pos = s.pointer_pos(&e);
+ let (driver, t) = s.turn();
+ driver.pointer_enter(t, pos);
+ s.update_cursor();
+ })
+ })?;
+ let l = lp.clone();
+ listen(target, "pointerleave", false, move |_e: PointerEvent| {
+ l.event(|s| {
+ let (driver, t) = s.turn();
+ driver.pointer_leave(t);
+ })
+ })?;
+ let l = lp.clone();
+ let c = canvas.clone();
+ listen(target, "pointerdown", true, move |e: PointerEvent| {
+ let Some(btn) = dom_button(e.button()) else { return };
+ // Take the keyboard first: the focus event this fires is
+ // dispatched now, before the shell is borrowed below. Capture
+ // keeps a drag's moves and its release coming to the canvas when
+ // the pointer leaves it, as a Wayland implicit grab does.
+ let _ = c.focus();
+ let _ = c.set_pointer_capture(e.pointer_id());
+ e.prevent_default();
+ l.event(|s| {
+ let mods = s.mods_from(e.ctrl_key(), e.shift_key(), e.alt_key(), e.meta_key());
+ s.sync_mods(mods);
+ let pos = s.pointer_pos(&e);
+ let site = PressSite { size: s.size(), on_popup: false, can_grab: false };
+ let (driver, t) = s.turn();
+ driver.pointer_press(t, btn, pos, site);
+ })
+ })?;
+ let l = lp.clone();
+ listen(target, "pointerup", false, move |e: PointerEvent| {
+ let Some(btn) = dom_button(e.button()) else { return };
+ l.event(|s| {
+ let pos = s.pointer_pos(&e);
+ let (driver, t) = s.turn();
+ driver.pointer_release(t, btn, pos);
+ s.update_cursor();
+ })
+ })?;
+ // The right button is the app's, not the page's menu.
+ listen(target, "contextmenu", true, |e: web_sys::Event| e.prevent_default())?;
+ let l = lp.clone();
+ listen(target, "wheel", true, move |e: WheelEvent| {
+ e.prevent_default();
+ let frame = wheel_frame(e.delta_mode(), e.delta_x(), e.delta_y());
+ l.event(|s| {
+ let mods = s.mods_from(e.ctrl_key(), e.shift_key(), e.alt_key(), e.meta_key());
+ s.sync_mods(mods);
+ let pos = LogicalPosition::new(e.offset_x() as f32, e.offset_y() as f32);
+ s.driver.cursor_pos = (pos.x, pos.y);
+ let (driver, t) = s.turn();
+ driver.scroll(t, frame, pos);
+ });
+ if frame.source == Some(ScrollSource::Finger) {
+ l.arm_finger_lift();
+ }
+ })?;
+ let l = lp.clone();
+ listen(target, "keydown", true, move |e: KeyboardEvent| l.key(&e, ElementState::Pressed))?;
+ let l = lp.clone();
+ listen(target, "keyup", true, move |e: KeyboardEvent| l.key(&e, ElementState::Released))?;
+ for (name, focused) in [("focus", true), ("blur", false)] {
+ let l = lp.clone();
+ listen(target, name, false, move |_e: FocusEvent| {
+ l.event(|s| {
+ let (driver, t) = s.turn();
+ driver.keyboard_focus(t, focused);
+ })
+ })?;
+ }
+ // The page laying the canvas out anew is a configure; the turn it
+ // wakes measures the box (`sync`).
+ let l = lp.clone();
+ let observer = Closure::<dyn FnMut()>::new(move || l.wake());
+ let ro = web_sys::ResizeObserver::new(observer.as_ref().unchecked_ref())?;
+ ro.observe(canvas);
+ observer.forget();
+ std::mem::forget(ro);
+ Ok(())
+ }
+
+ fn key(&self, e: &KeyboardEvent, state: ElementState) {
+ let Some((key, text)) = map_key(&e.key(), e.ctrl_key() || e.meta_key()) else { return };
+ if !passes_to_page(e) {
+ e.prevent_default();
+ }
+ // The driver repeats a held key itself, at the toolkit's own rate
+ // (`KEY_REPEAT_DELAY` / `_INTERVAL`), as it does on Wayland, where
+ // the compositor sends one press: the browser's repeats are dropped.
+ if e.repeat() {
+ return;
+ }
+ self.event(|s| {
+ let mods = s.mods_from(e.ctrl_key(), e.shift_key(), e.alt_key(), e.meta_key());
+ s.sync_mods(mods);
+ let (driver, t) = s.turn();
+ driver.key(t, key, text, state);
+ });
+ }
+
+ fn arm_finger_lift(&self) {
+ let win = window();
+ if let Some(t) = self.finger_timer.take() {
+ win.clear_timeout_with_handle(t);
+ }
+ if let Some(cb) = self.finger_end_cb.borrow().as_ref() {
+ self.finger_timer.set(
+ win.set_timeout_with_callback_and_timeout_and_arguments_0(
+ cb.as_ref().unchecked_ref(),
+ FINGER_LIFT.as_millis() as i32,
+ )
+ .ok(),
+ );
+ }
+ }
+
+ /// No finger frame for [`FINGER_LIFT`]: the gesture ended.
+ fn finger_lift(&self) {
+ self.finger_timer.set(None);
+ let frame = ScrollFrame { source: Some(ScrollSource::Finger), stop: true, ..Default::default() };
+ self.event(|s| {
+ let (x, y) = s.driver.cursor_pos;
+ let (driver, t) = s.turn();
+ driver.scroll(t, frame, LogicalPosition::new(x, y));
+ });
+ }
+}
+
+fn window() -> web_sys::Window {
+ web_sys::window().expect("cce-ui's browser shell runs in a window")
+}
+
+fn device_pixel_ratio() -> f64 {
+ web_sys::window().map_or(1.0, |w| w.device_pixel_ratio())
+}
+
+fn is_mac() -> bool {
+ web_sys::window()
+ .and_then(|w| w.navigator().platform().ok())
+ .is_some_and(|p| p.starts_with("Mac") || p.starts_with("iP"))
+}
+
+fn set_css_size(canvas: &HtmlCanvasElement, w: u32, h: u32) {
+ let style = canvas.style();
+ let _ = style.set_property("width", &format!("{w}px"));
+ let _ = style.set_property("height", &format!("{h}px"));
+}
+
+/// Add `f` as the target's `name` listener for the page's lifetime. A
+/// `cancelable` one is added `passive: false`, so it may cancel the event's
+/// default (the page's scroll, menu, focus move or shortcut).
+fn listen<E: JsCast + 'static>(
+ target: &web_sys::EventTarget,
+ name: &str,
+ cancelable: bool,
+ mut f: impl FnMut(E) + 'static,
+) -> Result<(), JsValue> {
+ let cb = Closure::<dyn FnMut(web_sys::Event)>::new(move |e: web_sys::Event| f(e.unchecked_into()));
+ let opts = AddEventListenerOptions::new();
+ opts.set_passive(!cancelable);
+ target.add_event_listener_with_callback_and_add_event_listener_options(name, cb.as_ref().unchecked_ref(), &opts)?;
+ cb.forget();
+ Ok(())
+}
+
+/// A DOM `button` as the driver's. The back and forward buttons are not
+/// buttons the toolkit has.
+fn dom_button(b: i16) -> Option<MouseButton> {
+ match b {
+ 0 => Some(MouseButton::Left),
+ 1 => Some(MouseButton::Middle),
+ 2 => Some(MouseButton::Right),
+ _ => None,
+ }
+}
+
+/// Keys the page keeps however the app routes keys: reload and the
+/// developer tools.
+fn passes_to_page(e: &KeyboardEvent) -> bool {
+ let k = e.key();
+ let accel = e.ctrl_key() || e.meta_key();
+ k == "F5" || k == "F12" || (accel && (k == "r" || k == "R")) || (accel && e.shift_key() && (k == "I" || k == "J"))
+}
diff --git a/src/widget/input/text_box.rs b/src/widget/input/text_box.rs
index 0fb4647..0b52757 100644
--- a/src/widget/input/text_box.rs
+++ b/src/widget/input/text_box.rs
@@ -30,8 +30,14 @@ static FONT_DB: OnceLock<resvg::usvg::fontdb::Database> = OnceLock::new();
pub fn get_font_db() -> &'static resvg::usvg::fontdb::Database {
FONT_DB.get_or_init(|| {
let mut db = resvg::usvg::fontdb::Database::new();
- db.load_system_fonts();
- db.load_fonts_dir(crate::fonts_dir());
+ #[cfg(not(target_arch = "wasm32"))]
+ {
+ db.load_system_fonts();
+ db.load_fonts_dir(crate::fonts_dir());
+ }
+ // A page has neither: the fonts it handed the browser shell.
+ #[cfg(target_arch = "wasm32")]
+ crate::page_fonts::load_into(&mut db);
db
})
}