damascene_web/lib.rs
1//! Browser host for Damascene wasm apps.
2//!
3//! Write normal UI code against `damascene_core::prelude::*`, then call
4//! [`start_with`] from your wasm crate's `#[wasm_bindgen(start)]`
5//! entry point. The host opens a wgpu surface against a canvas in the
6//! page and drives the app through winit's browser event loop.
7//!
8//! The default configuration expects a `<canvas id="damascene_canvas">`.
9//! Use [`start_with_config`] when embedding into a page with a different
10//! canvas id.
11//!
12//! `damascene-winit-wgpu` is the equivalent reusable native host.
13
14use damascene_core::Rect;
15
16/// Default canvas element id used by [`WebHostConfig::default`].
17pub const DEFAULT_CANVAS_ID: &str = "damascene_canvas";
18
19/// Default logical viewport. Sized to feel reasonable both as a winit
20/// window and as a browser canvas. Browsers can override this by
21/// resizing the canvas; the runner reacts to `winit::Resized`.
22pub const VIEWPORT: Rect = Rect {
23 x: 0.0,
24 y: 0.0,
25 w: 900.0,
26 h: 640.0,
27};
28
29/// Browser host configuration.
30#[derive(Clone, Debug)]
31pub struct WebHostConfig {
32 /// Fallback logical viewport used when the canvas has no CSS size
33 /// yet. Once the page lays the canvas out, the host tracks its CSS
34 /// box through `ResizeObserver`.
35 pub viewport: Rect,
36 /// Id of the canvas element the host should attach to.
37 pub canvas_id: String,
38}
39
40impl WebHostConfig {
41 pub fn new(viewport: Rect) -> Self {
42 Self {
43 viewport,
44 canvas_id: DEFAULT_CANVAS_ID.to_string(),
45 }
46 }
47
48 pub fn with_canvas_id(mut self, canvas_id: impl Into<String>) -> Self {
49 self.canvas_id = canvas_id.into();
50 self
51 }
52}
53
54impl Default for WebHostConfig {
55 fn default() -> Self {
56 Self::new(VIEWPORT)
57 }
58}
59
60#[cfg(target_arch = "wasm32")]
61pub use web_entry::{WebHandle, install_logger, start_with, start_with_config};
62
63#[cfg(not(target_arch = "wasm32"))]
64pub use native_stub::{WebHandle, install_logger, start_with, start_with_config};
65
66/// Single-threaded message queue bridging async browser callbacks into
67/// the render loop — the standard wasm integration shape every app was
68/// inventing (fetch results, WebSocket messages, file reads):
69///
70/// 1. Create one `Mailbox<Msg>` per message stream; clone it into your
71/// `spawn_local` futures and JS callbacks.
72/// 2. After [`start_with_config`] returns, call [`Mailbox::set_handle`]
73/// with the [`WebHandle`] so pushes wake the host.
74/// 3. [`Mailbox::push`] from callbacks — it queues the value and
75/// requests a redraw.
76/// 4. [`Mailbox::drain`] in `App::before_build` and fold the messages
77/// into app state; the frame being built sees them.
78///
79/// `Clone` is a cheap `Rc` bump (wasm is single-threaded; this type is
80/// deliberately not `Send`). Pushes before `set_handle` are queued and
81/// delivered on the first frame after the host starts.
82#[derive(Clone)]
83pub struct Mailbox<T> {
84 inner: std::rc::Rc<MailboxInner<T>>,
85}
86
87struct MailboxInner<T> {
88 queue: std::cell::RefCell<std::collections::VecDeque<T>>,
89 handle: std::cell::RefCell<Option<WebHandle>>,
90}
91
92impl<T> Default for Mailbox<T> {
93 fn default() -> Self {
94 Self::new()
95 }
96}
97
98impl<T> Mailbox<T> {
99 /// An empty mailbox with no host attached yet.
100 pub fn new() -> Self {
101 Self {
102 inner: std::rc::Rc::new(MailboxInner {
103 queue: std::cell::RefCell::new(std::collections::VecDeque::new()),
104 handle: std::cell::RefCell::new(None),
105 }),
106 }
107 }
108
109 /// Attach the host's redraw handle (the [`WebHandle`] returned by
110 /// [`start_with_config`]) so subsequent pushes wake the render
111 /// loop. Idempotent; replaces any previous handle.
112 pub fn set_handle(&self, handle: WebHandle) {
113 *self.inner.handle.borrow_mut() = Some(handle);
114 }
115
116 /// Queue a message and request a redraw (when a handle is
117 /// attached). Call from `spawn_local` futures and JS event
118 /// callbacks.
119 pub fn push(&self, value: T) {
120 self.inner.queue.borrow_mut().push_back(value);
121 if let Some(handle) = self.inner.handle.borrow().as_ref() {
122 handle.request_redraw();
123 }
124 }
125
126 /// Take every queued message, in push order. Call once per frame
127 /// from `App::before_build`.
128 pub fn drain(&self) -> Vec<T> {
129 self.inner.queue.borrow_mut().drain(..).collect()
130 }
131
132 /// True when nothing is queued.
133 pub fn is_empty(&self) -> bool {
134 self.inner.queue.borrow().is_empty()
135 }
136}
137
138#[cfg(not(target_arch = "wasm32"))]
139mod native_stub {
140 use damascene_core::{App, Rect};
141
142 use super::WebHostConfig;
143
144 /// Browser redraw handle.
145 ///
146 /// On non-wasm targets this is a no-op placeholder so host crates
147 /// can type-check shared code. It is only functional on
148 /// `wasm32-unknown-unknown`.
149 #[derive(Clone, Debug, Default)]
150 pub struct WebHandle {
151 _private: (),
152 }
153
154 impl WebHandle {
155 pub fn request_redraw(&self) {}
156 pub fn destroy(&self) {}
157 }
158
159 pub fn start_with<A: App + 'static>(_viewport: Rect, _app: A) -> WebHandle {
160 panic!("damascene-web can only start apps on wasm32-unknown-unknown")
161 }
162
163 /// No-op on non-wasm targets; see the wasm doc.
164 pub fn install_logger(_level: log::Level) {}
165
166 pub fn start_with_config<A: App + 'static>(_config: WebHostConfig, _app: A) -> WebHandle {
167 panic!("damascene-web can only start apps on wasm32-unknown-unknown")
168 }
169}
170
171// ---- Wasm host ----
172//
173// Lives in its own module so it can pull in wasm-only deps without
174// polluting native builds.
175
176#[cfg(target_arch = "wasm32")]
177mod web_entry {
178 use std::cell::{Cell, RefCell};
179 use std::collections::VecDeque;
180 use std::rc::Rc;
181 use std::sync::Arc;
182
183 use damascene_core::{
184 App, BuildCx, Cursor, FrameTrigger, HostDiagnostics, KeyModifiers, LogicalKey, NamedKey,
185 Palette, PhysicalKey, Pointer, PointerButton, PointerId, PointerKind, Rect, UiEvent,
186 UiEventKind, clipboard,
187 widgets::text_input::{self, ClipboardKind},
188 };
189 use damascene_wgpu::{PrepareTimings, Runner, RunnerCaps};
190 use damascene_winit::{key_modifiers, map_key, map_physical, winit_cursor};
191
192 // MSAA is off on the browser. The WebGL2 path doesn't advertise
193 // `MULTISAMPLED_SHADING`, so MSAA gives nothing to the SDF stock
194 // surfaces (they do their own analytic AA in the fragment shader);
195 // it would only have improved vector-icon polygon-edge AA. With it
196 // on, Firefox + Mesa's implicit MSAA resolve was mis-syncing
197 // partial regions of the swapchain — the sidebar would freeze at
198 // its previous pixels until something forced a tree reshape. WebGPU
199 // (Chromium) was unaffected but we use the same value for both
200 // browser backends to keep one code path. Revisit once the WebGL2
201 // resolve issue is understood (or once WebGPU is the only target).
202 const SAMPLE_COUNT: u32 = 1;
203 use wasm_bindgen::JsCast;
204 use wasm_bindgen::prelude::Closure;
205 use web_time::{Duration, Instant};
206 use winit::application::ApplicationHandler;
207 use winit::event::{ElementState, MouseScrollDelta, WindowEvent};
208 use winit::event_loop::{ActiveEventLoop, EventLoop};
209 use winit::platform::web::{EventLoopExtWebSys, WindowAttributesExtWebSys};
210 use winit::window::{Window, WindowId};
211
212 use super::WebHostConfig;
213
214 /// Number of redraws to accumulate before logging an averaged
215 /// frame-timing line. 60 → roughly once per second at 60fps when
216 /// animations are in flight; for idle UI (no redraws) the log
217 /// just stops, which is the right behavior.
218 const FRAME_LOG_INTERVAL: u32 = 60;
219
220 /// Pointer event captured by a DOM listener and queued for the
221 /// next frame's dispatch pass. We can't dispatch directly inside
222 /// the closure because the app handle and the renderer live on
223 /// `Host`, which is owned by winit's event loop and only reachable
224 /// through `&mut self` in `window_event`. The queue lets the
225 /// closures stay simple (push + request_redraw) while the
226 /// dispatch path runs with full host state.
227 enum QueuedPointer {
228 Move(Pointer),
229 Down(Pointer),
230 Up(Pointer),
231 Cancel,
232 Leave,
233 }
234
235 /// Map `PointerEvent.pointerType` → [`PointerKind`].
236 fn pointer_kind_from_type(s: &str) -> PointerKind {
237 match s {
238 "touch" => PointerKind::Touch,
239 "pen" => PointerKind::Pen,
240 // "mouse", "" or any future / unknown value falls back to
241 // mouse semantics — that's the conservative default for
242 // hover-driven affordances.
243 _ => PointerKind::Mouse,
244 }
245 }
246
247 /// Map `PointerEvent.button` → [`PointerButton`]. `None` for
248 /// buttons Damascene does not route (back, forward, pen eraser).
249 fn pointer_button_from_event(b: i16) -> Option<PointerButton> {
250 match b {
251 0 => Some(PointerButton::Primary),
252 1 => Some(PointerButton::Middle),
253 2 => Some(PointerButton::Secondary),
254 _ => None,
255 }
256 }
257
258 /// Translate a DOM `PointerEvent` to an Damascene [`Pointer`]. Uses
259 /// `offset_x`/`offset_y` because they are already canvas-local
260 /// CSS pixels — the runtime expects logical-pixel coordinates,
261 /// so no DPI division is needed (in contrast to winit's
262 /// physical-pixel `CursorMoved`).
263 fn pointer_from_event(event: &web_sys::PointerEvent, button: PointerButton) -> Pointer {
264 let pressure = event.pressure();
265 Pointer {
266 x: event.offset_x() as f32,
267 y: event.offset_y() as f32,
268 button,
269 kind: pointer_kind_from_type(&event.pointer_type()),
270 id: PointerId(event.pointer_id() as u32),
271 // PointerEvent always returns a value for `pressure`, but
272 // it's `0.0` for non-pressure-sensitive devices (mouse).
273 // `Some(0.0)` would be misleading, so we filter that case.
274 pressure: if pressure > 0.0 { Some(pressure) } else { None },
275 }
276 }
277
278 /// Rolling per-frame timing bucket. Three top-level CPU stages
279 /// (`build`, `prepare`, `submit`) plus a per-stage breakdown of
280 /// what's inside `prepare` (layout / draw_ops / paint / gpu_upload
281 /// / snapshot — see [`PrepareTimings`]). `inter` is the wall-clock
282 /// interval between consecutive RedrawRequested calls; comparing
283 /// `build + prepare + submit` against `inter` shows how much frame
284 /// budget the CPU is burning vs. how much the browser's rAF throttle
285 /// gives us.
286 #[derive(Default)]
287 struct FrameStats {
288 build_us: u64,
289 prepare_us: u64,
290 submit_us: u64,
291 inter_us: u64,
292 // Sub-buckets inside prepare. Sum is ~prepare_us minus a few
293 // microseconds of Instant::now() overhead.
294 layout_us: u64,
295 draw_ops_us: u64,
296 paint_us: u64,
297 gpu_upload_us: u64,
298 snapshot_us: u64,
299 samples: u32,
300 last_frame_start: Option<Instant>,
301 }
302
303 impl FrameStats {
304 fn record(
305 &mut self,
306 frame_start: Instant,
307 t1: Instant,
308 t2: Instant,
309 t3: Instant,
310 prep: PrepareTimings,
311 ) {
312 self.build_us += (t1 - frame_start).as_micros() as u64;
313 self.prepare_us += (t2 - t1).as_micros() as u64;
314 self.submit_us += (t3 - t2).as_micros() as u64;
315 self.layout_us += prep.layout.as_micros() as u64;
316 self.draw_ops_us += prep.draw_ops.as_micros() as u64;
317 self.paint_us += prep.paint.as_micros() as u64;
318 self.gpu_upload_us += prep.gpu_upload.as_micros() as u64;
319 self.snapshot_us += prep.snapshot.as_micros() as u64;
320 if let Some(prev) = self.last_frame_start {
321 self.inter_us += (frame_start - prev).as_micros() as u64;
322 }
323 self.last_frame_start = Some(frame_start);
324 self.samples += 1;
325 if self.samples >= FRAME_LOG_INTERVAL {
326 self.flush();
327 }
328 }
329
330 fn flush(&mut self) {
331 // `inter` averages over `samples - 1` because the first
332 // frame in each window has no prior frame to diff against.
333 let n = self.samples as u64;
334 let inter_n = (self.samples.saturating_sub(1)) as u64;
335 let build = self.build_us / n;
336 let prepare = self.prepare_us / n;
337 let submit = self.submit_us / n;
338 let layout = self.layout_us / n;
339 let draw_ops = self.draw_ops_us / n;
340 let paint = self.paint_us / n;
341 let gpu_upload = self.gpu_upload_us / n;
342 let snapshot = self.snapshot_us / n;
343 let cpu = build + prepare + submit;
344 let inter = self.inter_us.checked_div(inter_n).unwrap_or(0);
345 let util = (cpu * 100).checked_div(inter).unwrap_or(0);
346 log::info!(
347 "frame[{n}] inter={:.2}ms cpu={:.2}ms util={util}% | build={:.2} prepare={:.2} (layout={:.2} draw_ops={:.2} paint={:.2} gpu={:.2} snapshot={:.2}) submit={:.2}",
348 inter as f64 / 1000.0,
349 cpu as f64 / 1000.0,
350 build as f64 / 1000.0,
351 prepare as f64 / 1000.0,
352 layout as f64 / 1000.0,
353 draw_ops as f64 / 1000.0,
354 paint as f64 / 1000.0,
355 gpu_upload as f64 / 1000.0,
356 snapshot as f64 / 1000.0,
357 submit as f64 / 1000.0,
358 );
359 self.build_us = 0;
360 self.prepare_us = 0;
361 self.submit_us = 0;
362 self.inter_us = 0;
363 self.layout_us = 0;
364 self.draw_ops_us = 0;
365 self.paint_us = 0;
366 self.gpu_upload_us = 0;
367 self.snapshot_us = 0;
368 self.samples = 0;
369 // Keep last_frame_start so `inter` in the next window
370 // includes the gap from the last logged frame to the
371 // first frame of the new window.
372 }
373 }
374
375 /// Wire the global `tracing` subscriber to `tracing-wasm`, which
376 /// emits `performance.mark` / `performance.measure` calls for every
377 /// span. Open DevTools → Performance, hit Record, exercise the UI;
378 /// each span shows up as a labeled User Timing measure in the
379 /// flamegraph (`prepare::layout`, `paint::text::shape_runs`, etc).
380 /// Defaults are fine — span events go to console.log, measures get
381 /// written, and the subscriber only sees enabled spans (no extra
382 /// filter wiring needed on top of the `profiling` feature).
383 #[cfg(feature = "profiling")]
384 fn install_profiling_subscriber() {
385 tracing_wasm::set_as_global_default();
386 }
387
388 /// Route `log` macros to the browser console and surface panics
389 /// with a stack trace — the `eframe::WebLogger` equivalent. Safe
390 /// to call multiple times; the first level wins (the `log` crate
391 /// allows one global logger).
392 ///
393 /// [`start_with_config`] calls this with [`log::Level::Info`]
394 /// automatically, so most apps never need it. Call it yourself
395 /// *before* starting the host when you want a different level or
396 /// logs from pre-start setup code:
397 ///
398 /// ```ignore
399 /// damascene_web::install_logger(log::Level::Debug);
400 /// let handle = damascene_web::start_with_config(config, app);
401 /// ```
402 pub fn install_logger(level: log::Level) {
403 console_error_panic_hook::set_once();
404 let _ = console_log::init_with_level(level);
405 }
406
407 /// Handle returned by [`start_with`] so embedding code can wake the
408 /// host after external browser events enqueue app work, and tear
409 /// the host down when the embedding page unmounts the canvas.
410 #[derive(Clone)]
411 pub struct WebHandle {
412 inner: Rc<WebHandleInner>,
413 }
414
415 struct WebHandleInner {
416 window: RefCell<Option<Arc<Window>>>,
417 ready: Cell<bool>,
418 pending_redraw: Cell<bool>,
419 destroy: Cell<bool>,
420 }
421
422 impl WebHandle {
423 fn new() -> Self {
424 Self {
425 inner: Rc::new(WebHandleInner {
426 window: RefCell::new(None),
427 ready: Cell::new(false),
428 pending_redraw: Cell::new(false),
429 destroy: Cell::new(false),
430 }),
431 }
432 }
433
434 /// Request a redraw from external browser integration code.
435 ///
436 /// If the browser window or GPU setup is not ready yet, the
437 /// request is remembered and flushed once setup completes.
438 /// No-op after [`Self::destroy`].
439 pub fn request_redraw(&self) {
440 if self.inner.destroy.get() {
441 return;
442 }
443 if self.inner.ready.get()
444 && let Some(window) = self.inner.window.borrow().as_ref()
445 {
446 window.request_redraw();
447 return;
448 }
449 self.inner.pending_redraw.set(true);
450 }
451
452 /// Tear down the host: stop the event loop, unregister every
453 /// DOM listener and the `ResizeObserver` this host installed,
454 /// remove the hidden soft-keyboard `<input>` from the
455 /// document, and release the GPU surface. Call this when an
456 /// SPA unmounts the canvas — without it each mount leaks the
457 /// previous host's listeners and appended input, and a later
458 /// fire of a leaked listener throws (its Rust closure is
459 /// gone).
460 ///
461 /// Returns immediately; the teardown itself runs on the next
462 /// event-loop turn (the handle wakes the loop). Idempotent —
463 /// repeated calls, or calls racing the still-async GPU setup,
464 /// are safe. After destroy the handle is inert:
465 /// [`Self::request_redraw`] becomes a no-op, and a new
466 /// [`start_with`] call (with a fresh canvas of the same id)
467 /// creates an independent host.
468 ///
469 /// The canvas element itself is left in the page — it belongs
470 /// to the embedding markup, not to Damascene.
471 pub fn destroy(&self) {
472 self.inner.destroy.set(true);
473 // Wake the loop so the host observes the flag. Before the
474 // window exists (`resumed` hasn't run) the flag alone is
475 // enough: `resumed` checks it before installing anything.
476 if let Some(window) = self.inner.window.borrow().as_ref() {
477 window.request_redraw();
478 }
479 }
480
481 fn destroy_requested(&self) -> bool {
482 self.inner.destroy.get()
483 }
484
485 fn set_window(&self, window: Arc<Window>) {
486 *self.inner.window.borrow_mut() = Some(window);
487 }
488
489 /// Drop the handle's `Arc<Window>` so the winit window (and
490 /// the canvas listeners it owns) can actually die at teardown
491 /// even while the embedder keeps the handle around.
492 fn clear_window(&self) {
493 *self.inner.window.borrow_mut() = None;
494 }
495
496 fn mark_ready(&self) -> bool {
497 self.inner.ready.set(true);
498 self.inner.pending_redraw.replace(false)
499 }
500 }
501
502 /// Start an Damascene app in the browser using the default canvas id.
503 ///
504 /// Call this from the downstream crate's own
505 /// `#[wasm_bindgen(start)]` function.
506 pub fn start_with<A: App + 'static>(viewport: Rect, app: A) -> WebHandle {
507 start_with_config(WebHostConfig::new(viewport), app)
508 }
509
510 /// Start an Damascene app in the browser with explicit host config.
511 ///
512 /// The function spawns winit's web event loop and returns
513 /// immediately. Keep the returned [`WebHandle`] anywhere external
514 /// JS callbacks need to wake Damascene after pushing work into
515 /// app-owned shared state.
516 ///
517 /// # GPU-setup failures
518 ///
519 /// Adapter/device acquisition is async and finishes after the
520 /// page's `init()` promise resolved, so a browser with neither
521 /// usable WebGPU nor WebGL2 cannot reject `init()`. Failures are
522 /// reported as a bubbling `damascene-error` `CustomEvent` on the
523 /// canvas with `detail = { kind: "gpu-setup", message }` — listen
524 /// there (or on the document) to show an error UI.
525 ///
526 /// # SPA lifecycle
527 ///
528 /// For a full-page canvas that lives as long as the tab, the
529 /// handle can simply be kept (or dropped — the host runs either
530 /// way). When the canvas is mounted by an SPA framework, pair
531 /// every mount with [`WebHandle::destroy`] on unmount:
532 ///
533 /// ```ignore
534 /// // mount: (the canvas element must already be in the DOM)
535 /// let handle = start_with_config(WebHostConfig::new(viewport), app);
536 /// // unmount: stop the loop, unregister listeners + observer,
537 /// // remove the hidden soft-keyboard input, release GPU.
538 /// handle.destroy();
539 /// ```
540 ///
541 /// Without the destroy, each remount leaks the previous host —
542 /// its DOM listeners, `ResizeObserver`, hidden `<input>`, and GPU
543 /// surface — and a leaked listener that later fires throws into a
544 /// dropped Rust closure. After `destroy()`, mounting again with a
545 /// fresh canvas (same id is fine) creates an independent host.
546 pub fn start_with_config<A: App + 'static>(config: WebHostConfig, app: A) -> WebHandle {
547 // Surface panics in the browser console with a stack trace —
548 // without this hook a wasm panic dies silently as `unreachable`.
549 // (No-ops if the app called `install_logger` earlier with its
550 // own level.)
551 install_logger(log::Level::Info);
552 // When built with `--features profiling`, route every
553 // `profile_span!` call to the browser's User Timing API so spans
554 // show up as named measures in DevTools → Performance alongside
555 // the page's own frame/script work. Off-builds compile this away.
556 #[cfg(feature = "profiling")]
557 install_profiling_subscriber();
558
559 let event_loop = EventLoop::new().expect("EventLoop::new");
560 let handle = WebHandle::new();
561 let host = Host::new(config, app, handle.clone());
562 // spawn_app hands control to the browser. Native uses
563 // run_app(...) which blocks; on wasm32 the event loop is
564 // driven by the browser's animation-frame callbacks.
565 event_loop.spawn_app(host);
566 handle
567 }
568
569 /// Open a URL surfaced by `App::drain_link_opens` in a new tab.
570 /// `_blank` matches what users expect for a click on an external
571 /// link in app UI; `noopener` severs the `window.opener` reference
572 /// so the opened page can't reverse-control this one. Failures are
573 /// logged rather than panicking — popup blockers and CSP rules can
574 /// reject the open and the showcase shouldn't crash because the
575 /// browser said no.
576 fn open_link(url: &str) {
577 let Some(window) = web_sys::window() else {
578 log::warn!("damascene-web: no window; dropping link open for {url}");
579 return;
580 };
581 if let Err(err) = window.open_with_url_and_target_and_features(url, "_blank", "noopener") {
582 log::warn!("damascene-web: window.open({url}) failed: {err:?}");
583 }
584 }
585
586 /// Surface a fatal GPU-setup failure to the embedding page.
587 ///
588 /// Adapter/device acquisition runs in an async task *after*
589 /// `start_with` (and therefore the page's `init()` promise) has
590 /// resolved, so a panic there would bypass any `init().catch(...)`
591 /// error UI entirely — the user gets a blank canvas and a console
592 /// `unreachable`. Instead the failure is logged and dispatched as
593 /// a bubbling `damascene-error` `CustomEvent` on the canvas, with
594 /// `detail = { kind: "gpu-setup", message }`. Listen on the canvas
595 /// (or, since it bubbles, the document) to show an error UI:
596 ///
597 /// ```js
598 /// canvas.addEventListener('damascene-error', (e) => {
599 /// showFatalError(e.detail.message);
600 /// });
601 /// ```
602 fn report_setup_error(canvas: &web_sys::HtmlCanvasElement, message: &str) {
603 log::error!("damascene-web: {message}");
604 let detail = js_sys::Object::new();
605 let _ = js_sys::Reflect::set(&detail, &"kind".into(), &"gpu-setup".into());
606 let _ = js_sys::Reflect::set(&detail, &"message".into(), &message.into());
607 let init = web_sys::CustomEventInit::new();
608 init.set_bubbles(true);
609 init.set_detail(&detail);
610 if let Ok(event) = web_sys::CustomEvent::new_with_event_init_dict("damascene-error", &init)
611 {
612 let _ = canvas.dispatch_event(&event);
613 }
614 }
615
616 /// Locate the configured canvas element in the host page.
617 fn locate_canvas(canvas_id: &str) -> web_sys::HtmlCanvasElement {
618 let window = web_sys::window().expect("no window");
619 let document = window.document().expect("no document");
620 document
621 .get_element_by_id(canvas_id)
622 .unwrap_or_else(|| panic!("missing #{canvas_id} canvas element"))
623 .dyn_into::<web_sys::HtmlCanvasElement>()
624 .unwrap_or_else(|_| panic!("#{canvas_id} is not a canvas"))
625 }
626
627 /// Read the canvas's CSS-laid-out box at the device pixel ratio.
628 /// Returned size is what the swapchain backing buffer should match;
629 /// callers pass it to `apply_canvas_size` to actually reconfigure
630 /// the surface.
631 fn measure_canvas(canvas: &web_sys::HtmlCanvasElement, fallback: Rect) -> (u32, u32) {
632 let dpr = web_sys::window()
633 .map(|w| w.device_pixel_ratio())
634 .unwrap_or(1.0)
635 .max(1.0);
636 let css_w = if canvas.client_width() > 0 {
637 canvas.client_width() as f64
638 } else {
639 fallback.w.max(1.0) as f64
640 };
641 let css_h = if canvas.client_height() > 0 {
642 canvas.client_height() as f64
643 } else {
644 fallback.h.max(1.0) as f64
645 };
646 let phys_w = (css_w * dpr).round() as u32;
647 let phys_h = (css_h * dpr).round() as u32;
648 (phys_w, phys_h)
649 }
650
651 /// Set the canvas's drawing buffer to `(phys_w, phys_h)` and
652 /// reconfigure the surface + MSAA target to match. Called once at
653 /// initial setup and on every ResizeObserver fire afterward.
654 ///
655 /// We bypass winit's `request_inner_size` round-trip — the web
656 /// backend doesn't reliably translate it into a `Resized` event, so
657 /// canvas resizes mid-session were leaving the swapchain stretched
658 /// at the original size until the page reloaded. Doing the
659 /// reconfigure inline keeps the surface in lockstep with the
660 /// canvas.
661 fn apply_canvas_size(
662 canvas: &web_sys::HtmlCanvasElement,
663 gfx: &mut Gfx,
664 phys_w: u32,
665 phys_h: u32,
666 ) {
667 canvas.set_width(phys_w);
668 canvas.set_height(phys_h);
669 if gfx.config.width == phys_w && gfx.config.height == phys_h {
670 return;
671 }
672 gfx.config.width = phys_w;
673 gfx.config.height = phys_h;
674 gfx.surface.configure(&gfx.device, &gfx.config);
675 gfx.renderer.set_surface_size(phys_w, phys_h);
676 if let Some(msaa) = gfx.msaa.as_mut() {
677 let extent = surface_extent(&gfx.config);
678 if !msaa.matches(extent) {
679 *msaa = damascene_wgpu::MsaaTarget::new(
680 &gfx.device,
681 gfx.render_format,
682 extent,
683 SAMPLE_COUNT,
684 );
685 }
686 }
687 }
688
689 /// The canvas pointer listeners as (event name, callback) pairs,
690 /// kept so [`Host::teardown`] can unregister each one.
691 type PointerListeners = Vec<(&'static str, Closure<dyn FnMut(web_sys::PointerEvent)>)>;
692
693 /// Install `pointermove` / `pointerdown` / `pointerup` /
694 /// `pointercancel` / `pointerleave` listeners on `canvas` and
695 /// stash the closures in `out` for the host's lifetime.
696 ///
697 /// Each listener pushes onto the shared queue and requests a
698 /// redraw; the host's `window_event` drains the queue at the top
699 /// of every call. `pointerdown` also calls `setPointerCapture` so
700 /// the pointer keeps reporting to the canvas during a drag even
701 /// when the contact slides off — without this, slider scrubbing
702 /// and text-selection drag stop the moment the finger leaves the
703 /// element.
704 fn install_pointer_listeners(
705 canvas: &web_sys::HtmlCanvasElement,
706 window: &Arc<Window>,
707 pending: &Rc<RefCell<VecDeque<QueuedPointer>>>,
708 gfx: &Rc<RefCell<Option<Gfx>>>,
709 soft_keyboard: Option<&Rc<SoftKeyboard>>,
710 out: &mut PointerListeners,
711 ) {
712 // pointermove
713 {
714 let pending = pending.clone();
715 let window = window.clone();
716 let closure: Closure<dyn FnMut(web_sys::PointerEvent)> =
717 Closure::new(move |event: web_sys::PointerEvent| {
718 let p = pointer_from_event(&event, PointerButton::Primary);
719 pending.borrow_mut().push_back(QueuedPointer::Move(p));
720 window.request_redraw();
721 });
722 canvas
723 .add_event_listener_with_callback("pointermove", closure.as_ref().unchecked_ref())
724 .expect("add pointermove listener");
725 out.push(("pointermove", closure));
726 }
727
728 // pointerdown
729 {
730 let pending = pending.clone();
731 let window = window.clone();
732 let canvas_for_capture = canvas.clone();
733 let gfx_for_hit = gfx.clone();
734 let soft_keyboard = soft_keyboard.cloned();
735 let closure: Closure<dyn FnMut(web_sys::PointerEvent)> =
736 Closure::new(move |event: web_sys::PointerEvent| {
737 let Some(button) = pointer_button_from_event(event.button()) else {
738 return;
739 };
740 let p = pointer_from_event(&event, button);
741 // Soft-keyboard summon must happen synchronously
742 // inside this user-gesture handler — iOS rejects
743 // programmatic `.focus()` from any later context.
744 // Hit-test against the runner's last laid-out
745 // tree (read-only borrow) to decide whether the
746 // press would land on a text-input widget; if
747 // so, focus the hidden input now. The runner-
748 // side dispatch follows on the next frame via
749 // the queue/drain path.
750 let mut focused_input = false;
751 if matches!(p.kind, PointerKind::Touch | PointerKind::Pen)
752 && let Some(sk) = soft_keyboard.as_ref()
753 {
754 let want_keyboard = gfx_for_hit
755 .borrow()
756 .as_ref()
757 .map(|g| g.renderer.would_press_focus_text_input(p.x, p.y))
758 .unwrap_or(false);
759 if want_keyboard {
760 sk.focus_if_needed();
761 focused_input = true;
762 }
763 }
764 // Take focus on tap-down so subsequent keydown
765 // events (soft keyboard, hardware keyboard on
766 // tablets) reach the canvas. winit's web backend
767 // would normally do this for compat-mouse events,
768 // but we no longer route through there.
769 //
770 // Skip when the input was just focused — the
771 // canvas is fighting for the same DOM focus, and
772 // taking it back here was preventing Android (and
773 // iOS) from ever seeing a focused input long
774 // enough to summon the on-screen keyboard.
775 // Hardware-keyboard input into a text input still
776 // works because keystrokes reach the input's
777 // own listeners and route through `text_input` /
778 // `key_down` the same way they would via the
779 // canvas's keydown handler.
780 if !focused_input {
781 let _ = canvas_for_capture
782 .dyn_ref::<web_sys::HtmlElement>()
783 .and_then(|el| el.focus().ok());
784 }
785 // Keep this pointer captured so a drag that
786 // slides off the canvas still produces events to
787 // the runner (essential for touch sliders,
788 // drag-select, and text-input scrubbing).
789 let _ = canvas_for_capture.set_pointer_capture(event.pointer_id());
790 // When the press just summoned the on-screen
791 // keyboard, suppress the browser's default
792 // pointerdown action so it doesn't shift DOM
793 // focus to the canvas (a tabindex=0 element)
794 // after our listener returns. Android Chrome
795 // does that focus shift as part of touch
796 // pointerdown handling on focusable elements,
797 // and the resulting blur on our hidden input
798 // dismisses the keyboard one frame after it
799 // appears. We also stopPropagation so any
800 // document-level listener the host page wires
801 // doesn't get a second crack at shifting focus.
802 if focused_input {
803 event.prevent_default();
804 event.stop_propagation();
805 }
806 pending.borrow_mut().push_back(QueuedPointer::Down(p));
807 window.request_redraw();
808 });
809 canvas
810 .add_event_listener_with_callback("pointerdown", closure.as_ref().unchecked_ref())
811 .expect("add pointerdown listener");
812 out.push(("pointerdown", closure));
813 }
814
815 // pointerup
816 {
817 let pending = pending.clone();
818 let window = window.clone();
819 let closure: Closure<dyn FnMut(web_sys::PointerEvent)> =
820 Closure::new(move |event: web_sys::PointerEvent| {
821 let Some(button) = pointer_button_from_event(event.button()) else {
822 return;
823 };
824 let p = pointer_from_event(&event, button);
825 pending.borrow_mut().push_back(QueuedPointer::Up(p));
826 window.request_redraw();
827 });
828 canvas
829 .add_event_listener_with_callback("pointerup", closure.as_ref().unchecked_ref())
830 .expect("add pointerup listener");
831 out.push(("pointerup", closure));
832 }
833
834 // pointercancel — fired when the OS / browser steals the
835 // pointer (e.g., a system gesture interrupts a touch). Routed
836 // to the runtime's cancel entry so in-flight press / gesture
837 // captures abandon without applying release effects.
838 {
839 let pending = pending.clone();
840 let window = window.clone();
841 let closure: Closure<dyn FnMut(web_sys::PointerEvent)> =
842 Closure::new(move |_event: web_sys::PointerEvent| {
843 pending.borrow_mut().push_back(QueuedPointer::Cancel);
844 window.request_redraw();
845 });
846 canvas
847 .add_event_listener_with_callback("pointercancel", closure.as_ref().unchecked_ref())
848 .expect("add pointercancel listener");
849 out.push(("pointercancel", closure));
850 }
851
852 // pointerleave — pointer left the canvas. Mirrors winit's
853 // CursorLeft on native; clears hover state.
854 {
855 let pending = pending.clone();
856 let window = window.clone();
857 let closure: Closure<dyn FnMut(web_sys::PointerEvent)> =
858 Closure::new(move |_event: web_sys::PointerEvent| {
859 pending.borrow_mut().push_back(QueuedPointer::Leave);
860 window.request_redraw();
861 });
862 canvas
863 .add_event_listener_with_callback("pointerleave", closure.as_ref().unchecked_ref())
864 .expect("add pointerleave listener");
865 out.push(("pointerleave", closure));
866 }
867 }
868
869 // ===================================================================
870 // Soft keyboard
871 //
872 // A `<canvas>` cannot summon the on-screen keyboard on touch
873 // platforms — only focusable text-input DOM elements can, and only
874 // when the focus comes from a user-gesture event handler. This
875 // module overlays a hidden `<input type="text">` and synchronously
876 // focuses it from the pointerdown DOM listener when the press would
877 // land on an Damascene text-input widget. Once focused, the input
878 // receives `input` events for typed characters (routed to the
879 // runtime as `text_input(...)`) and `keydown` events for editing
880 // keys (routed as synthetic `key_down(Backspace, ...)`).
881 //
882 // The native host (damascene-winit-wgpu) routes hardware keyboards
883 // through winit and is unaffected by any of this. Soft keyboards
884 // on a future Android winit host would use winit's own IME path.
885 // ===================================================================
886
887 /// One discrete edit produced by the soft keyboard. Drained by
888 /// the host once per `window_event` and dispatched through the
889 /// runtime's existing keyboard / text-input entry points so the
890 /// focused widget sees the same shape it would for a hardware
891 /// keystroke.
892 enum TextEdit {
893 /// User typed text — route as `runner.text_input(s)`.
894 Insert(String),
895 /// User pressed an editing key (Backspace, Enter, arrows,
896 /// Delete, Home/End) — route as `runner.key_down(key, ...)`.
897 Key(LogicalKey, PhysicalKey),
898 }
899
900 /// The hidden `<input>` that summons the soft keyboard plus its
901 /// DOM listeners and the pending-edit queue. Held by [`Host`]
902 /// for the lifetime of the page; the closures inside borrow
903 /// the queue via clones of its `Rc`.
904 ///
905 /// Modeled on egui's `text_agent.rs` after observing that
906 /// Android's keyboard refused to stay open against an
907 /// `opacity:0; pointer-events:none` element. Egui keeps the
908 /// element technically interactive (no pointer-events: none),
909 /// uses `<input type="text">` rather than `<textarea>`, and
910 /// hides it via `caret-color: transparent` +
911 /// `background-color: transparent` instead of opacity. Android
912 /// then treats it as a real focusable input and the keyboard
913 /// stays up.
914 struct SoftKeyboard {
915 input: web_sys::HtmlInputElement,
916 /// Whether we believe the input currently holds DOM focus.
917 /// Tracked here (rather than read via `document.activeElement`
918 /// every time) so `focus_if_needed` can no-op for repeated
919 /// taps that don't actually need to refocus. `Rc<Cell<_>>`
920 /// because the `blur` closure also writes to it when the OS
921 /// dismisses the keyboard outside our control.
922 focused: Rc<Cell<bool>>,
923 /// Queue of edits captured by the DOM listeners since the
924 /// last drain. Drained by [`Host`] inside `window_event`.
925 pending: Rc<RefCell<VecDeque<TextEdit>>>,
926 /// The `input` event closure; unregistered in
927 /// [`Self::uninstall`].
928 input_closure: Closure<dyn FnMut(web_sys::InputEvent)>,
929 /// The `keydown` closure that catches editing keys
930 /// (Backspace, Enter, arrow keys) the soft keyboard fires as
931 /// `keydown` rather than `input`. Unregistered in
932 /// [`Self::uninstall`].
933 keydown_closure: Closure<dyn FnMut(web_sys::KeyboardEvent)>,
934 /// The `blur` closure that resets `focused` when the OS /
935 /// user dismisses the keyboard outside of our control.
936 /// Unregistered in [`Self::uninstall`].
937 blur_closure: Closure<dyn FnMut(web_sys::Event)>,
938 }
939
940 impl SoftKeyboard {
941 /// Create the hidden input, attach it to the document, and
942 /// wire up the listeners. Returns `None` if any DOM
943 /// operation fails (no body, etc.) — the host then runs
944 /// without soft-keyboard support, which is the correct
945 /// degradation for environments where it can't work.
946 fn install(canvas: &web_sys::HtmlCanvasElement, window: &Arc<Window>) -> Option<Self> {
947 let document = canvas.owner_document()?;
948 let input = document
949 .create_element("input")
950 .ok()?
951 .dyn_into::<web_sys::HtmlInputElement>()
952 .ok()?;
953 input.set_type("text");
954 // Visible-for-focus, invisible-for-the-eye. The element
955 // has to remain *technically* focusable for Android's
956 // keyboard to stay up — `pointer-events: none`,
957 // `opacity: 0`, and `display: none` all disqualify. We
958 // mirror egui's working configuration: a 1×1
959 // transparent-background element with the caret hidden,
960 // pinned to `(0, 0)` of the document. The canvas paints
961 // on top of everything else and absorbs every visible
962 // tap; the input is just a DOM focus target.
963 if let Some(style) = input.dyn_ref::<web_sys::HtmlElement>().map(|e| e.style()) {
964 let _ = style.set_property("position", "absolute");
965 let _ = style.set_property("top", "0");
966 let _ = style.set_property("left", "0");
967 let _ = style.set_property("width", "1px");
968 let _ = style.set_property("height", "1px");
969 let _ = style.set_property("background-color", "transparent");
970 let _ = style.set_property("border", "none");
971 let _ = style.set_property("outline", "none");
972 let _ = style.set_property("caret-color", "transparent");
973 }
974 // Attribute hygiene: prevent the on-screen keyboard from
975 // showing autocorrect suggestions / capitalization /
976 // browser autofill, which would interfere with character-
977 // by-character routing into the runtime.
978 let _ = input.set_attribute("autocapitalize", "off");
979 let _ = input.set_attribute("autocomplete", "off");
980 let _ = input.set_attribute("autocorrect", "off");
981 let _ = input.set_attribute("spellcheck", "false");
982 document.body()?.append_child(&input).ok()?;
983
984 let pending: Rc<RefCell<VecDeque<TextEdit>>> = Rc::new(RefCell::new(VecDeque::new()));
985
986 // input: fires on every character insertion and on
987 // deletes. Read inputType to discriminate; route to the
988 // pending queue and clear the input so the next event
989 // sees only the new edit (we don't keep the input's
990 // value as the source of truth — the focused Damascene
991 // widget owns the actual string).
992 //
993 // Android Gboard workaround (from egui): after a
994 // non-composition `input`, blur and refocus the element
995 // so the predictive-text suggestion bar doesn't latch
996 // invisible characters that have to be deleted before
997 // real ones. Skip during composition (IME) since blur
998 // would cancel the in-progress glyph.
999 let input_pending = pending.clone();
1000 let input_window = window.clone();
1001 let input_el_for_input = input.clone();
1002 let input_closure: Closure<dyn FnMut(web_sys::InputEvent)> =
1003 Closure::new(move |event: web_sys::InputEvent| {
1004 let composing = event.is_composing();
1005 let input_type = event.input_type();
1006 let edit = match input_type.as_str() {
1007 "deleteContentBackward"
1008 | "deleteWordBackward"
1009 | "deleteSoftLineBackward"
1010 | "deleteHardLineBackward" => Some(TextEdit::Key(
1011 LogicalKey::Named(NamedKey::Backspace),
1012 PhysicalKey::Unidentified,
1013 )),
1014 _ => {
1015 let value = input_el_for_input.value();
1016 if value.is_empty() || composing {
1017 None
1018 } else {
1019 Some(TextEdit::Insert(value))
1020 }
1021 }
1022 };
1023 if !composing {
1024 input_el_for_input.set_value("");
1025 // Gboard reset.
1026 let _ = input_el_for_input.blur();
1027 let _ = input_el_for_input.focus();
1028 }
1029 if let Some(edit) = edit {
1030 input_pending.borrow_mut().push_back(edit);
1031 input_window.request_redraw();
1032 }
1033 });
1034 input
1035 .add_event_listener_with_callback("input", input_closure.as_ref().unchecked_ref())
1036 .ok()?;
1037
1038 // keydown: when our hidden input has focus, the canvas
1039 // never sees keystrokes — so we have to forward editing
1040 // keys (Backspace, Enter, arrows) through here. The
1041 // `input` handler above also covers Backspace via
1042 // inputType for the typical Android case; this catches
1043 // the iPad-with-hardware-keyboard variant where
1044 // Backspace fires as `keydown` only.
1045 let keydown_pending = pending.clone();
1046 let keydown_window = window.clone();
1047 let keydown_closure: Closure<dyn FnMut(web_sys::KeyboardEvent)> =
1048 Closure::new(move |event: web_sys::KeyboardEvent| {
1049 // Enter never lands in the `input` handler: the
1050 // hidden element is type="text", so the value
1051 // stays empty and only this keydown fires —
1052 // no double dispatch.
1053 let logical = match event.key().as_str() {
1054 "Backspace" => Some(NamedKey::Backspace),
1055 "Delete" => Some(NamedKey::Delete),
1056 "Enter" => Some(NamedKey::Enter),
1057 "ArrowUp" => Some(NamedKey::ArrowUp),
1058 "ArrowDown" => Some(NamedKey::ArrowDown),
1059 "ArrowLeft" => Some(NamedKey::ArrowLeft),
1060 "ArrowRight" => Some(NamedKey::ArrowRight),
1061 "Home" => Some(NamedKey::Home),
1062 "End" => Some(NamedKey::End),
1063 _ => None,
1064 };
1065 if let Some(named) = logical {
1066 let physical = dom_physical(&event.code());
1067 keydown_pending
1068 .borrow_mut()
1069 .push_back(TextEdit::Key(LogicalKey::Named(named), physical));
1070 keydown_window.request_redraw();
1071 event.prevent_default();
1072 }
1073 });
1074 input
1075 .add_event_listener_with_callback(
1076 "keydown",
1077 keydown_closure.as_ref().unchecked_ref(),
1078 )
1079 .ok()?;
1080
1081 // blur: keep our `focused` mirror in sync when the
1082 // input loses focus outside our control (user dismissed
1083 // the keyboard via the OS dismiss button, tab key,
1084 // etc.). Without this, `focus_if_needed` would no-op
1085 // on the next text-input tap.
1086 let focused: Rc<Cell<bool>> = Rc::new(Cell::new(false));
1087 let blur_focused = focused.clone();
1088 let blur_closure: Closure<dyn FnMut(web_sys::Event)> =
1089 Closure::new(move |_event: web_sys::Event| {
1090 blur_focused.set(false);
1091 });
1092 input
1093 .add_event_listener_with_callback("blur", blur_closure.as_ref().unchecked_ref())
1094 .ok()?;
1095
1096 Some(Self {
1097 input,
1098 focused,
1099 pending,
1100 input_closure,
1101 keydown_closure,
1102 blur_closure,
1103 })
1104 }
1105
1106 /// Undo [`Self::install`]: unregister the three listeners and
1107 /// remove the hidden input from the document. Part of the
1108 /// host teardown driven by [`WebHandle::destroy`] — without
1109 /// it every SPA remount appends another input to `<body>`.
1110 fn uninstall(&self) {
1111 let _ = self.input.remove_event_listener_with_callback(
1112 "input",
1113 self.input_closure.as_ref().unchecked_ref(),
1114 );
1115 let _ = self.input.remove_event_listener_with_callback(
1116 "keydown",
1117 self.keydown_closure.as_ref().unchecked_ref(),
1118 );
1119 let _ = self.input.remove_event_listener_with_callback(
1120 "blur",
1121 self.blur_closure.as_ref().unchecked_ref(),
1122 );
1123 self.input.remove();
1124 }
1125
1126 /// Focus the input so the soft keyboard opens. **Must be
1127 /// called inside a user-gesture event handler** (e.g., the
1128 /// pointerdown DOM closure) — iOS suppresses programmatic
1129 /// focus from any other context. No-op if we believe the
1130 /// input already has focus.
1131 fn focus_if_needed(&self) {
1132 if !self.focused.get() {
1133 let _ = self.input.focus();
1134 self.focused.set(true);
1135 }
1136 }
1137
1138 /// Blur the input so the soft keyboard dismisses. Safe to
1139 /// call from any context. No-op when the input isn't
1140 /// believed to be focused.
1141 fn dismiss(&self) {
1142 if self.focused.get() {
1143 let _ = self.input.blur();
1144 self.focused.set(false);
1145 }
1146 }
1147
1148 /// Drain pending edits captured by the listeners since the
1149 /// last drain. Called by the host inside `window_event`.
1150 fn drain(&self) -> Vec<TextEdit> {
1151 self.pending.borrow_mut().drain(..).collect()
1152 }
1153 }
1154
1155 /// Mirrors the native winit + wgpu host shape, but with browser
1156 /// surface init (async via wasm-bindgen-futures rather than
1157 /// pollster). Kept inline here so `damascene-winit-wgpu` stays free of
1158 /// wasm-only deps.
1159 struct Host<A: App> {
1160 config: WebHostConfig,
1161 app: A,
1162 handle: WebHandle,
1163 gfx: Rc<RefCell<Option<Gfx>>>,
1164 last_pointer: Option<(f32, f32)>,
1165 modifiers: KeyModifiers,
1166 stats: FrameStats,
1167 /// Last cursor pushed to `Window::set_cursor`. winit-web maps
1168 /// the icon to `canvas.style.cursor` so this drives the
1169 /// browser's CSS cursor; we cache to avoid resetting the same
1170 /// string each frame.
1171 last_cursor: Cursor,
1172 /// Reason the next redraw is being requested. Each event handler
1173 /// that calls `request_redraw` sets this beforehand; the
1174 /// RedrawRequested arm consumes it once and snapshots it into
1175 /// [`HostDiagnostics::trigger`]. Defaults back to `Other` after
1176 /// each consume — safe fallback for redraws the host can't
1177 /// attribute (e.g. the post-async-setup `request_redraw`).
1178 next_trigger: FrameTrigger,
1179 /// Wall clock at the start of the previous redraw; diff with
1180 /// the next frame's start gives `last_frame_dt`.
1181 last_frame_at: Option<Instant>,
1182 /// Counts redraws actually rendered.
1183 frame_index: u64,
1184 /// Timing breakdown from the last completed rendered frame.
1185 last_build: Duration,
1186 last_prepare: Duration,
1187 last_layout: Duration,
1188 last_layout_intrinsic_cache_hits: u64,
1189 last_layout_intrinsic_cache_misses: u64,
1190 last_layout_pruned_subtrees: u64,
1191 last_layout_pruned_nodes: u64,
1192 last_draw_ops: Duration,
1193 last_draw_ops_culled_text_ops: u64,
1194 last_paint: Duration,
1195 last_paint_culled_ops: u64,
1196 last_gpu_upload: Duration,
1197 last_snapshot: Duration,
1198 last_submit: Duration,
1199 last_text_layout_cache_hits: u64,
1200 last_text_layout_cache_misses: u64,
1201 last_text_layout_cache_evictions: u64,
1202 last_text_layout_shaped_bytes: u64,
1203 /// Physical canvas size used by the most recent full
1204 /// [`Runner::prepare`] call. The repaint dispatcher requires
1205 /// this to match the current `gfx.config` size before taking
1206 /// the paint-only path: the cached `DrawOp` list was laid out
1207 /// against this size, so a `ResizeObserver` fire that updated
1208 /// `gfx.config` since must force a fresh layout rather than
1209 /// painting stale geometry to the new viewport.
1210 last_prepared_size: Option<(u32, u32)>,
1211 /// Adapter backend tag, captured at adapter selection time.
1212 /// `Rc<RefCell>` because the surface is created in an async
1213 /// task that finishes after `Host::new`; the cell is read
1214 /// each frame in the RedrawRequested arm.
1215 backend: Rc<RefCell<&'static str>>,
1216 /// Browser `paste` events carry trusted clipboard text without
1217 /// the Firefox permission menu used by `navigator.clipboard.readText`.
1218 /// The callback enqueues text here, then requests a redraw; the
1219 /// RedrawRequested arm converts it into a focused Damascene `TextInput`.
1220 pending_clipboard_text: Rc<RefCell<VecDeque<String>>>,
1221 /// Web browsers do not expose the X11/Wayland primary-selection
1222 /// clipboard. Keep an app-local approximation so Damascene selection
1223 /// highlight can still feed middle-click paste inside the canvas.
1224 primary_selection: String,
1225 /// Diagnostics snapshot from the last built frame, retained so
1226 /// event dispatch can attach it to [`damascene_core::EventCx`].
1227 last_diagnostics: Option<damascene_core::HostDiagnostics>,
1228 /// The canvas the host bound to, stored at `resumed()` so
1229 /// [`Self::teardown`] can unregister listeners even after the
1230 /// embedding page detached the element from the document.
1231 canvas: Option<web_sys::HtmlCanvasElement>,
1232 /// The JS paste callback object; held alive for the host's
1233 /// lifetime and unregistered in [`Self::teardown`].
1234 paste_closure: Option<Closure<dyn FnMut(web_sys::ClipboardEvent)>>,
1235 /// The JS keydown callback object; held alive for the host's
1236 /// lifetime and unregistered in [`Self::teardown`].
1237 keydown_closure: Option<Closure<dyn FnMut(web_sys::KeyboardEvent)>>,
1238 /// The JS callback object that ResizeObserver fires; held
1239 /// alive for the host's lifetime. Dropping it alone would NOT
1240 /// stop the observer — [`Self::teardown`] calls
1241 /// `disconnect()` on the observer first.
1242 resize_closure: Option<Closure<dyn FnMut()>>,
1243 /// The observer itself; disconnected in [`Self::teardown`].
1244 resize_observer: Option<web_sys::ResizeObserver>,
1245 /// DOM pointer events captured by the listeners installed in
1246 /// `resumed()`. Drained at the top of every `window_event`
1247 /// call so dispatch into the runner and app uses the same
1248 /// `&mut self` path the rest of the host does.
1249 pending_pointer: Rc<RefCell<VecDeque<QueuedPointer>>>,
1250 /// The JS callbacks for each of pointermove / pointerdown /
1251 /// pointerup / pointercancel / pointerleave on the canvas,
1252 /// paired with their event names so [`Self::teardown`] can
1253 /// unregister them.
1254 pointer_closures: PointerListeners,
1255 /// The JS callback that calls `preventDefault` on
1256 /// `contextmenu` so the browser's native menu doesn't pop
1257 /// over the canvas. Right-click already emits
1258 /// `PointerButton::Secondary` through the pointer listeners;
1259 /// this just suppresses the platform menu so apps can render
1260 /// their own. Unregistered in [`Self::teardown`].
1261 contextmenu_closure: Option<Closure<dyn FnMut(web_sys::MouseEvent)>>,
1262 /// Bottom safe-area inset in logical pixels, set by the
1263 /// VisualViewport `resize` listener whenever the keyboard
1264 /// (or any other platform chrome that shrinks the visual
1265 /// viewport) appears or disappears. The cell is shared with
1266 /// the JS callback via `Rc<Cell<f32>>`; the host reads it
1267 /// each frame and feeds it into `BuildCx::with_safe_area`.
1268 keyboard_inset_bottom: Rc<Cell<f32>>,
1269 /// The JS callback that updates `keyboard_inset_bottom` on
1270 /// visualViewport resize. None on browsers that don't expose
1271 /// `window.visualViewport` (older engines / jsdom-style test
1272 /// contexts). Unregistered in [`Self::teardown`].
1273 viewport_closure: Option<Closure<dyn FnMut(web_sys::Event)>>,
1274 /// Hidden `<input type="text">` that summons the on-screen
1275 /// keyboard when a touch press lands on an Damascene text-input
1276 /// widget. `None` when soft-keyboard install failed (no body,
1277 /// etc.) — the host still runs, just without
1278 /// on-screen-keyboard support. Shared with the pointerdown
1279 /// closure via `Rc` clone so focus-on-press can fire in the
1280 /// user-gesture context.
1281 soft_keyboard: Option<Rc<SoftKeyboard>>,
1282 }
1283
1284 struct Gfx {
1285 window: Arc<Window>,
1286 surface: wgpu::Surface<'static>,
1287 device: wgpu::Device,
1288 queue: wgpu::Queue,
1289 config: wgpu::SurfaceConfiguration,
1290 renderer: Runner,
1291 /// `None` when [`SAMPLE_COUNT`] is 1 — the renderer draws
1292 /// straight into the swapchain texture and there's no resolve
1293 /// pass. `Some` when MSAA is enabled, holding the
1294 /// multisampled colour attachment that the swapchain texture
1295 /// is the resolve target for.
1296 msaa: Option<damascene_wgpu::MsaaTarget>,
1297 /// Format used for render-target views and pipelines. May
1298 /// differ from `config.format` when we re-view a linear
1299 /// swapchain texture as sRGB (Chromium WebGPU path) — the
1300 /// swapchain stores `Rgba8Unorm`, but every view is
1301 /// `Rgba8UnormSrgb` so the hardware encodes on write.
1302 render_format: wgpu::TextureFormat,
1303 }
1304
1305 /// Logical-pixel viewport currently configured on the canvas — the
1306 /// value the next `build` sees, so event-time layout math agrees
1307 /// with build-time. `None` only when the scale factor is degenerate.
1308 fn logical_viewport_of(gfx: &Gfx) -> Option<(f32, f32)> {
1309 let scale = gfx.window.scale_factor() as f32;
1310 if scale <= 0.0 {
1311 return None;
1312 }
1313 Some((
1314 gfx.config.width as f32 / scale,
1315 gfx.config.height as f32 / scale,
1316 ))
1317 }
1318
1319 fn surface_extent(config: &wgpu::SurfaceConfiguration) -> wgpu::Extent3d {
1320 wgpu::Extent3d {
1321 width: config.width,
1322 height: config.height,
1323 depth_or_array_layers: 1,
1324 }
1325 }
1326
1327 impl<A: App> Host<A> {
1328 fn new(config: WebHostConfig, app: A, handle: WebHandle) -> Self {
1329 Self {
1330 config,
1331 app,
1332 handle,
1333 gfx: Rc::new(RefCell::new(None)),
1334 last_pointer: None,
1335 modifiers: KeyModifiers::default(),
1336 stats: FrameStats::default(),
1337 last_cursor: Cursor::Default,
1338 next_trigger: FrameTrigger::Initial,
1339 last_frame_at: None,
1340 frame_index: 0,
1341 last_build: Duration::ZERO,
1342 last_prepare: Duration::ZERO,
1343 last_layout: Duration::ZERO,
1344 last_layout_intrinsic_cache_hits: 0,
1345 last_layout_intrinsic_cache_misses: 0,
1346 last_layout_pruned_subtrees: 0,
1347 last_layout_pruned_nodes: 0,
1348 last_draw_ops: Duration::ZERO,
1349 last_draw_ops_culled_text_ops: 0,
1350 last_paint: Duration::ZERO,
1351 last_paint_culled_ops: 0,
1352 last_gpu_upload: Duration::ZERO,
1353 last_snapshot: Duration::ZERO,
1354 last_submit: Duration::ZERO,
1355 last_text_layout_cache_hits: 0,
1356 last_text_layout_cache_misses: 0,
1357 last_text_layout_cache_evictions: 0,
1358 last_text_layout_shaped_bytes: 0,
1359 last_prepared_size: None,
1360 backend: Rc::new(RefCell::new("?")),
1361 pending_clipboard_text: Rc::new(RefCell::new(VecDeque::new())),
1362 primary_selection: String::new(),
1363 last_diagnostics: None,
1364 canvas: None,
1365 paste_closure: None,
1366 keydown_closure: None,
1367 resize_closure: None,
1368 resize_observer: None,
1369 pending_pointer: Rc::new(RefCell::new(VecDeque::new())),
1370 pointer_closures: Vec::new(),
1371 contextmenu_closure: None,
1372 keyboard_inset_bottom: Rc::new(Cell::new(0.0)),
1373 viewport_closure: None,
1374 soft_keyboard: None,
1375 }
1376 }
1377
1378 /// Undo everything `resumed()` installed: unregister the DOM
1379 /// listeners, disconnect the ResizeObserver, uninstall the
1380 /// soft-keyboard input, release the GPU surface, and drop the
1381 /// handle's window reference so the winit `Window` (which
1382 /// owns winit's own canvas listeners) can die.
1383 ///
1384 /// Runs from [`ApplicationHandler::exiting`] when
1385 /// [`WebHandle::destroy`] stops the loop. Idempotent: every
1386 /// step `take()`s its state, so a second call is a no-op.
1387 /// Unregistration must happen explicitly — dropping a
1388 /// `Closure` only invalidates the JS function; the DOM
1389 /// registration would survive and throw on its next fire.
1390 fn teardown(&mut self) {
1391 // Disconnect before dropping the closure: a disconnected
1392 // observer can never fire into the dead closure.
1393 if let Some(observer) = self.resize_observer.take() {
1394 observer.disconnect();
1395 }
1396 self.resize_closure = None;
1397
1398 if let Some(canvas) = self.canvas.take() {
1399 for (event, closure) in self.pointer_closures.drain(..) {
1400 let _ = canvas.remove_event_listener_with_callback(
1401 event,
1402 closure.as_ref().unchecked_ref(),
1403 );
1404 }
1405 if let Some(closure) = self.keydown_closure.take() {
1406 let _ = canvas.remove_event_listener_with_callback(
1407 "keydown",
1408 closure.as_ref().unchecked_ref(),
1409 );
1410 }
1411 if let Some(closure) = self.contextmenu_closure.take() {
1412 let _ = canvas.remove_event_listener_with_callback(
1413 "contextmenu",
1414 closure.as_ref().unchecked_ref(),
1415 );
1416 }
1417 // The paste listener was registered on the document,
1418 // not the canvas.
1419 if let Some(closure) = self.paste_closure.take()
1420 && let Some(document) = canvas.owner_document()
1421 {
1422 let _ = document.remove_event_listener_with_callback(
1423 "paste",
1424 closure.as_ref().unchecked_ref(),
1425 );
1426 }
1427 }
1428
1429 if let Some(closure) = self.viewport_closure.take()
1430 && let Some(window_obj) = web_sys::window()
1431 && let Some(vv) = window_obj.visual_viewport()
1432 {
1433 let _ = vv.remove_event_listener_with_callback(
1434 "resize",
1435 closure.as_ref().unchecked_ref(),
1436 );
1437 }
1438
1439 if let Some(soft_keyboard) = self.soft_keyboard.take() {
1440 soft_keyboard.uninstall();
1441 }
1442
1443 // Release the surface / device / queue. The async setup
1444 // task may still be in flight; if it completes after this
1445 // it writes a fresh Gfx into the cell of an exited loop —
1446 // harmless, and freed when the task's Rc clones drop.
1447 self.gfx.borrow_mut().take();
1448 // Let the winit Window die even while the embedder still
1449 // holds WebHandle clones.
1450 self.handle.clear_window();
1451 }
1452
1453 /// Drain DOM PointerEvents captured by the listeners since the
1454 /// last `window_event` call and dispatch them through the
1455 /// runner + app the same way native winit pointer events do.
1456 ///
1457 /// Returns `true` when at least one event triggered a redraw
1458 /// — the host uses this to set `next_trigger` for the next
1459 /// frame's diagnostics.
1460 fn drain_pending_pointer(&mut self, gfx: &mut Gfx) -> bool {
1461 // Drain time-driven events (touch long-press) before any
1462 // queued DOM input. Even on frames where no DOM event
1463 // arrived (the user held still through the long-press
1464 // deadline), this still needs to fire — `next_redraw_in`
1465 // schedules the wakeup that brings us here.
1466 let mut redraw = false;
1467 let polled = gfx.renderer.poll_input(Instant::now());
1468 if !polled.is_empty() {
1469 redraw = true;
1470 for event in polled {
1471 dispatch_app_event(
1472 &mut self.app,
1473 event,
1474 &gfx.renderer,
1475 logical_viewport_of(gfx),
1476 self.last_diagnostics.as_ref(),
1477 &mut self.primary_selection,
1478 );
1479 }
1480 }
1481 let queue: Vec<QueuedPointer> = self.pending_pointer.borrow_mut().drain(..).collect();
1482 if queue.is_empty() {
1483 return redraw;
1484 }
1485 for queued in queue {
1486 match queued {
1487 QueuedPointer::Move(p) => {
1488 self.last_pointer = Some((p.x, p.y));
1489 let moved = gfx.renderer.pointer_moved(p);
1490 for event in moved.events {
1491 dispatch_app_event(
1492 &mut self.app,
1493 event,
1494 &gfx.renderer,
1495 logical_viewport_of(gfx),
1496 self.last_diagnostics.as_ref(),
1497 &mut self.primary_selection,
1498 );
1499 }
1500 if moved.needs_redraw {
1501 redraw = true;
1502 }
1503 }
1504 QueuedPointer::Down(p) => {
1505 self.last_pointer = Some((p.x, p.y));
1506 for event in gfx.renderer.pointer_down(p) {
1507 dispatch_app_event(
1508 &mut self.app,
1509 event,
1510 &gfx.renderer,
1511 logical_viewport_of(gfx),
1512 self.last_diagnostics.as_ref(),
1513 &mut self.primary_selection,
1514 );
1515 }
1516 redraw = true;
1517 }
1518 QueuedPointer::Up(p) => {
1519 self.last_pointer = Some((p.x, p.y));
1520 for event in gfx.renderer.pointer_up(p) {
1521 let event =
1522 attach_primary_selection_text(event, &self.primary_selection);
1523 dispatch_app_event(
1524 &mut self.app,
1525 event,
1526 &gfx.renderer,
1527 logical_viewport_of(gfx),
1528 self.last_diagnostics.as_ref(),
1529 &mut self.primary_selection,
1530 );
1531 }
1532 redraw = true;
1533 }
1534 QueuedPointer::Cancel => {
1535 // A real cancel, not an up: abandons in-flight
1536 // gesture captures without applying release
1537 // effects, and regardless of which button began
1538 // them (a synthesized Primary up couldn't end a
1539 // Secondary/Middle-initiated drag).
1540 self.last_pointer = None;
1541 for event in gfx.renderer.pointer_cancelled() {
1542 dispatch_app_event(
1543 &mut self.app,
1544 event,
1545 &gfx.renderer,
1546 logical_viewport_of(gfx),
1547 self.last_diagnostics.as_ref(),
1548 &mut self.primary_selection,
1549 );
1550 }
1551 redraw = true;
1552 }
1553 QueuedPointer::Leave => {
1554 self.last_pointer = None;
1555 for event in gfx.renderer.pointer_left() {
1556 dispatch_app_event(
1557 &mut self.app,
1558 event,
1559 &gfx.renderer,
1560 logical_viewport_of(gfx),
1561 self.last_diagnostics.as_ref(),
1562 &mut self.primary_selection,
1563 );
1564 }
1565 redraw = true;
1566 }
1567 }
1568 }
1569 redraw
1570 }
1571
1572 /// Drain edits captured by the soft-keyboard input since
1573 /// the last `window_event` and route them through the
1574 /// runner's existing keyboard / text-input entry points so
1575 /// the focused widget sees the same shape it would for a
1576 /// hardware keystroke. Returns `true` when at least one edit
1577 /// was dispatched so the caller can mark the next-frame
1578 /// trigger.
1579 fn drain_soft_keyboard(&mut self, gfx: &mut Gfx) -> bool {
1580 let Some(sk) = self.soft_keyboard.as_ref() else {
1581 return false;
1582 };
1583 let edits = sk.drain();
1584 if edits.is_empty() {
1585 return false;
1586 }
1587 for edit in edits {
1588 match edit {
1589 TextEdit::Insert(text) => {
1590 if let Some(event) = gfx.renderer.text_input(text) {
1591 dispatch_app_event(
1592 &mut self.app,
1593 event,
1594 &gfx.renderer,
1595 logical_viewport_of(gfx),
1596 self.last_diagnostics.as_ref(),
1597 &mut self.primary_selection,
1598 );
1599 }
1600 }
1601 TextEdit::Key(logical, physical) => {
1602 for event in gfx
1603 .renderer
1604 .key_down(logical, physical, self.modifiers, false)
1605 {
1606 dispatch_app_event(
1607 &mut self.app,
1608 event,
1609 &gfx.renderer,
1610 logical_viewport_of(gfx),
1611 self.last_diagnostics.as_ref(),
1612 &mut self.primary_selection,
1613 );
1614 }
1615 }
1616 }
1617 }
1618 true
1619 }
1620
1621 /// Sync the soft keyboard's open/closed state with the
1622 /// runner's current focus. Called once per `window_event`
1623 /// after pointer / soft-keyboard drain so a press that
1624 /// shifted focus away from a text input can dismiss the
1625 /// on-screen keyboard within the same frame.
1626 ///
1627 /// We never *open* the keyboard from here — that has to
1628 /// happen synchronously inside the pointerdown closure for
1629 /// iOS to honor it. Closing has no such restriction.
1630 fn sync_soft_keyboard_focus(&self, gfx: &Gfx) {
1631 let Some(sk) = self.soft_keyboard.as_ref() else {
1632 return;
1633 };
1634 // Only dismiss when our state says the keyboard should
1635 // be down AND the DOM input still believes it's focused.
1636 // Skipping the .blur() when DOM focus is already gone
1637 // avoids redundant blur events; more importantly, it
1638 // means a stray sync that races a still-resolving focus
1639 // doesn't tear the keyboard down out from under itself.
1640 if !gfx.renderer.focused_captures_keys() && sk.focused.get() {
1641 sk.dismiss();
1642 }
1643 }
1644 }
1645
1646 fn backend_label(backend: wgpu::Backend) -> &'static str {
1647 match backend {
1648 wgpu::Backend::Vulkan => "Vulkan",
1649 wgpu::Backend::Metal => "Metal",
1650 wgpu::Backend::Dx12 => "DX12",
1651 wgpu::Backend::Gl => "WebGL2",
1652 wgpu::Backend::BrowserWebGpu => "WebGPU",
1653 wgpu::Backend::Noop => "noop",
1654 }
1655 }
1656
1657 /// sRGB-tagged view-format sibling for a linear `*8Unorm` swapchain
1658 /// format. Used to recover gamma-correct output on Chromium's WebGPU
1659 /// surface: the swapchain offers only linear formats there, so we
1660 /// declare the sRGB form as a view format and render through that —
1661 /// hardware applies the sRGB encode on store and the compositor
1662 /// reads gamma-correct pixels. Returns `None` for formats that have
1663 /// no sRGB sibling (e.g. `Rgba16Float`, where the float storage is
1664 /// already linear-precision-correct), in which case the caller
1665 /// keeps the chosen format unchanged.
1666 fn srgb_view_of(format: wgpu::TextureFormat) -> Option<wgpu::TextureFormat> {
1667 use wgpu::TextureFormat as F;
1668 match format {
1669 F::Rgba8Unorm => Some(F::Rgba8UnormSrgb),
1670 F::Bgra8Unorm => Some(F::Bgra8UnormSrgb),
1671 _ => None,
1672 }
1673 }
1674
1675 impl<A: App + 'static> ApplicationHandler for Host<A> {
1676 fn resumed(&mut self, event_loop: &ActiveEventLoop) {
1677 // Destroyed before the loop's first turn: exit without
1678 // installing anything, so there is nothing to tear down.
1679 if self.handle.destroy_requested() {
1680 event_loop.exit();
1681 return;
1682 }
1683 if self.gfx.borrow().is_some() {
1684 return;
1685 }
1686 let canvas = locate_canvas(&self.config.canvas_id);
1687 self.canvas = Some(canvas.clone());
1688
1689 // Build the window bound to the existing canvas. We do
1690 // *not* call `with_inner_size` — on the web backend that
1691 // forces canvas.width/height to the requested physical
1692 // pixels, which then disagrees with the surface size if
1693 // we read it from CSS. Letting winit pick from the canvas
1694 // attributes (default 300×150 if unset, otherwise whatever
1695 // the host page declared) keeps inner_size() and the
1696 // canvas backing buffer in lockstep. The ResizeObserver
1697 // installed below carries the canvas through later layout
1698 // changes; we don't depend on winit dispatching `Resized`.
1699 let attrs = Window::default_attributes()
1700 .with_canvas(Some(canvas.clone()))
1701 // Browser paste, including Linux middle-click primary
1702 // paste, is delivered as a DOM ClipboardEvent. winit's
1703 // default web preventDefault path suppresses those
1704 // browser-side events, so Damascene handles clipboard
1705 // suppression at the document paste listener instead.
1706 .with_prevent_default(false);
1707 let window = Arc::new(event_loop.create_window(attrs).expect("create window"));
1708 self.handle.set_window(window.clone());
1709
1710 // Force the canvas backing buffer to match the canvas's
1711 // CSS-laid-out size at the device pixel ratio. Without
1712 // this the canvas defaults to 300×150 device pixels, the
1713 // swapchain ends up tiny and stretched, and Firefox's
1714 // WebGPU backend fails the first present with "not enough
1715 // memory left" because the surface texture and the canvas
1716 // drawing buffer disagree. winit's `Window::inner_size()`
1717 // reads canvas.width/canvas.height on the web backend, so
1718 // setting them here is what the async surface setup picks
1719 // up for the initial swap-chain dimensions.
1720 let viewport = self.config.viewport;
1721 let (initial_w, initial_h) = measure_canvas(&canvas, viewport);
1722 canvas.set_width(initial_w);
1723 canvas.set_height(initial_h);
1724
1725 // Keep the canvas backing buffer tracking its CSS box
1726 // size for the lifetime of the page. ResizeObserver fires
1727 // once on observe() with the initial size, then again
1728 // every time the canvas's content rect changes. We bypass
1729 // winit's `request_inner_size` round-trip — its web
1730 // backend doesn't reliably translate that into a
1731 // `Resized` event, which left the swapchain stretched
1732 // mid-session — and reconfigure the surface directly via
1733 // `apply_canvas_size`. Until the async surface setup
1734 // completes we just keep canvas.width/height in sync so
1735 // the eventual `inner_size()` read picks up the latest.
1736 let canvas_for_observer = canvas.clone();
1737 let window_for_observer = window.clone();
1738 let gfx_for_observer = self.gfx.clone();
1739 let resize_closure: Closure<dyn FnMut()> = Closure::new(move || {
1740 let (phys_w, phys_h) = measure_canvas(&canvas_for_observer, viewport);
1741 let mut gfx_borrow = gfx_for_observer.borrow_mut();
1742 if let Some(gfx) = gfx_borrow.as_mut() {
1743 apply_canvas_size(&canvas_for_observer, gfx, phys_w, phys_h);
1744 } else {
1745 canvas_for_observer.set_width(phys_w);
1746 canvas_for_observer.set_height(phys_h);
1747 }
1748 drop(gfx_borrow);
1749 window_for_observer.request_redraw();
1750 });
1751 let observer = web_sys::ResizeObserver::new(resize_closure.as_ref().unchecked_ref())
1752 .expect("ResizeObserver::new failed");
1753 observer.observe(&canvas);
1754 self.resize_closure = Some(resize_closure);
1755 self.resize_observer = Some(observer);
1756
1757 let pending_clipboard_text = self.pending_clipboard_text.clone();
1758 let window_for_paste = window.clone();
1759 let paste_closure: Closure<dyn FnMut(web_sys::ClipboardEvent)> =
1760 Closure::new(move |event: web_sys::ClipboardEvent| {
1761 let Some(data) = event.clipboard_data() else {
1762 log::warn!("damascene-web: paste event had no clipboardData");
1763 return;
1764 };
1765 let Ok(text) = data.get_data("text/plain") else {
1766 log::warn!("damascene-web: paste event could not read text/plain");
1767 return;
1768 };
1769 if text.is_empty() {
1770 return;
1771 }
1772 event.prevent_default();
1773 event.stop_propagation();
1774 pending_clipboard_text.borrow_mut().push_back(text);
1775 window_for_paste.request_redraw();
1776 });
1777 canvas
1778 .owner_document()
1779 .expect("canvas has no owner document")
1780 .add_event_listener_with_callback("paste", paste_closure.as_ref().unchecked_ref())
1781 .expect("add paste listener");
1782 self.paste_closure = Some(paste_closure);
1783
1784 let keydown_closure: Closure<dyn FnMut(web_sys::KeyboardEvent)> =
1785 Closure::new(move |event: web_sys::KeyboardEvent| {
1786 if should_prevent_browser_key_default(&event) {
1787 event.prevent_default();
1788 }
1789 });
1790 canvas
1791 .add_event_listener_with_callback(
1792 "keydown",
1793 keydown_closure.as_ref().unchecked_ref(),
1794 )
1795 .expect("add keydown listener");
1796 self.keydown_closure = Some(keydown_closure);
1797
1798 // Tell the browser the canvas owns all touch input —
1799 // without this, `touch-action: auto` (the default) makes
1800 // touch-drag pan/zoom the page before any PointerEvent
1801 // ever fires, so the runtime sees nothing. Setting it on
1802 // the element matches what touch-first canvas apps
1803 // (drawing tools, games) ship.
1804 if let Some(style) = canvas.dyn_ref::<web_sys::HtmlElement>().map(|e| e.style()) {
1805 let _ = style.set_property("touch-action", "none");
1806 }
1807
1808 // Soft-keyboard plumbing. Install before the pointer
1809 // listeners so the pointerdown closure can call into it
1810 // synchronously from the user-gesture context. Failure
1811 // to install (no body, etc.) leaves the host running
1812 // without on-screen-keyboard support, which is the
1813 // correct degradation for environments where it can't
1814 // work.
1815 self.soft_keyboard = SoftKeyboard::install(&canvas, &window).map(Rc::new);
1816 if self.soft_keyboard.is_none() {
1817 log::warn!(
1818 "damascene-web: soft keyboard install failed; text input will not summon \
1819 the on-screen keyboard"
1820 );
1821 }
1822
1823 // Bind DOM PointerEvent directly. winit on the browser
1824 // collapses touch and pen to mouse before forwarding, so
1825 // routing through `WindowEvent::MouseInput` would lose
1826 // the modality, the per-pointer ID, and pressure — the
1827 // exact information the runtime needs to specialize for
1828 // touch. Each listener pushes onto `pending_pointer` and
1829 // requests a redraw; the next `window_event` call drains
1830 // the queue and dispatches into the runner + app with
1831 // full host state. The compatibility mouse events winit
1832 // would otherwise translate are ignored further down by
1833 // this file deliberately not handling
1834 // `WindowEvent::MouseInput` / `CursorMoved` /
1835 // `CursorLeft` on web.
1836 install_pointer_listeners(
1837 &canvas,
1838 &window,
1839 &self.pending_pointer,
1840 &self.gfx,
1841 self.soft_keyboard.as_ref(),
1842 &mut self.pointer_closures,
1843 );
1844
1845 // Suppress the browser's native context menu on the
1846 // canvas. Right-click already routes to the runtime as
1847 // `PointerButton::Secondary` via the pointerdown listener
1848 // above; without this the platform menu pops on top of
1849 // the app and intercepts subsequent input. Apps that want
1850 // an Damascene-rendered menu wire it through the Secondary
1851 // press path as they would on native.
1852 let contextmenu_closure: Closure<dyn FnMut(web_sys::MouseEvent)> =
1853 Closure::new(move |event: web_sys::MouseEvent| {
1854 event.prevent_default();
1855 });
1856 canvas
1857 .add_event_listener_with_callback(
1858 "contextmenu",
1859 contextmenu_closure.as_ref().unchecked_ref(),
1860 )
1861 .expect("add contextmenu listener");
1862 self.contextmenu_closure = Some(contextmenu_closure);
1863
1864 // VisualViewport reports the visible region of the page
1865 // minus platform chrome. When the on-screen keyboard
1866 // appears, `visualViewport.height` shrinks while
1867 // `window.innerHeight` (the layout viewport) doesn't —
1868 // the difference is the keyboard inset, which apps read
1869 // through `BuildCx::safe_area_bottom` and use to inset
1870 // their interactive content. Skip silently on browsers
1871 // without VisualViewport (older engines, jsdom).
1872 if let Some(window_obj) = web_sys::window()
1873 && let Some(vv) = window_obj.visual_viewport()
1874 {
1875 let cell = self.keyboard_inset_bottom.clone();
1876 let layout_window = window_obj.clone();
1877 // Seed the cell with the current value so the first
1878 // frame after install has the right inset (handles
1879 // the case of resuming a tab where the keyboard is
1880 // already up). Clamp small differences (URL-bar
1881 // hide/show varies inner_height vs visualViewport by
1882 // ~5px on iOS Safari) so the seed reads as zero.
1883 let initial_inset = ((layout_window
1884 .inner_height()
1885 .ok()
1886 .and_then(|v| v.as_f64())
1887 .unwrap_or(0.0)
1888 - vv.height())
1889 .max(0.0) as f32)
1890 .max(0.0);
1891 let initial_inset = if initial_inset < 16.0 {
1892 0.0
1893 } else {
1894 initial_inset
1895 };
1896 cell.set(initial_inset);
1897 // Note: this listener intentionally does *not* call
1898 // `request_redraw`. The keyboard appearing already
1899 // chains through the focus that summoned it
1900 // (animation deadlines drive the next few frames),
1901 // and inserting an extra redraw here on Android
1902 // raced with the just-summoned soft keyboard's
1903 // focus and dismissed it almost immediately. The
1904 // cell is read by `BuildCx::with_safe_area` each
1905 // frame; whichever frame fires next picks up the
1906 // new value.
1907 let viewport_closure: Closure<dyn FnMut(web_sys::Event)> =
1908 Closure::new(move |_event: web_sys::Event| {
1909 let Some(window_obj) = web_sys::window() else {
1910 return;
1911 };
1912 let Some(vv) = window_obj.visual_viewport() else {
1913 return;
1914 };
1915 let layout_h = window_obj
1916 .inner_height()
1917 .ok()
1918 .and_then(|v| v.as_f64())
1919 .unwrap_or(0.0);
1920 let visible_h = vv.height();
1921 let raw = (layout_h - visible_h).max(0.0) as f32;
1922 // Same small-difference clamp as the seed —
1923 // keeps URL-bar jitter from looking like a
1924 // tiny keyboard.
1925 let inset = if raw < 16.0 { 0.0 } else { raw };
1926 cell.set(inset);
1927 });
1928 vv.add_event_listener_with_callback(
1929 "resize",
1930 viewport_closure.as_ref().unchecked_ref(),
1931 )
1932 .expect("add visualViewport resize listener");
1933 self.viewport_closure = Some(viewport_closure);
1934 }
1935
1936 // Allow both browser backends. wgpu's synchronous
1937 // Instance::new() can't safely decide this: if
1938 // `navigator.gpu` exists, it routes the whole instance
1939 // through WebGPU, even on browsers/GPUs where
1940 // requestAdapter() later returns null. The async helper
1941 // probes adapter creation first and removes WebGPU from the
1942 // descriptor when it is not really usable, letting WebGL2
1943 // handle Chrome/Linux-style partial support instead of
1944 // panicking during adapter selection.
1945 //
1946 // WebGPU is required for backdrop-sampling shaders
1947 // (`liquid_glass`) because WebGL2 surfaces don't advertise
1948 // `COPY_SRC` on the swapchain texture, so the snapshot copy
1949 // can't run — we register backdrop shaders only when the
1950 // chosen adapter's surface supports COPY_SRC, which in
1951 // practice means "WebGPU was selected."
1952 //
1953 // Firefox: as of 2026-05, Firefox's WebGPU implementation
1954 // still wedges its compositor on pointer events with our
1955 // atlas-uploading path (whole canvas goes black until the
1956 // cursor leaves). The workaround on the user side is to
1957 // disable WebGPU in `about:config` (`dom.webgpu.enabled =
1958 // false`); wgpu then transparently picks WebGL2 here and
1959 // backdrop shaders are skipped via the COPY_SRC check
1960 // below. Revisit when Firefox WebGPU stabilises.
1961 // Adapter + device requests are async on wasm; spawn the
1962 // setup as a future and stash the result in self.gfx so
1963 // subsequent resumed/window_event calls find it ready.
1964 //
1965 // `App::shaders()` is captured here (before the move into
1966 // the async block) so the runner can register custom
1967 // shaders the App declares — including backdrop-sampling
1968 // ones like `liquid_glass`. Without this the showcase's
1969 // glass card draws are silently dropped because the
1970 // pipeline doesn't exist.
1971 let shaders = self.app.shaders();
1972 let theme = self.app.theme();
1973 let gfx_slot = self.gfx.clone();
1974 let backend_slot = self.backend.clone();
1975 let window_for_async = window.clone();
1976 let handle_for_async = self.handle.clone();
1977 let canvas_for_async = canvas.clone();
1978 wasm_bindgen_futures::spawn_local(async move {
1979 let mut instance_desc = wgpu::InstanceDescriptor::new_without_display_handle();
1980 instance_desc.backends = wgpu::Backends::BROWSER_WEBGPU | wgpu::Backends::GL;
1981 let instance = wgpu::util::new_instance_with_webgpu_detection(instance_desc).await;
1982 // Every failure below is a legitimate platform outcome
1983 // (no WebGPU *and* no WebGL2, GPU process crashed,
1984 // driver denylisted, …), not a bug — report it to the
1985 // page instead of panicking, because this task runs
1986 // after `init()` resolved and a panic here is
1987 // uncatchable from page JS.
1988 let surface = match instance.create_surface(window_for_async.clone()) {
1989 Ok(surface) => surface,
1990 Err(err) => {
1991 report_setup_error(
1992 &canvas_for_async,
1993 &format!("could not create a rendering surface for the canvas: {err}"),
1994 );
1995 return;
1996 }
1997 };
1998
1999 let adapter = match instance
2000 .request_adapter(&wgpu::RequestAdapterOptions {
2001 power_preference: wgpu::PowerPreference::default(),
2002 compatible_surface: Some(&surface),
2003 force_fallback_adapter: false,
2004 apply_limit_buckets: false,
2005 })
2006 .await
2007 {
2008 Ok(adapter) => adapter,
2009 Err(err) => {
2010 report_setup_error(
2011 &canvas_for_async,
2012 &format!(
2013 "no compatible GPU adapter — this browser offers neither usable \
2014 WebGPU nor WebGL2 ({err})"
2015 ),
2016 );
2017 return;
2018 }
2019 };
2020
2021 // Log the adapter we actually got. `Backends::BROWSER_WEBGPU
2022 // | Backends::GL` silently falls back to WebGL2 if the
2023 // browser's WebGPU init fails, and WebGL2 frames cost
2024 // an order of magnitude more GPU time than WebGPU on
2025 // the same scene — so this is the first thing to check
2026 // when investigating "why is it slow on the web".
2027 let info = adapter.get_info();
2028 log::info!(
2029 "damascene-web: adapter selected — backend={:?} name={:?} driver={:?} device_type={:?}",
2030 info.backend,
2031 info.name,
2032 info.driver,
2033 info.device_type,
2034 );
2035 *backend_slot.borrow_mut() = backend_label(info.backend);
2036
2037 // What the runner must adapt to on this adapter — naga's
2038 // GLSL ES target rejects per-sample interpolation and
2039 // depth-texture loads at shader-module creation, so these
2040 // have to be known up front. See `RunnerCaps` for the
2041 // per-cap details (including the SwiftShader caveat that
2042 // makes GL distrusted wholesale).
2043 let caps = RunnerCaps::from_adapter(&adapter);
2044 if !caps.per_sample_shading {
2045 log::info!(
2046 "damascene-web: per-sample shading unavailable on selected backend; \
2047 shaders will downlevel `@interpolate(perspective, sample)` to per-pixel-centre interpolation"
2048 );
2049 }
2050 if !caps.depth_readback {
2051 log::info!(
2052 "damascene-web: depth-attachment read-back unavailable on WebGL2; \
2053 3D scene label occlusion uses the packed depth-as-color capture"
2054 );
2055 }
2056
2057 // WebGL2 has a tighter feature/limit envelope than
2058 // native; downlevel_webgl2_defaults is the matching
2059 // baseline. Cap at the adapter's actual limits so
2060 // device creation succeeds on every integrated GPU.
2061 let limits =
2062 wgpu::Limits::downlevel_webgl2_defaults().using_resolution(adapter.limits());
2063
2064 let (device, queue) = match adapter
2065 .request_device(&wgpu::DeviceDescriptor {
2066 label: Some("damascene_web::device"),
2067 required_features: wgpu::Features::empty(),
2068 required_limits: limits,
2069 experimental_features: wgpu::ExperimentalFeatures::default(),
2070 memory_hints: wgpu::MemoryHints::Performance,
2071 trace: wgpu::Trace::Off,
2072 })
2073 .await
2074 {
2075 Ok(pair) => pair,
2076 Err(err) => {
2077 report_setup_error(
2078 &canvas_for_async,
2079 &format!("GPU device creation failed on the selected adapter: {err}"),
2080 );
2081 return;
2082 }
2083 };
2084
2085 let surface_caps = surface.get_capabilities(&adapter);
2086 let format = surface_caps
2087 .formats
2088 .iter()
2089 .copied()
2090 .find(|f| f.is_srgb())
2091 .unwrap_or(surface_caps.formats[0]);
2092 // Decide the render-target view format. If the chosen
2093 // swapchain format is already sRGB-tagged (native, most
2094 // browsers' WebGL2 surfaces), this collapses to the
2095 // same format. Chromium's WebGPU surface offers only
2096 // linear formats — `Rgba8Unorm`, `Bgra8Unorm`,
2097 // `Rgba16Float` — so without this fix-up our shaders'
2098 // linear writes hit the compositor uncorrected and the
2099 // page renders 2.2-gamma's worth darker than native.
2100 // The trick: keep the swapchain format as `Rgba8Unorm`
2101 // (storage), declare `Rgba8UnormSrgb` as a view format,
2102 // and create every render-target view through that. The
2103 // hardware applies the sRGB encode on store. WebGPU
2104 // explicitly permits this view-format reinterpretation
2105 // because the two formats differ only in the sRGB flag.
2106 let render_format = srgb_view_of(format).unwrap_or(format);
2107 let view_formats = if render_format != format {
2108 vec![render_format]
2109 } else {
2110 Vec::new()
2111 };
2112 log::info!(
2113 "damascene-web: surface format {:?} (sRGB? {}) → render view {:?}; offered {:?}",
2114 format,
2115 format.is_srgb(),
2116 render_format,
2117 surface_caps.formats,
2118 );
2119 // Single source of truth for the swapchain size:
2120 // winit's inner_size() in physical pixels. Same value
2121 // that the native winit + wgpu host uses; matches what
2122 // sync_canvas_to_css() set the canvas backing buffer to.
2123 let inner = window_for_async.inner_size();
2124 // COPY_SRC is required so backdrop-sampling shaders can
2125 // copy the post-Pass-A surface into the runner's
2126 // snapshot texture mid-frame. WebGL2 surfaces typically
2127 // advertise it; if the adapter ever doesn't, we fall
2128 // back to RENDER_ATTACHMENT-only and any backdrop
2129 // shaders the App declared simply won't paint a glass
2130 // surface (the rest of the UI is unaffected).
2131 let want_copy_src = surface_caps.usages.contains(wgpu::TextureUsages::COPY_SRC);
2132 let usage = if want_copy_src {
2133 wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC
2134 } else {
2135 log::warn!(
2136 "damascene-web: surface does not advertise COPY_SRC; backdrop-sampling \
2137 shaders will paint nothing on this backend"
2138 );
2139 wgpu::TextureUsages::RENDER_ATTACHMENT
2140 };
2141 // Prefer Fifo (vsync) so redraws can't outrun the
2142 // browser's compositor — same rationale as
2143 // damascene-winit-wgpu.
2144 let present_mode = if surface_caps
2145 .present_modes
2146 .contains(&wgpu::PresentMode::Fifo)
2147 {
2148 wgpu::PresentMode::Fifo
2149 } else {
2150 surface_caps.present_modes[0]
2151 };
2152 let config = wgpu::SurfaceConfiguration {
2153 usage,
2154 format,
2155 width: inner.width.max(1),
2156 height: inner.height.max(1),
2157 present_mode,
2158 alpha_mode: surface_caps.alpha_modes[0],
2159 view_formats,
2160 // `Auto` keeps the canvas defaults (sRGB, standard tone
2161 // mapping) — the web host negotiates SDR today. HDR
2162 // canvas output needs an explicit `ExtendedSrgb` request
2163 // and is part of the color-negotiation follow-up.
2164 color_space: wgpu::SurfaceColorSpace::Auto,
2165 desired_maximum_frame_latency: 2,
2166 };
2167 surface.configure(&device, &config);
2168
2169 let mut renderer =
2170 Runner::with_caps(&device, &queue, render_format, SAMPLE_COUNT, caps);
2171 renderer.set_theme(theme);
2172 renderer.set_surface_size(config.width, config.height);
2173 // Register every shader the App declared. If the
2174 // surface doesn't support COPY_SRC (so multi-pass
2175 // backdrop sampling is impossible), skip the backdrop
2176 // shaders rather than registering them and rendering
2177 // garbage.
2178 for s in shaders {
2179 if s.samples_backdrop && !want_copy_src {
2180 continue;
2181 }
2182 renderer.register_shader_with(
2183 &device,
2184 s.name,
2185 s.wgsl,
2186 s.samples_backdrop,
2187 s.samples_time,
2188 );
2189 }
2190
2191 // MSAA target only when SAMPLE_COUNT > 1; the
2192 // single-sample path renders straight into the
2193 // swapchain texture.
2194 let msaa = if SAMPLE_COUNT > 1 {
2195 Some(damascene_wgpu::MsaaTarget::new(
2196 &device,
2197 render_format,
2198 surface_extent(&config),
2199 SAMPLE_COUNT,
2200 ))
2201 } else {
2202 None
2203 };
2204 *gfx_slot.borrow_mut() = Some(Gfx {
2205 window: window_for_async.clone(),
2206 surface,
2207 device,
2208 queue,
2209 config,
2210 renderer,
2211 msaa,
2212 render_format,
2213 });
2214 if handle_for_async.mark_ready() {
2215 log::debug!("damascene-web: flushing pending external redraw request");
2216 }
2217 window_for_async.request_redraw();
2218 });
2219 }
2220
2221 fn window_event(
2222 &mut self,
2223 event_loop: &ActiveEventLoop,
2224 _id: WindowId,
2225 event: WindowEvent,
2226 ) {
2227 // [`WebHandle::destroy`] sets the flag and wakes the loop;
2228 // this check must run before the gfx guard below so a
2229 // destroy that races the still-async GPU setup is honored.
2230 // `exit()` fires `LoopExiting` → [`Self::exiting`] runs
2231 // the DOM teardown, then winit drops this Host.
2232 if self.handle.destroy_requested() {
2233 event_loop.exit();
2234 return;
2235 }
2236 // Clone the `Rc` first so the `RefMut` we get from
2237 // `borrow_mut` is tied to the cloned cell rather than
2238 // through `&self.gfx` — that lets `drain_pending_pointer`
2239 // re-borrow `self` mutably while `gfx_borrow` is still
2240 // live.
2241 let gfx_cell = self.gfx.clone();
2242 let mut gfx_borrow = gfx_cell.borrow_mut();
2243 let Some(gfx) = gfx_borrow.as_mut() else {
2244 // Async setup hasn't finished; drop the event. The
2245 // post-setup `request_redraw` will trigger a fresh
2246 // RedrawRequested once we're ready.
2247 return;
2248 };
2249 // Drain DOM PointerEvent listeners before processing the
2250 // winit event. The closures pushed onto
2251 // `pending_pointer` and called `request_redraw`, which
2252 // is what brought us here — handle the captured input
2253 // first so RedrawRequested sees the post-event state.
2254 if self.drain_pending_pointer(gfx) {
2255 self.next_trigger = FrameTrigger::Pointer;
2256 }
2257 // Drain soft-keyboard edits next — order matters because
2258 // a pointer event may have shifted focus to a text
2259 // input, after which keystrokes captured this frame
2260 // should reach the new target.
2261 if self.drain_soft_keyboard(gfx) {
2262 self.next_trigger = FrameTrigger::Keyboard;
2263 }
2264 // If focus moved off a text input this frame, dismiss
2265 // the on-screen keyboard now (done after both drains so
2266 // the focus state reflects everything that just
2267 // happened).
2268 self.sync_soft_keyboard_focus(gfx);
2269 let scale = gfx.window.scale_factor() as f32;
2270
2271 match event {
2272 WindowEvent::CloseRequested => event_loop.exit(),
2273
2274 WindowEvent::Resized(size) => {
2275 gfx.config.width = size.width.max(1);
2276 gfx.config.height = size.height.max(1);
2277 gfx.surface.configure(&gfx.device, &gfx.config);
2278 gfx.renderer
2279 .set_surface_size(gfx.config.width, gfx.config.height);
2280 if let Some(msaa) = gfx.msaa.as_mut() {
2281 let extent = surface_extent(&gfx.config);
2282 if !msaa.matches(extent) {
2283 *msaa = damascene_wgpu::MsaaTarget::new(
2284 &gfx.device,
2285 gfx.render_format,
2286 extent,
2287 SAMPLE_COUNT,
2288 );
2289 }
2290 }
2291 self.next_trigger = FrameTrigger::Resize;
2292 gfx.window.request_redraw();
2293 }
2294
2295 // Pointer input on web flows through DOM PointerEvent
2296 // listeners installed in `resumed()`. winit's
2297 // CursorMoved / CursorLeft / MouseInput on the web
2298 // backend collapse touch and pen to mouse before
2299 // forwarding, so handling them here would either
2300 // double-route (the DOM listener already saw them)
2301 // or strip the modality. They're intentionally
2302 // ignored — the drain at the top of window_event
2303 // dispatches everything the closures captured.
2304
2305 // Browser drag/drop and clipboard-image plumbing rides
2306 // the HTML File API rather than winit (which doesn't
2307 // surface DroppedFile on wasm32). Web hosts that need
2308 // file-drop support listen for `dragenter` / `drop` on
2309 // the canvas via wasm-bindgen and route the resulting
2310 // bytes through their own paths. The winit event arms
2311 // exist for source-parity with the native hosts; on
2312 // web they currently won't fire.
2313 WindowEvent::HoveredFile(path) => {
2314 let (lx, ly) = self.last_pointer.unwrap_or((0.0, 0.0));
2315 for event in gfx.renderer.file_hovered(path, lx, ly) {
2316 dispatch_app_event(
2317 &mut self.app,
2318 event,
2319 &gfx.renderer,
2320 logical_viewport_of(gfx),
2321 self.last_diagnostics.as_ref(),
2322 &mut self.primary_selection,
2323 );
2324 }
2325 self.next_trigger = FrameTrigger::Pointer;
2326 gfx.window.request_redraw();
2327 }
2328
2329 WindowEvent::HoveredFileCancelled => {
2330 for event in gfx.renderer.file_hover_cancelled() {
2331 dispatch_app_event(
2332 &mut self.app,
2333 event,
2334 &gfx.renderer,
2335 logical_viewport_of(gfx),
2336 self.last_diagnostics.as_ref(),
2337 &mut self.primary_selection,
2338 );
2339 }
2340 self.next_trigger = FrameTrigger::Pointer;
2341 gfx.window.request_redraw();
2342 }
2343
2344 WindowEvent::DroppedFile(path) => {
2345 let (lx, ly) = self.last_pointer.unwrap_or((0.0, 0.0));
2346 for event in gfx.renderer.file_dropped(path, lx, ly) {
2347 dispatch_app_event(
2348 &mut self.app,
2349 event,
2350 &gfx.renderer,
2351 logical_viewport_of(gfx),
2352 self.last_diagnostics.as_ref(),
2353 &mut self.primary_selection,
2354 );
2355 }
2356 self.next_trigger = FrameTrigger::Pointer;
2357 gfx.window.request_redraw();
2358 }
2359
2360 WindowEvent::MouseWheel { delta, .. } => {
2361 let Some((lx, ly)) = self.last_pointer else {
2362 return;
2363 };
2364 let (dx, dy) = match delta {
2365 MouseScrollDelta::LineDelta(x, y) => (-x * 50.0, -y * 50.0),
2366 MouseScrollDelta::PixelDelta(p) => {
2367 (-(p.x as f32) / scale, -(p.y as f32) / scale)
2368 }
2369 };
2370 let mut needs_redraw = false;
2371 let consumed =
2372 if let Some(event) = gfx.renderer.pointer_wheel_event(lx, ly, dx, dy) {
2373 needs_redraw = true;
2374 dispatch_app_wheel_event(
2375 &mut self.app,
2376 event,
2377 &gfx.renderer,
2378 logical_viewport_of(gfx),
2379 self.last_diagnostics.as_ref(),
2380 &mut self.primary_selection,
2381 )
2382 } else {
2383 false
2384 };
2385 if !consumed && gfx.renderer.pointer_wheel(lx, ly, dy) {
2386 needs_redraw = true;
2387 }
2388 if needs_redraw {
2389 self.next_trigger = FrameTrigger::Pointer;
2390 gfx.window.request_redraw();
2391 }
2392 }
2393
2394 WindowEvent::ModifiersChanged(modifiers) => {
2395 self.modifiers = key_modifiers(modifiers.state());
2396 gfx.renderer.set_modifiers(self.modifiers);
2397 }
2398
2399 WindowEvent::KeyboardInput {
2400 event:
2401 key_event @ winit::event::KeyEvent {
2402 state: ElementState::Pressed,
2403 ..
2404 },
2405 is_synthetic: false,
2406 ..
2407 } => {
2408 let logical = map_key(&key_event.logical_key);
2409 let physical = map_physical(key_event.physical_key);
2410 if logical != LogicalKey::Unidentified || physical != PhysicalKey::Unidentified
2411 {
2412 for event in gfx.renderer.key_down(
2413 logical,
2414 physical,
2415 self.modifiers,
2416 key_event.repeat,
2417 ) {
2418 match text_input::clipboard_request(&event) {
2419 Some(ClipboardKind::Copy) => {
2420 copy_current_selection(&gfx.renderer, write_clipboard_text);
2421 dispatch_app_event(
2422 &mut self.app,
2423 event,
2424 &gfx.renderer,
2425 logical_viewport_of(gfx),
2426 self.last_diagnostics.as_ref(),
2427 &mut self.primary_selection,
2428 );
2429 }
2430 Some(ClipboardKind::Cut) => {
2431 copy_current_selection(&gfx.renderer, write_clipboard_text);
2432 dispatch_app_event(
2433 &mut self.app,
2434 clipboard::delete_selection_event(event),
2435 &gfx.renderer,
2436 logical_viewport_of(gfx),
2437 self.last_diagnostics.as_ref(),
2438 &mut self.primary_selection,
2439 );
2440 }
2441 Some(ClipboardKind::Paste) => {}
2442 None => dispatch_app_event(
2443 &mut self.app,
2444 event,
2445 &gfx.renderer,
2446 logical_viewport_of(gfx),
2447 self.last_diagnostics.as_ref(),
2448 &mut self.primary_selection,
2449 ),
2450 }
2451 }
2452 }
2453 if let Some(text) = &key_event.text
2454 && let Some(event) = gfx.renderer.text_input(text.to_string())
2455 {
2456 dispatch_app_event(
2457 &mut self.app,
2458 event,
2459 &gfx.renderer,
2460 logical_viewport_of(gfx),
2461 self.last_diagnostics.as_ref(),
2462 &mut self.primary_selection,
2463 );
2464 }
2465 self.next_trigger = FrameTrigger::Keyboard;
2466 gfx.window.request_redraw();
2467 }
2468 WindowEvent::Ime(winit::event::Ime::Commit(text)) => {
2469 if let Some(event) = gfx.renderer.text_input(text) {
2470 dispatch_app_event(
2471 &mut self.app,
2472 event,
2473 &gfx.renderer,
2474 logical_viewport_of(gfx),
2475 self.last_diagnostics.as_ref(),
2476 &mut self.primary_selection,
2477 );
2478 }
2479 self.next_trigger = FrameTrigger::Keyboard;
2480 gfx.window.request_redraw();
2481 }
2482
2483 WindowEvent::RedrawRequested => {
2484 let frame_start = Instant::now();
2485 let event_viewport = logical_viewport_of(gfx);
2486 let clipboard_drained = drain_pending_clipboard_text(
2487 &mut self.app,
2488 &mut gfx.renderer,
2489 event_viewport,
2490 self.last_diagnostics.as_ref(),
2491 &self.pending_clipboard_text,
2492 &mut self.primary_selection,
2493 );
2494 if clipboard_drained {
2495 self.next_trigger = FrameTrigger::Keyboard;
2496 }
2497 let frame = match gfx.surface.get_current_texture() {
2498 wgpu::CurrentSurfaceTexture::Success(frame)
2499 | wgpu::CurrentSurfaceTexture::Suboptimal(frame) => frame,
2500 wgpu::CurrentSurfaceTexture::Lost
2501 | wgpu::CurrentSurfaceTexture::Outdated => {
2502 gfx.surface.configure(&gfx.device, &gfx.config);
2503 return;
2504 }
2505 other => {
2506 log::error!("surface unavailable: {other:?}");
2507 return;
2508 }
2509 };
2510 // Render through the sRGB view format (see
2511 // `srgb_view_of` and the surface configuration step
2512 // for why). When the swapchain is already sRGB this
2513 // collapses to the storage format and the view is
2514 // identical to `..Default::default()`.
2515 let view = frame.texture.create_view(&wgpu::TextureViewDescriptor {
2516 format: Some(gfx.render_format),
2517 ..Default::default()
2518 });
2519
2520 let last_frame_dt = self
2521 .last_frame_at
2522 .map(|t| frame_start.duration_since(t))
2523 .unwrap_or(std::time::Duration::ZERO);
2524 self.last_frame_at = Some(frame_start);
2525 let trigger = std::mem::take(&mut self.next_trigger);
2526 let scale_factor = gfx.window.scale_factor() as f32;
2527 let viewport_rect = Rect::new(
2528 0.0,
2529 0.0,
2530 gfx.config.width as f32 / scale_factor,
2531 gfx.config.height as f32 / scale_factor,
2532 );
2533 let current_size = (gfx.config.width, gfx.config.height);
2534 // Paint-only path: a time-driven shader's deadline
2535 // fired and nothing else has changed since the last
2536 // full prepare — skip rebuild + layout and reuse the
2537 // cached ops via `repaint`. The size guard catches
2538 // ResizeObserver fires that updated `gfx.config`
2539 // since the last prepare without setting a trigger.
2540 let paint_only = trigger == FrameTrigger::ShaderPaint
2541 && Some(current_size) == self.last_prepared_size;
2542
2543 let (prepare, palette, t_after_build, t_after_prepare) = if paint_only {
2544 // No build pass: reuse the renderer's already-set
2545 // theme palette and skip diagnostics / frame_index
2546 // bump. Apps reading `cx.diagnostics()` see the
2547 // overlay update only on layout frames, which is
2548 // the documented contract for paint-only.
2549 let palette = gfx.renderer.theme().palette().clone();
2550 let t_after_build = Instant::now();
2551 let prepare = gfx.renderer.repaint(
2552 &gfx.device,
2553 &gfx.queue,
2554 viewport_rect,
2555 scale_factor,
2556 );
2557 let t_after_prepare = Instant::now();
2558 (prepare, palette, t_after_build, t_after_prepare)
2559 } else {
2560 self.frame_index = self.frame_index.wrapping_add(1);
2561 let diagnostics = HostDiagnostics {
2562 backend: *self.backend.borrow(),
2563 surface_size: (gfx.config.width, gfx.config.height),
2564 scale_factor,
2565 msaa_samples: SAMPLE_COUNT,
2566 frame_index: self.frame_index,
2567 last_frame_dt,
2568 last_build: self.last_build,
2569 last_prepare: self.last_prepare,
2570 last_layout: self.last_layout,
2571 last_layout_intrinsic_cache_hits: self.last_layout_intrinsic_cache_hits,
2572 last_layout_intrinsic_cache_misses: self
2573 .last_layout_intrinsic_cache_misses,
2574 last_layout_pruned_subtrees: self.last_layout_pruned_subtrees,
2575 last_layout_pruned_nodes: self.last_layout_pruned_nodes,
2576 last_draw_ops: self.last_draw_ops,
2577 last_draw_ops_culled_text_ops: self.last_draw_ops_culled_text_ops,
2578 last_paint: self.last_paint,
2579 last_paint_culled_ops: self.last_paint_culled_ops,
2580 last_gpu_upload: self.last_gpu_upload,
2581 last_snapshot: self.last_snapshot,
2582 last_submit: self.last_submit,
2583 last_text_layout_cache_hits: self.last_text_layout_cache_hits,
2584 last_text_layout_cache_misses: self.last_text_layout_cache_misses,
2585 last_text_layout_cache_evictions: self.last_text_layout_cache_evictions,
2586 last_text_layout_shaped_bytes: self.last_text_layout_shaped_bytes,
2587 trigger,
2588 // The web/WebGPU host doesn't negotiate color
2589 // management; it composites in the default
2590 // sRGB-linear working space and presents to a
2591 // browser-managed canvas.
2592 working_color_space: damascene_core::paint::DEFAULT_WORKING_COLOR_SPACE,
2593 color_management:
2594 damascene_core::color::ColorManagementStatus::Unavailable,
2595 // No wgpu surface caps plumbed from the WebGPU
2596 // host yet; the canvas is browser-managed.
2597 surface_color: None,
2598 };
2599 // Retained for event dispatch: handlers read the
2600 // last built frame's snapshot via EventCx.
2601 self.last_diagnostics = Some(diagnostics.clone());
2602 self.app.before_build();
2603 let theme = self.app.theme();
2604 let safe_area = damascene_core::Sides {
2605 left: 0.0,
2606 right: 0.0,
2607 top: 0.0,
2608 bottom: self.keyboard_inset_bottom.get(),
2609 };
2610 let cx = BuildCx::new(&theme)
2611 .with_ui_state(gfx.renderer.ui_state())
2612 .with_diagnostics(&diagnostics)
2613 .with_viewport(viewport_rect.w, viewport_rect.h)
2614 .with_safe_area(safe_area);
2615 let tree = self.app.build(&cx);
2616 let palette = theme.palette().clone();
2617 gfx.renderer.set_theme(theme);
2618 gfx.renderer.set_hotkeys(self.app.hotkeys());
2619 gfx.renderer.set_selection(self.app.selection());
2620 gfx.renderer.push_toasts(self.app.drain_toasts());
2621 gfx.renderer
2622 .push_focus_requests(self.app.drain_focus_requests());
2623 gfx.renderer
2624 .push_scroll_requests(self.app.drain_scroll_requests());
2625 gfx.renderer
2626 .push_viewport_requests(self.app.drain_viewport_requests());
2627 gfx.renderer
2628 .push_plot_requests(self.app.drain_plot_requests());
2629 for url in self.app.drain_link_opens() {
2630 open_link(&url);
2631 }
2632 let t_after_build = Instant::now();
2633 let prepare = gfx.renderer.prepare(
2634 &gfx.device,
2635 &gfx.queue,
2636 tree,
2637 viewport_rect,
2638 scale_factor,
2639 );
2640 let t_after_prepare = Instant::now();
2641
2642 // Cursor resolution depends on the laid-out tree
2643 // and the hovered key derived from layout ids,
2644 // so it only updates on the full-prepare path.
2645 // Paint-only frames inherit the previous cursor.
2646 let cursor = gfx.renderer.snapshot_cursor();
2647 if cursor != self.last_cursor {
2648 gfx.window.set_cursor(winit_cursor(cursor));
2649 self.last_cursor = cursor;
2650 }
2651 self.last_prepared_size = Some(current_size);
2652 (prepare, palette, t_after_build, t_after_prepare)
2653 };
2654
2655 let mut encoder =
2656 gfx.device
2657 .create_command_encoder(&wgpu::CommandEncoderDescriptor {
2658 label: Some("damascene_web::encoder"),
2659 });
2660 // `render()` owns pass lifetimes itself so it can
2661 // split around `BackdropSnapshot` boundaries when
2662 // the App uses backdrop-sampling shaders. With no
2663 // boundary it collapses to a single Clear pass —
2664 // same behaviour as the old `begin_render_pass +
2665 // draw + end_render_pass` path.
2666 gfx.renderer.render(
2667 &gfx.device,
2668 &mut encoder,
2669 &frame.texture,
2670 &view,
2671 gfx.msaa.as_ref().map(|m| &m.view),
2672 wgpu::LoadOp::Clear(bg_color(&palette)),
2673 );
2674 gfx.queue.submit(Some(encoder.finish()));
2675 gfx.queue.present(frame);
2676 let t_after_submit = Instant::now();
2677
2678 self.stats.record(
2679 frame_start,
2680 t_after_build,
2681 t_after_prepare,
2682 t_after_submit,
2683 prepare.timings,
2684 );
2685 self.last_build = t_after_build - frame_start;
2686 self.last_prepare = t_after_prepare - t_after_build;
2687 self.last_submit = t_after_submit - t_after_prepare;
2688 self.last_layout = prepare.timings.layout;
2689 self.last_layout_intrinsic_cache_hits =
2690 prepare.timings.layout_intrinsic_cache.hits;
2691 self.last_layout_intrinsic_cache_misses =
2692 prepare.timings.layout_intrinsic_cache.misses;
2693 self.last_layout_pruned_subtrees = prepare.timings.layout_prune.subtrees;
2694 self.last_layout_pruned_nodes = prepare.timings.layout_prune.nodes;
2695 self.last_draw_ops = prepare.timings.draw_ops;
2696 self.last_draw_ops_culled_text_ops = prepare.timings.draw_ops_culled_text_ops;
2697 self.last_paint = prepare.timings.paint;
2698 self.last_paint_culled_ops = prepare.timings.paint_culled_ops;
2699 self.last_gpu_upload = prepare.timings.gpu_upload;
2700 self.last_snapshot = prepare.timings.snapshot;
2701 self.last_text_layout_cache_hits = prepare.timings.text_layout_cache.hits;
2702 self.last_text_layout_cache_misses = prepare.timings.text_layout_cache.misses;
2703 self.last_text_layout_cache_evictions =
2704 prepare.timings.text_layout_cache.evictions;
2705 self.last_text_layout_shaped_bytes =
2706 prepare.timings.text_layout_cache.shaped_bytes;
2707
2708 // Two-lane scheduling: a layout-driven signal
2709 // (animation settling, widget redraw_within,
2710 // tooltip / toast pending) takes precedence over a
2711 // paint-only signal — both arrive immediately
2712 // because the browser raf loop has no deadline
2713 // parking, but the trigger encodes which path the
2714 // next frame should take. On a paint-only frame
2715 // `repaint` reports `next_layout_redraw_in = None`
2716 // (it didn't re-evaluate), so the layout deadline
2717 // can only fall through if the prior full prepare
2718 // already cleared it.
2719 if prepare.next_layout_redraw_in.is_some() {
2720 self.next_trigger = FrameTrigger::Animation;
2721 gfx.window.request_redraw();
2722 } else if prepare.next_paint_redraw_in.is_some() {
2723 self.next_trigger = FrameTrigger::ShaderPaint;
2724 gfx.window.request_redraw();
2725 }
2726 }
2727 _ => {}
2728 }
2729 }
2730
2731 /// Fires once when the loop stops — on web that is only ever
2732 /// [`WebHandle::destroy`] (browsers have no `CloseRequested`).
2733 /// Runs the DOM teardown while the page references are still
2734 /// alive; winit drops this Host (and with it the JS closures)
2735 /// immediately after, then removes its own canvas listeners.
2736 fn exiting(&mut self, _event_loop: &ActiveEventLoop) {
2737 self.teardown();
2738 }
2739 }
2740
2741 /// Map a DOM `KeyboardEvent.code` string to a damascene
2742 /// [`PhysicalKey`]. Covers the editing / navigation keys the
2743 /// soft-keyboard keydown path forwards; anything else is
2744 /// [`PhysicalKey::Unidentified`].
2745 fn dom_physical(code: &str) -> PhysicalKey {
2746 match code {
2747 "Backspace" => PhysicalKey::Backspace,
2748 "Delete" => PhysicalKey::Delete,
2749 "Enter" => PhysicalKey::Enter,
2750 "NumpadEnter" => PhysicalKey::NumpadEnter,
2751 "ArrowUp" => PhysicalKey::ArrowUp,
2752 "ArrowDown" => PhysicalKey::ArrowDown,
2753 "ArrowLeft" => PhysicalKey::ArrowLeft,
2754 "ArrowRight" => PhysicalKey::ArrowRight,
2755 "Home" => PhysicalKey::Home,
2756 "End" => PhysicalKey::End,
2757 _ => PhysicalKey::Unidentified,
2758 }
2759 }
2760
2761 fn should_prevent_browser_key_default(event: &web_sys::KeyboardEvent) -> bool {
2762 // Keep browser/system shortcuts alive, especially Ctrl/Cmd+V:
2763 // preventing that keydown suppresses the trusted DOM `paste`
2764 // event that carries clipboard text in Firefox.
2765 if event.ctrl_key() || event.meta_key() || event.alt_key() {
2766 return false;
2767 }
2768
2769 let key = event.key();
2770 if key.chars().count() == 1 {
2771 return true;
2772 }
2773
2774 matches!(
2775 key.as_str(),
2776 "ArrowUp"
2777 | "ArrowDown"
2778 | "ArrowLeft"
2779 | "ArrowRight"
2780 | "Backspace"
2781 | "Delete"
2782 | "Home"
2783 | "End"
2784 | "PageUp"
2785 | "PageDown"
2786 | "Tab"
2787 | "Enter"
2788 | "Escape"
2789 )
2790 }
2791
2792 /// Clear color for the canvas: the background token converted into the
2793 /// working space, exactly like every painted fill. The web host doesn't
2794 /// negotiate color management — it composites in the default sRGB-linear
2795 /// working space (the host env pins `DEFAULT_WORKING_COLOR_SPACE`), so
2796 /// [`damascene_core::paint::rgba_f32`] is the matching conversion
2797 /// (issue #45).
2798 fn bg_color(palette: &Palette) -> wgpu::Color {
2799 let [r, g, b, a] = damascene_core::paint::rgba_f32(palette.background);
2800 wgpu::Color {
2801 r: r as f64,
2802 g: g as f64,
2803 b: b as f64,
2804 a: a as f64,
2805 }
2806 }
2807
2808 fn copy_current_selection(renderer: &Runner, write_text: impl FnOnce(String)) {
2809 // Read the selection out of `last_tree` (via the runtime
2810 // helper) — see `RunnerCore::selected_text` for why a
2811 // build-only path would miss selections inside a virtual
2812 // list.
2813 let Some(text) = renderer.selected_text() else {
2814 return;
2815 };
2816 write_text(text);
2817 }
2818
2819 fn write_clipboard_text(text: String) {
2820 let Some(window) = web_sys::window() else {
2821 log::warn!("damascene-web: no window; clipboard write dropped");
2822 return;
2823 };
2824 let promise = window.navigator().clipboard().write_text(&text);
2825 wasm_bindgen_futures::spawn_local(async move {
2826 if let Err(err) = wasm_bindgen_futures::JsFuture::from(promise).await {
2827 log::warn!("damascene-web: clipboard writeText failed: {err:?}");
2828 }
2829 });
2830 }
2831
2832 fn attach_primary_selection_text(mut event: UiEvent, primary_selection: &str) -> UiEvent {
2833 if event.kind == UiEventKind::MiddleClick && !primary_selection.is_empty() {
2834 event.text = Some(primary_selection.to_string());
2835 }
2836 event
2837 }
2838
2839 fn event_cx<'a>(
2840 renderer: &'a Runner,
2841 viewport: Option<(f32, f32)>,
2842 diagnostics: Option<&'a damascene_core::HostDiagnostics>,
2843 ) -> damascene_core::EventCx<'a> {
2844 let mut cx = damascene_core::EventCx::new().with_ui_state(renderer.ui_state());
2845 if let Some((w, h)) = viewport {
2846 cx = cx.with_viewport(w, h);
2847 }
2848 if let Some(d) = diagnostics {
2849 cx = cx.with_diagnostics(d);
2850 }
2851 cx
2852 }
2853
2854 fn dispatch_app_event<A: App>(
2855 app: &mut A,
2856 event: UiEvent,
2857 renderer: &Runner,
2858 viewport: Option<(f32, f32)>,
2859 diagnostics: Option<&damascene_core::HostDiagnostics>,
2860 primary_selection: &mut String,
2861 ) {
2862 let before = app.selection();
2863 let cx = event_cx(renderer, viewport, diagnostics);
2864 app.on_event(event, &cx);
2865 if app.selection() != before {
2866 // Resolve the post-event selection against `last_tree`.
2867 // The new selection's keys are typically the row the user
2868 // just clicked, which is present in the previous frame's
2869 // snapshot.
2870 *primary_selection = renderer
2871 .selected_text_for(&app.selection())
2872 .filter(|text| !text.is_empty())
2873 .unwrap_or_default();
2874 }
2875 }
2876
2877 fn dispatch_app_wheel_event<A: App>(
2878 app: &mut A,
2879 event: UiEvent,
2880 renderer: &Runner,
2881 viewport: Option<(f32, f32)>,
2882 diagnostics: Option<&damascene_core::HostDiagnostics>,
2883 primary_selection: &mut String,
2884 ) -> bool {
2885 let before = app.selection();
2886 let cx = event_cx(renderer, viewport, diagnostics);
2887 let consumed = app.on_wheel_event(event, &cx);
2888 if app.selection() != before {
2889 *primary_selection = renderer
2890 .selected_text_for(&app.selection())
2891 .filter(|text| !text.is_empty())
2892 .unwrap_or_default();
2893 }
2894 consumed
2895 }
2896
2897 fn drain_pending_clipboard_text<A: App>(
2898 app: &mut A,
2899 renderer: &mut Runner,
2900 viewport: Option<(f32, f32)>,
2901 diagnostics: Option<&damascene_core::HostDiagnostics>,
2902 pending_text: &Rc<RefCell<VecDeque<String>>>,
2903 primary_selection: &mut String,
2904 ) -> bool {
2905 let mut drained = false;
2906 while let Some(text) = pending_text.borrow_mut().pop_front() {
2907 let Some(event) = renderer.text_input(text.clone()) else {
2908 continue;
2909 };
2910 drained = true;
2911 let event = clipboard::paste_text_event(event, text);
2912 dispatch_app_event(
2913 app,
2914 event,
2915 renderer,
2916 viewport,
2917 diagnostics,
2918 primary_selection,
2919 );
2920 }
2921 drained
2922 }
2923}
2924
2925#[cfg(test)]
2926mod mailbox_tests {
2927 use super::Mailbox;
2928
2929 #[test]
2930 fn mailbox_queues_and_drains_in_order() {
2931 let mailbox = Mailbox::new();
2932 assert!(mailbox.is_empty());
2933 // Pushes before set_handle queue silently (no host to wake).
2934 mailbox.push(1);
2935 let clone = mailbox.clone();
2936 clone.push(2);
2937 mailbox.push(3);
2938 assert!(!mailbox.is_empty());
2939 assert_eq!(mailbox.drain(), vec![1, 2, 3]);
2940 assert!(mailbox.is_empty());
2941 assert_eq!(mailbox.drain(), Vec::<i32>::new());
2942 }
2943
2944 #[test]
2945 fn mailbox_set_handle_accepts_stub() {
2946 let mailbox = Mailbox::new();
2947 mailbox.set_handle(super::WebHandle::default());
2948 mailbox.push("after-handle");
2949 assert_eq!(mailbox.drain(), vec!["after-handle"]);
2950 }
2951}