Skip to main content

aetna_web/
lib.rs

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