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}