Skip to main content

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}