damascene_web/lib.rs
1//! Browser host for Damascene wasm apps.
2//!
3//! Write normal UI code against `damascene_core::prelude::*`, then call
4//! [`start_with`] from your wasm crate's `#[wasm_bindgen(start)]`
5//! entry point. The host opens a wgpu surface against a canvas in the
6//! page and drives the app through winit's browser event loop.
7//!
8//! The default configuration expects a `<canvas id="damascene_canvas">`.
9//! Use [`start_with_config`] when embedding into a page with a different
10//! canvas id.
11//!
12//! `damascene-winit-wgpu` is the equivalent reusable native host.
13
14use damascene_core::Rect;
15
16/// Default canvas element id used by [`WebHostConfig::default`].
17pub const DEFAULT_CANVAS_ID: &str = "damascene_canvas";
18
19/// Default logical viewport. Sized to feel reasonable both as a winit
20/// window and as a browser canvas. Browsers can override this by
21/// resizing the canvas; the runner reacts to `winit::Resized`.
22pub const VIEWPORT: Rect = Rect {
23 x: 0.0,
24 y: 0.0,
25 w: 900.0,
26 h: 640.0,
27};
28
29/// Browser host configuration.
30#[derive(Clone, Debug)]
31pub struct WebHostConfig {
32 /// Fallback logical viewport used when the canvas has no CSS size
33 /// yet. Once the page lays the canvas out, the host tracks its CSS
34 /// box through `ResizeObserver`.
35 pub viewport: Rect,
36 /// Id of the canvas element the host should attach to.
37 pub canvas_id: String,
38}
39
40impl WebHostConfig {
41 pub fn new(viewport: Rect) -> Self {
42 Self {
43 viewport,
44 canvas_id: DEFAULT_CANVAS_ID.to_string(),
45 }
46 }
47
48 pub fn with_canvas_id(mut self, canvas_id: impl Into<String>) -> Self {
49 self.canvas_id = canvas_id.into();
50 self
51 }
52}
53
54impl Default for WebHostConfig {
55 fn default() -> Self {
56 Self::new(VIEWPORT)
57 }
58}
59
60#[cfg(target_arch = "wasm32")]
61pub use web_entry::{WebHandle, 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 damascene_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!("damascene-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!("damascene-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 damascene_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 damascene_wgpu::{PrepareTimings, Runner, RunnerCaps};
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 Damascene 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 Damascene [`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 Damascene 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 Damascene 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 Damascene 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!("damascene-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!("damascene-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 = damascene_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 Damascene 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 (damascene-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 Damascene
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 `damascene-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 Damascene `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 Damascene 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 Damascene 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<damascene_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 Damascene 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!("damascene-web: paste event had no clipboardData");
1370 return;
1371 };
1372 let Ok(text) = data.get_data("text/plain") else {
1373 log::warn!("damascene-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 "damascene-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 Damascene-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 "damascene-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 // What the runner must adapt to on this adapter — naga's
1618 // GLSL ES target rejects per-sample interpolation and
1619 // depth-texture loads at shader-module creation, so these
1620 // have to be known up front. See `RunnerCaps` for the
1621 // per-cap details (including the SwiftShader caveat that
1622 // makes GL distrusted wholesale).
1623 let caps = RunnerCaps::from_adapter(&adapter);
1624 if !caps.per_sample_shading {
1625 log::info!(
1626 "damascene-web: per-sample shading unavailable on selected backend; \
1627 shaders will downlevel `@interpolate(perspective, sample)` to per-pixel-centre interpolation"
1628 );
1629 }
1630 if !caps.depth_readback {
1631 log::info!(
1632 "damascene-web: depth-attachment read-back unavailable on WebGL2; \
1633 3D scene label occlusion uses the packed depth-as-color capture"
1634 );
1635 }
1636
1637 // WebGL2 has a tighter feature/limit envelope than
1638 // native; downlevel_webgl2_defaults is the matching
1639 // baseline. Cap at the adapter's actual limits so
1640 // device creation succeeds on every integrated GPU.
1641 let limits =
1642 wgpu::Limits::downlevel_webgl2_defaults().using_resolution(adapter.limits());
1643
1644 let (device, queue) = adapter
1645 .request_device(&wgpu::DeviceDescriptor {
1646 label: Some("damascene_web::device"),
1647 required_features: wgpu::Features::empty(),
1648 required_limits: limits,
1649 experimental_features: wgpu::ExperimentalFeatures::default(),
1650 memory_hints: wgpu::MemoryHints::Performance,
1651 trace: wgpu::Trace::Off,
1652 })
1653 .await
1654 .expect("request_device");
1655
1656 let surface_caps = surface.get_capabilities(&adapter);
1657 let format = surface_caps
1658 .formats
1659 .iter()
1660 .copied()
1661 .find(|f| f.is_srgb())
1662 .unwrap_or(surface_caps.formats[0]);
1663 // Decide the render-target view format. If the chosen
1664 // swapchain format is already sRGB-tagged (native, most
1665 // browsers' WebGL2 surfaces), this collapses to the
1666 // same format. Chromium's WebGPU surface offers only
1667 // linear formats — `Rgba8Unorm`, `Bgra8Unorm`,
1668 // `Rgba16Float` — so without this fix-up our shaders'
1669 // linear writes hit the compositor uncorrected and the
1670 // page renders 2.2-gamma's worth darker than native.
1671 // The trick: keep the swapchain format as `Rgba8Unorm`
1672 // (storage), declare `Rgba8UnormSrgb` as a view format,
1673 // and create every render-target view through that. The
1674 // hardware applies the sRGB encode on store. WebGPU
1675 // explicitly permits this view-format reinterpretation
1676 // because the two formats differ only in the sRGB flag.
1677 let render_format = srgb_view_of(format).unwrap_or(format);
1678 let view_formats = if render_format != format {
1679 vec![render_format]
1680 } else {
1681 Vec::new()
1682 };
1683 log::info!(
1684 "damascene-web: surface format {:?} (sRGB? {}) → render view {:?}; offered {:?}",
1685 format,
1686 format.is_srgb(),
1687 render_format,
1688 surface_caps.formats,
1689 );
1690 // Single source of truth for the swapchain size:
1691 // winit's inner_size() in physical pixels. Same value
1692 // that the native winit + wgpu host uses; matches what
1693 // sync_canvas_to_css() set the canvas backing buffer to.
1694 let inner = window_for_async.inner_size();
1695 // COPY_SRC is required so backdrop-sampling shaders can
1696 // copy the post-Pass-A surface into the runner's
1697 // snapshot texture mid-frame. WebGL2 surfaces typically
1698 // advertise it; if the adapter ever doesn't, we fall
1699 // back to RENDER_ATTACHMENT-only and any backdrop
1700 // shaders the App declared simply won't paint a glass
1701 // surface (the rest of the UI is unaffected).
1702 let want_copy_src = surface_caps.usages.contains(wgpu::TextureUsages::COPY_SRC);
1703 let usage = if want_copy_src {
1704 wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC
1705 } else {
1706 log::warn!(
1707 "damascene-web: surface does not advertise COPY_SRC; backdrop-sampling \
1708 shaders will paint nothing on this backend"
1709 );
1710 wgpu::TextureUsages::RENDER_ATTACHMENT
1711 };
1712 // Prefer Fifo (vsync) so redraws can't outrun the
1713 // browser's compositor — same rationale as
1714 // damascene-winit-wgpu.
1715 let present_mode = if surface_caps
1716 .present_modes
1717 .contains(&wgpu::PresentMode::Fifo)
1718 {
1719 wgpu::PresentMode::Fifo
1720 } else {
1721 surface_caps.present_modes[0]
1722 };
1723 let config = wgpu::SurfaceConfiguration {
1724 usage,
1725 format,
1726 width: inner.width.max(1),
1727 height: inner.height.max(1),
1728 present_mode,
1729 alpha_mode: surface_caps.alpha_modes[0],
1730 view_formats,
1731 desired_maximum_frame_latency: 2,
1732 };
1733 surface.configure(&device, &config);
1734
1735 let mut renderer =
1736 Runner::with_caps(&device, &queue, render_format, SAMPLE_COUNT, caps);
1737 renderer.set_theme(theme);
1738 renderer.set_surface_size(config.width, config.height);
1739 // Register every shader the App declared. If the
1740 // surface doesn't support COPY_SRC (so multi-pass
1741 // backdrop sampling is impossible), skip the backdrop
1742 // shaders rather than registering them and rendering
1743 // garbage.
1744 for s in shaders {
1745 if s.samples_backdrop && !want_copy_src {
1746 continue;
1747 }
1748 renderer.register_shader_with(
1749 &device,
1750 s.name,
1751 s.wgsl,
1752 s.samples_backdrop,
1753 s.samples_time,
1754 );
1755 }
1756
1757 // MSAA target only when SAMPLE_COUNT > 1; the
1758 // single-sample path renders straight into the
1759 // swapchain texture.
1760 let msaa = if SAMPLE_COUNT > 1 {
1761 Some(damascene_wgpu::MsaaTarget::new(
1762 &device,
1763 render_format,
1764 surface_extent(&config),
1765 SAMPLE_COUNT,
1766 ))
1767 } else {
1768 None
1769 };
1770 *gfx_slot.borrow_mut() = Some(Gfx {
1771 window: window_for_async.clone(),
1772 surface,
1773 device,
1774 queue,
1775 config,
1776 renderer,
1777 msaa,
1778 render_format,
1779 });
1780 if handle_for_async.mark_ready() {
1781 log::debug!("damascene-web: flushing pending external redraw request");
1782 }
1783 window_for_async.request_redraw();
1784 });
1785 }
1786
1787 fn window_event(
1788 &mut self,
1789 event_loop: &ActiveEventLoop,
1790 _id: WindowId,
1791 event: WindowEvent,
1792 ) {
1793 // Clone the `Rc` first so the `RefMut` we get from
1794 // `borrow_mut` is tied to the cloned cell rather than
1795 // through `&self.gfx` — that lets `drain_pending_pointer`
1796 // re-borrow `self` mutably while `gfx_borrow` is still
1797 // live.
1798 let gfx_cell = self.gfx.clone();
1799 let mut gfx_borrow = gfx_cell.borrow_mut();
1800 let Some(gfx) = gfx_borrow.as_mut() else {
1801 // Async setup hasn't finished; drop the event. The
1802 // post-setup `request_redraw` will trigger a fresh
1803 // RedrawRequested once we're ready.
1804 return;
1805 };
1806 // Drain DOM PointerEvent listeners before processing the
1807 // winit event. The closures pushed onto
1808 // `pending_pointer` and called `request_redraw`, which
1809 // is what brought us here — handle the captured input
1810 // first so RedrawRequested sees the post-event state.
1811 if self.drain_pending_pointer(gfx) {
1812 self.next_trigger = FrameTrigger::Pointer;
1813 }
1814 // Drain soft-keyboard edits next — order matters because
1815 // a pointer event may have shifted focus to a text
1816 // input, after which keystrokes captured this frame
1817 // should reach the new target.
1818 if self.drain_soft_keyboard(gfx) {
1819 self.next_trigger = FrameTrigger::Keyboard;
1820 }
1821 // If focus moved off a text input this frame, dismiss
1822 // the on-screen keyboard now (done after both drains so
1823 // the focus state reflects everything that just
1824 // happened).
1825 self.sync_soft_keyboard_focus(gfx);
1826 let scale = gfx.window.scale_factor() as f32;
1827
1828 match event {
1829 WindowEvent::CloseRequested => event_loop.exit(),
1830
1831 WindowEvent::Resized(size) => {
1832 gfx.config.width = size.width.max(1);
1833 gfx.config.height = size.height.max(1);
1834 gfx.surface.configure(&gfx.device, &gfx.config);
1835 gfx.renderer
1836 .set_surface_size(gfx.config.width, gfx.config.height);
1837 if let Some(msaa) = gfx.msaa.as_mut() {
1838 let extent = surface_extent(&gfx.config);
1839 if !msaa.matches(extent) {
1840 *msaa = damascene_wgpu::MsaaTarget::new(
1841 &gfx.device,
1842 gfx.render_format,
1843 extent,
1844 SAMPLE_COUNT,
1845 );
1846 }
1847 }
1848 self.next_trigger = FrameTrigger::Resize;
1849 gfx.window.request_redraw();
1850 }
1851
1852 // Pointer input on web flows through DOM PointerEvent
1853 // listeners installed in `resumed()`. winit's
1854 // CursorMoved / CursorLeft / MouseInput on the web
1855 // backend collapse touch and pen to mouse before
1856 // forwarding, so handling them here would either
1857 // double-route (the DOM listener already saw them)
1858 // or strip the modality. They're intentionally
1859 // ignored — the drain at the top of window_event
1860 // dispatches everything the closures captured.
1861
1862 // Browser drag/drop and clipboard-image plumbing rides
1863 // the HTML File API rather than winit (which doesn't
1864 // surface DroppedFile on wasm32). Web hosts that need
1865 // file-drop support listen for `dragenter` / `drop` on
1866 // the canvas via wasm-bindgen and route the resulting
1867 // bytes through their own paths. The winit event arms
1868 // exist for source-parity with the native hosts; on
1869 // web they currently won't fire.
1870 WindowEvent::HoveredFile(path) => {
1871 let (lx, ly) = self.last_pointer.unwrap_or((0.0, 0.0));
1872 for event in gfx.renderer.file_hovered(path, lx, ly) {
1873 dispatch_app_event(
1874 &mut self.app,
1875 event,
1876 &gfx.renderer,
1877 &mut self.primary_selection,
1878 );
1879 }
1880 self.next_trigger = FrameTrigger::Pointer;
1881 gfx.window.request_redraw();
1882 }
1883
1884 WindowEvent::HoveredFileCancelled => {
1885 for event in gfx.renderer.file_hover_cancelled() {
1886 dispatch_app_event(
1887 &mut self.app,
1888 event,
1889 &gfx.renderer,
1890 &mut self.primary_selection,
1891 );
1892 }
1893 self.next_trigger = FrameTrigger::Pointer;
1894 gfx.window.request_redraw();
1895 }
1896
1897 WindowEvent::DroppedFile(path) => {
1898 let (lx, ly) = self.last_pointer.unwrap_or((0.0, 0.0));
1899 for event in gfx.renderer.file_dropped(path, lx, ly) {
1900 dispatch_app_event(
1901 &mut self.app,
1902 event,
1903 &gfx.renderer,
1904 &mut self.primary_selection,
1905 );
1906 }
1907 self.next_trigger = FrameTrigger::Pointer;
1908 gfx.window.request_redraw();
1909 }
1910
1911 WindowEvent::MouseWheel { delta, .. } => {
1912 let Some((lx, ly)) = self.last_pointer else {
1913 return;
1914 };
1915 let dy = match delta {
1916 MouseScrollDelta::LineDelta(_, y) => -y * 50.0,
1917 MouseScrollDelta::PixelDelta(p) => -(p.y as f32) / scale,
1918 };
1919 let mut needs_redraw = false;
1920 let consumed =
1921 if let Some(event) = gfx.renderer.pointer_wheel_event(lx, ly, 0.0, dy) {
1922 needs_redraw = true;
1923 dispatch_app_wheel_event(
1924 &mut self.app,
1925 event,
1926 &gfx.renderer,
1927 &mut self.primary_selection,
1928 )
1929 } else {
1930 false
1931 };
1932 if !consumed && gfx.renderer.pointer_wheel(lx, ly, dy) {
1933 needs_redraw = true;
1934 }
1935 if needs_redraw {
1936 self.next_trigger = FrameTrigger::Pointer;
1937 gfx.window.request_redraw();
1938 }
1939 }
1940
1941 WindowEvent::ModifiersChanged(modifiers) => {
1942 self.modifiers = key_modifiers(modifiers.state());
1943 gfx.renderer.set_modifiers(self.modifiers);
1944 }
1945
1946 WindowEvent::KeyboardInput {
1947 event:
1948 key_event @ winit::event::KeyEvent {
1949 state: ElementState::Pressed,
1950 ..
1951 },
1952 is_synthetic: false,
1953 ..
1954 } => {
1955 if let Some(key) = map_key(&key_event.logical_key) {
1956 for event in gfx.renderer.key_down(key, self.modifiers, key_event.repeat) {
1957 match text_input::clipboard_request(&event) {
1958 Some(ClipboardKind::Copy) => {
1959 copy_current_selection(&gfx.renderer, write_clipboard_text);
1960 dispatch_app_event(
1961 &mut self.app,
1962 event,
1963 &gfx.renderer,
1964 &mut self.primary_selection,
1965 );
1966 }
1967 Some(ClipboardKind::Cut) => {
1968 copy_current_selection(&gfx.renderer, write_clipboard_text);
1969 dispatch_app_event(
1970 &mut self.app,
1971 clipboard::delete_selection_event(event),
1972 &gfx.renderer,
1973 &mut self.primary_selection,
1974 );
1975 }
1976 Some(ClipboardKind::Paste) => {}
1977 None => dispatch_app_event(
1978 &mut self.app,
1979 event,
1980 &gfx.renderer,
1981 &mut self.primary_selection,
1982 ),
1983 }
1984 }
1985 }
1986 if let Some(text) = &key_event.text
1987 && let Some(event) = gfx.renderer.text_input(text.to_string())
1988 {
1989 dispatch_app_event(
1990 &mut self.app,
1991 event,
1992 &gfx.renderer,
1993 &mut self.primary_selection,
1994 );
1995 }
1996 self.next_trigger = FrameTrigger::Keyboard;
1997 gfx.window.request_redraw();
1998 }
1999 WindowEvent::Ime(winit::event::Ime::Commit(text)) => {
2000 if let Some(event) = gfx.renderer.text_input(text) {
2001 dispatch_app_event(
2002 &mut self.app,
2003 event,
2004 &gfx.renderer,
2005 &mut self.primary_selection,
2006 );
2007 }
2008 self.next_trigger = FrameTrigger::Keyboard;
2009 gfx.window.request_redraw();
2010 }
2011
2012 WindowEvent::RedrawRequested => {
2013 let frame_start = Instant::now();
2014 let clipboard_drained = drain_pending_clipboard_text(
2015 &mut self.app,
2016 &mut gfx.renderer,
2017 &self.pending_clipboard_text,
2018 &mut self.primary_selection,
2019 );
2020 if clipboard_drained {
2021 self.next_trigger = FrameTrigger::Keyboard;
2022 }
2023 let frame = match gfx.surface.get_current_texture() {
2024 wgpu::CurrentSurfaceTexture::Success(frame)
2025 | wgpu::CurrentSurfaceTexture::Suboptimal(frame) => frame,
2026 wgpu::CurrentSurfaceTexture::Lost
2027 | wgpu::CurrentSurfaceTexture::Outdated => {
2028 gfx.surface.configure(&gfx.device, &gfx.config);
2029 return;
2030 }
2031 other => {
2032 log::error!("surface unavailable: {other:?}");
2033 return;
2034 }
2035 };
2036 // Render through the sRGB view format (see
2037 // `srgb_view_of` and the surface configuration step
2038 // for why). When the swapchain is already sRGB this
2039 // collapses to the storage format and the view is
2040 // identical to `..Default::default()`.
2041 let view = frame.texture.create_view(&wgpu::TextureViewDescriptor {
2042 format: Some(gfx.render_format),
2043 ..Default::default()
2044 });
2045
2046 let last_frame_dt = self
2047 .last_frame_at
2048 .map(|t| frame_start.duration_since(t))
2049 .unwrap_or(std::time::Duration::ZERO);
2050 self.last_frame_at = Some(frame_start);
2051 let trigger = std::mem::take(&mut self.next_trigger);
2052 let scale_factor = gfx.window.scale_factor() as f32;
2053 let viewport_rect = Rect::new(
2054 0.0,
2055 0.0,
2056 gfx.config.width as f32 / scale_factor,
2057 gfx.config.height as f32 / scale_factor,
2058 );
2059 let current_size = (gfx.config.width, gfx.config.height);
2060 // Paint-only path: a time-driven shader's deadline
2061 // fired and nothing else has changed since the last
2062 // full prepare — skip rebuild + layout and reuse the
2063 // cached ops via `repaint`. The size guard catches
2064 // ResizeObserver fires that updated `gfx.config`
2065 // since the last prepare without setting a trigger.
2066 let paint_only = trigger == FrameTrigger::ShaderPaint
2067 && Some(current_size) == self.last_prepared_size;
2068
2069 let (prepare, palette, t_after_build, t_after_prepare) = if paint_only {
2070 // No build pass: reuse the renderer's already-set
2071 // theme palette and skip diagnostics / frame_index
2072 // bump. Apps reading `cx.diagnostics()` see the
2073 // overlay update only on layout frames, which is
2074 // the documented contract for paint-only.
2075 let palette = gfx.renderer.theme().palette().clone();
2076 let t_after_build = Instant::now();
2077 let prepare = gfx.renderer.repaint(
2078 &gfx.device,
2079 &gfx.queue,
2080 viewport_rect,
2081 scale_factor,
2082 );
2083 let t_after_prepare = Instant::now();
2084 (prepare, palette, t_after_build, t_after_prepare)
2085 } else {
2086 self.frame_index = self.frame_index.wrapping_add(1);
2087 let diagnostics = HostDiagnostics {
2088 backend: *self.backend.borrow(),
2089 surface_size: (gfx.config.width, gfx.config.height),
2090 scale_factor,
2091 msaa_samples: SAMPLE_COUNT,
2092 frame_index: self.frame_index,
2093 last_frame_dt,
2094 last_build: self.last_build,
2095 last_prepare: self.last_prepare,
2096 last_layout: self.last_layout,
2097 last_layout_intrinsic_cache_hits: self.last_layout_intrinsic_cache_hits,
2098 last_layout_intrinsic_cache_misses: self
2099 .last_layout_intrinsic_cache_misses,
2100 last_layout_pruned_subtrees: self.last_layout_pruned_subtrees,
2101 last_layout_pruned_nodes: self.last_layout_pruned_nodes,
2102 last_draw_ops: self.last_draw_ops,
2103 last_draw_ops_culled_text_ops: self.last_draw_ops_culled_text_ops,
2104 last_paint: self.last_paint,
2105 last_paint_culled_ops: self.last_paint_culled_ops,
2106 last_gpu_upload: self.last_gpu_upload,
2107 last_snapshot: self.last_snapshot,
2108 last_submit: self.last_submit,
2109 last_text_layout_cache_hits: self.last_text_layout_cache_hits,
2110 last_text_layout_cache_misses: self.last_text_layout_cache_misses,
2111 last_text_layout_cache_evictions: self.last_text_layout_cache_evictions,
2112 last_text_layout_shaped_bytes: self.last_text_layout_shaped_bytes,
2113 trigger,
2114 // The web/WebGPU host doesn't negotiate color
2115 // management; it composites in the default
2116 // sRGB-linear working space and presents to a
2117 // browser-managed canvas.
2118 working_color_space: damascene_core::paint::DEFAULT_WORKING_COLOR_SPACE,
2119 color_management:
2120 damascene_core::color::ColorManagementStatus::Unavailable,
2121 // No wgpu surface caps plumbed from the WebGPU
2122 // host yet; the canvas is browser-managed.
2123 surface_color: None,
2124 };
2125 self.app.before_build();
2126 let theme = self.app.theme();
2127 let safe_area = damascene_core::Sides {
2128 left: 0.0,
2129 right: 0.0,
2130 top: 0.0,
2131 bottom: self.keyboard_inset_bottom.get(),
2132 };
2133 let cx = BuildCx::new(&theme)
2134 .with_ui_state(gfx.renderer.ui_state())
2135 .with_diagnostics(&diagnostics)
2136 .with_viewport(viewport_rect.w, viewport_rect.h)
2137 .with_safe_area(safe_area);
2138 let mut tree = self.app.build(&cx);
2139 let palette = theme.palette().clone();
2140 gfx.renderer.set_theme(theme);
2141 gfx.renderer.set_hotkeys(self.app.hotkeys());
2142 gfx.renderer.set_selection(self.app.selection());
2143 gfx.renderer.push_toasts(self.app.drain_toasts());
2144 gfx.renderer
2145 .push_focus_requests(self.app.drain_focus_requests());
2146 gfx.renderer
2147 .push_scroll_requests(self.app.drain_scroll_requests());
2148 for url in self.app.drain_link_opens() {
2149 open_link(&url);
2150 }
2151 let t_after_build = Instant::now();
2152 let prepare = gfx.renderer.prepare(
2153 &gfx.device,
2154 &gfx.queue,
2155 &mut tree,
2156 viewport_rect,
2157 scale_factor,
2158 );
2159 let t_after_prepare = Instant::now();
2160
2161 // Cursor resolution depends on the laid-out tree
2162 // and the hovered key derived from layout ids,
2163 // so it only updates on the full-prepare path.
2164 // Paint-only frames inherit the previous cursor.
2165 let cursor = gfx.renderer.ui_state().cursor(&tree);
2166 if cursor != self.last_cursor {
2167 gfx.window.set_cursor(winit_cursor(cursor));
2168 self.last_cursor = cursor;
2169 }
2170 self.last_prepared_size = Some(current_size);
2171 (prepare, palette, t_after_build, t_after_prepare)
2172 };
2173
2174 let mut encoder =
2175 gfx.device
2176 .create_command_encoder(&wgpu::CommandEncoderDescriptor {
2177 label: Some("damascene_web::encoder"),
2178 });
2179 // `render()` owns pass lifetimes itself so it can
2180 // split around `BackdropSnapshot` boundaries when
2181 // the App uses backdrop-sampling shaders. With no
2182 // boundary it collapses to a single Clear pass —
2183 // same behaviour as the old `begin_render_pass +
2184 // draw + end_render_pass` path.
2185 gfx.renderer.render(
2186 &gfx.device,
2187 &mut encoder,
2188 &frame.texture,
2189 &view,
2190 gfx.msaa.as_ref().map(|m| &m.view),
2191 wgpu::LoadOp::Clear(bg_color(&palette)),
2192 );
2193 gfx.queue.submit(Some(encoder.finish()));
2194 frame.present();
2195 let t_after_submit = Instant::now();
2196
2197 self.stats.record(
2198 frame_start,
2199 t_after_build,
2200 t_after_prepare,
2201 t_after_submit,
2202 prepare.timings,
2203 );
2204 self.last_build = t_after_build - frame_start;
2205 self.last_prepare = t_after_prepare - t_after_build;
2206 self.last_submit = t_after_submit - t_after_prepare;
2207 self.last_layout = prepare.timings.layout;
2208 self.last_layout_intrinsic_cache_hits =
2209 prepare.timings.layout_intrinsic_cache.hits;
2210 self.last_layout_intrinsic_cache_misses =
2211 prepare.timings.layout_intrinsic_cache.misses;
2212 self.last_layout_pruned_subtrees = prepare.timings.layout_prune.subtrees;
2213 self.last_layout_pruned_nodes = prepare.timings.layout_prune.nodes;
2214 self.last_draw_ops = prepare.timings.draw_ops;
2215 self.last_draw_ops_culled_text_ops = prepare.timings.draw_ops_culled_text_ops;
2216 self.last_paint = prepare.timings.paint;
2217 self.last_paint_culled_ops = prepare.timings.paint_culled_ops;
2218 self.last_gpu_upload = prepare.timings.gpu_upload;
2219 self.last_snapshot = prepare.timings.snapshot;
2220 self.last_text_layout_cache_hits = prepare.timings.text_layout_cache.hits;
2221 self.last_text_layout_cache_misses = prepare.timings.text_layout_cache.misses;
2222 self.last_text_layout_cache_evictions =
2223 prepare.timings.text_layout_cache.evictions;
2224 self.last_text_layout_shaped_bytes =
2225 prepare.timings.text_layout_cache.shaped_bytes;
2226
2227 // Two-lane scheduling: a layout-driven signal
2228 // (animation settling, widget redraw_within,
2229 // tooltip / toast pending) takes precedence over a
2230 // paint-only signal — both arrive immediately
2231 // because the browser raf loop has no deadline
2232 // parking, but the trigger encodes which path the
2233 // next frame should take. On a paint-only frame
2234 // `repaint` reports `next_layout_redraw_in = None`
2235 // (it didn't re-evaluate), so the layout deadline
2236 // can only fall through if the prior full prepare
2237 // already cleared it.
2238 if prepare.next_layout_redraw_in.is_some() {
2239 self.next_trigger = FrameTrigger::Animation;
2240 gfx.window.request_redraw();
2241 } else if prepare.next_paint_redraw_in.is_some() {
2242 self.next_trigger = FrameTrigger::ShaderPaint;
2243 gfx.window.request_redraw();
2244 }
2245 let _ = self.config.viewport;
2246 }
2247 _ => {}
2248 }
2249 }
2250 }
2251
2252 fn map_key(key: &Key) -> Option<UiKey> {
2253 match key {
2254 Key::Named(NamedKey::Enter) => Some(UiKey::Enter),
2255 Key::Named(NamedKey::Escape) => Some(UiKey::Escape),
2256 Key::Named(NamedKey::Tab) => Some(UiKey::Tab),
2257 Key::Named(NamedKey::Space) => Some(UiKey::Space),
2258 Key::Named(NamedKey::ArrowUp) => Some(UiKey::ArrowUp),
2259 Key::Named(NamedKey::ArrowDown) => Some(UiKey::ArrowDown),
2260 Key::Named(NamedKey::ArrowLeft) => Some(UiKey::ArrowLeft),
2261 Key::Named(NamedKey::ArrowRight) => Some(UiKey::ArrowRight),
2262 Key::Named(NamedKey::Backspace) => Some(UiKey::Backspace),
2263 Key::Named(NamedKey::Delete) => Some(UiKey::Delete),
2264 Key::Named(NamedKey::Home) => Some(UiKey::Home),
2265 Key::Named(NamedKey::End) => Some(UiKey::End),
2266 Key::Named(NamedKey::PageUp) => Some(UiKey::PageUp),
2267 Key::Named(NamedKey::PageDown) => Some(UiKey::PageDown),
2268 Key::Character(s) => Some(UiKey::Character(s.to_string())),
2269 Key::Named(named) => Some(UiKey::Other(format!("{named:?}"))),
2270 _ => None,
2271 }
2272 }
2273
2274 fn key_modifiers(mods: winit::keyboard::ModifiersState) -> KeyModifiers {
2275 KeyModifiers {
2276 shift: mods.shift_key(),
2277 ctrl: mods.control_key(),
2278 alt: mods.alt_key(),
2279 logo: mods.super_key(),
2280 }
2281 }
2282
2283 fn should_prevent_browser_key_default(event: &web_sys::KeyboardEvent) -> bool {
2284 // Keep browser/system shortcuts alive, especially Ctrl/Cmd+V:
2285 // preventing that keydown suppresses the trusted DOM `paste`
2286 // event that carries clipboard text in Firefox.
2287 if event.ctrl_key() || event.meta_key() || event.alt_key() {
2288 return false;
2289 }
2290
2291 let key = event.key();
2292 if key.chars().count() == 1 {
2293 return true;
2294 }
2295
2296 matches!(
2297 key.as_str(),
2298 "ArrowUp"
2299 | "ArrowDown"
2300 | "ArrowLeft"
2301 | "ArrowRight"
2302 | "Backspace"
2303 | "Delete"
2304 | "Home"
2305 | "End"
2306 | "PageUp"
2307 | "PageDown"
2308 | "Tab"
2309 | "Enter"
2310 | "Escape"
2311 )
2312 }
2313
2314 /// Translate an Damascene [`Cursor`] to winit's [`CursorIcon`]. winit's
2315 /// web backend then maps that to a CSS `cursor:` string and writes
2316 /// it to the canvas's inline style — so this is the only piece of
2317 /// platform-specific cursor wiring the browser host needs.
2318 /// `Cursor` is `non_exhaustive`; new variants land in `damascene-core`
2319 /// and a parallel arm here, with the wildcard as a forward-compat
2320 /// fallback.
2321 fn winit_cursor(cursor: Cursor) -> CursorIcon {
2322 match cursor {
2323 Cursor::Default => CursorIcon::Default,
2324 Cursor::Pointer => CursorIcon::Pointer,
2325 Cursor::Text => CursorIcon::Text,
2326 Cursor::NotAllowed => CursorIcon::NotAllowed,
2327 Cursor::Grab => CursorIcon::Grab,
2328 Cursor::Grabbing => CursorIcon::Grabbing,
2329 Cursor::Move => CursorIcon::Move,
2330 Cursor::EwResize => CursorIcon::EwResize,
2331 Cursor::NsResize => CursorIcon::NsResize,
2332 Cursor::NwseResize => CursorIcon::NwseResize,
2333 Cursor::NeswResize => CursorIcon::NeswResize,
2334 Cursor::ColResize => CursorIcon::ColResize,
2335 Cursor::RowResize => CursorIcon::RowResize,
2336 Cursor::Crosshair => CursorIcon::Crosshair,
2337 _ => CursorIcon::Default,
2338 }
2339 }
2340
2341 /// Clear color for the canvas: the background token converted into the
2342 /// working space, exactly like every painted fill. The web host doesn't
2343 /// negotiate color management — it composites in the default sRGB-linear
2344 /// working space (the host env pins `DEFAULT_WORKING_COLOR_SPACE`), so
2345 /// [`damascene_core::paint::rgba_f32`] is the matching conversion
2346 /// (issue #45).
2347 fn bg_color(palette: &Palette) -> wgpu::Color {
2348 let [r, g, b, a] = damascene_core::paint::rgba_f32(palette.background);
2349 wgpu::Color {
2350 r: r as f64,
2351 g: g as f64,
2352 b: b as f64,
2353 a: a as f64,
2354 }
2355 }
2356
2357 fn copy_current_selection(renderer: &Runner, write_text: impl FnOnce(String)) {
2358 // Read the selection out of `last_tree` (via the runtime
2359 // helper) — see `RunnerCore::selected_text` for why a
2360 // build-only path would miss selections inside a virtual
2361 // list.
2362 let Some(text) = renderer.selected_text() else {
2363 return;
2364 };
2365 write_text(text);
2366 }
2367
2368 fn write_clipboard_text(text: String) {
2369 let Some(window) = web_sys::window() else {
2370 log::warn!("damascene-web: no window; clipboard write dropped");
2371 return;
2372 };
2373 let promise = window.navigator().clipboard().write_text(&text);
2374 wasm_bindgen_futures::spawn_local(async move {
2375 if let Err(err) = wasm_bindgen_futures::JsFuture::from(promise).await {
2376 log::warn!("damascene-web: clipboard writeText failed: {err:?}");
2377 }
2378 });
2379 }
2380
2381 fn attach_primary_selection_text(mut event: UiEvent, primary_selection: &str) -> UiEvent {
2382 if event.kind == UiEventKind::MiddleClick && !primary_selection.is_empty() {
2383 event.text = Some(primary_selection.to_string());
2384 }
2385 event
2386 }
2387
2388 fn dispatch_app_event<A: App>(
2389 app: &mut A,
2390 event: UiEvent,
2391 renderer: &Runner,
2392 primary_selection: &mut String,
2393 ) {
2394 let before = app.selection();
2395 let cx = damascene_core::EventCx::new().with_ui_state(renderer.ui_state());
2396 app.on_event(event, &cx);
2397 if app.selection() != before {
2398 // Resolve the post-event selection against `last_tree`.
2399 // The new selection's keys are typically the row the user
2400 // just clicked, which is present in the previous frame's
2401 // snapshot.
2402 *primary_selection = renderer
2403 .selected_text_for(&app.selection())
2404 .filter(|text| !text.is_empty())
2405 .unwrap_or_default();
2406 }
2407 }
2408
2409 fn dispatch_app_wheel_event<A: App>(
2410 app: &mut A,
2411 event: UiEvent,
2412 renderer: &Runner,
2413 primary_selection: &mut String,
2414 ) -> bool {
2415 let before = app.selection();
2416 let cx = damascene_core::EventCx::new().with_ui_state(renderer.ui_state());
2417 let consumed = app.on_wheel_event(event, &cx);
2418 if app.selection() != before {
2419 *primary_selection = renderer
2420 .selected_text_for(&app.selection())
2421 .filter(|text| !text.is_empty())
2422 .unwrap_or_default();
2423 }
2424 consumed
2425 }
2426
2427 fn drain_pending_clipboard_text<A: App>(
2428 app: &mut A,
2429 renderer: &mut Runner,
2430 pending_text: &Rc<RefCell<VecDeque<String>>>,
2431 primary_selection: &mut String,
2432 ) -> bool {
2433 let mut drained = false;
2434 while let Some(text) = pending_text.borrow_mut().pop_front() {
2435 let Some(event) = renderer.text_input(text.clone()) else {
2436 continue;
2437 };
2438 drained = true;
2439 let event = clipboard::paste_text_event(event, text);
2440 dispatch_app_event(app, event, renderer, primary_selection);
2441 }
2442 drained
2443 }
2444}