git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

src/web/shell.rs (37.2K)

  1 //! The browser shell: an [`Application`] run on a `<canvas>`, the way the
  2 //! Wayland shell runs one on a surface. It is the third piece of the run
  3 //! loop over the shared two — [`Driver`] routes what the page's events mean,
  4 //! [`Pacer::turn`] decides what a turn does — and what is left here is the
  5 //! page's side of both:
  6 //!
  7 //! - **Events in.** Pointer, wheel, key and focus events on the canvas,
  8 //!   mapped into driver calls in cce-ui's terms (`map_key`, `wheel_frame`).
  9 //!   A page has no grabs, so a press on what would be a CSD border is the
 10 //!   app's ([`PressSite::can_grab`] false).
 11 //! - **Turns.** One turn per animation frame while anything moves (the
 12 //!   pacer's ACTIVE cadence), a timer while idle; any event, and any message
 13 //!   on the app's [`AppSender`](crate::engine::AppSender), wakes the loop
 14 //!   for the next frame. Presenting happens inside an animation-frame
 15 //!   callback, which is the browser's own frame pacing — there is no
 16 //!   outstanding frame to wait on ([`Shell::frame_pending`] is false).
 17 //! - **Frames out.** [`build_frame`] at the canvas's CSS size and the page's
 18 //!   `devicePixelRatio`, drawn by the [`WebRenderer`].
 19 //!
 20 //! **The keyboard is a hidden `<textarea>`'s**, not the canvas's: a page can
 21 //! compose input-method text (Japanese, Chinese, Korean, a dead key, an
 22 //! emoji panel) only into an editable element. It takes the focus a press
 23 //! on the canvas would have given the canvas (and the canvas, focused some
 24 //! other way, hands it over), its key events are the app's as the canvas's
 25 //! were, and its composition is the input method's: a key the input method
 26 //! takes (`Process`, keyCode 229, or one sent mid-composition) is left to
 27 //! it, `input` events while composing are the composition
 28 //! (`Driver::preedit`), `compositionend` its commit (`Driver::commit_text`),
 29 //! and text that arrives with no composition (an emoji panel, dictation) is
 30 //! committed as it comes. After each frame it is moved to the editing
 31 //! widget's caret (`ime::caret`), where the input method puts its
 32 //! candidates. A widget that drops a composition (`ime::take_reset`) has it
 33 //! cancelled by taking the focus off the textarea and back, inside the turn,
 34 //! where the events that raises are not the app's.
 35 //!
 36 //! **The clipboard** comes through the page's clipboard events, since a page
 37 //! may read the clipboard only inside a `paste` event: a ⌘/Ctrl+V is held
 38 //! back from the app until its `paste` event has handed over the text (or,
 39 //! if none comes, until the task after), so the widget that pastes on it
 40 //! reads that text (`widget::clipboard`). A ⌘/Ctrl+C or X reaches the app at
 41 //! once, and the `copy` / `cut` event it raises carries whatever the app
 42 //! copied. The three keys' defaults are the only ones the canvas lets the
 43 //! page have.
 44 //!
 45 //! The page owns the canvas's place in it; [`Sizing`] says who owns its
 46 //! size. There is no context-menu popup surface here: the menu is drawn in
 47 //! the canvas and kept inside it (`context_menu::constrain_to`), as on a
 48 //! layer surface.
 49 
 50 use std::cell::{Cell, RefCell};
 51 use std::rc::Rc;
 52 use std::sync::mpsc::Receiver;
 53 use std::time::Duration;
 54 
 55 use cursor_icon::CursorIcon;
 56 use wasm_bindgen::prelude::*;
 57 use wasm_bindgen::JsCast;
 58 use web_sys::{
 59     AddEventListenerOptions, ClipboardEvent, CompositionEvent, FocusEvent, HtmlCanvasElement, HtmlTextAreaElement, InputEvent,
 60     KeyboardEvent, PointerEvent, WheelEvent,
 61 };
 62 
 63 use super::renderer::{Capture, WebRenderer};
 64 use crate::backend::app::{set_wake, AppSender, Application, LogicalPosition, LogicalSize};
 65 use crate::backend::dom::{clipboard_key, map_key, utf16_range_to_bytes, wheel_frame, ClipKey};
 66 use crate::backend::driver::{Driver, Modifiers, PressSite, ScrollFrame, ScrollSource, Turn};
 67 use crate::backend::frame::build_frame;
 68 use crate::backend::shell::{Pacer, Shell, Step, ACTIVE_DISPATCH};
 69 use crate::widget::{clipboard, context_menu, ElementState, Key, MouseButton};
 70 use crate::backend::text::DlText;
 71 
 72 /// Who decides the canvas's size.
 73 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 74 pub enum Sizing {
 75     /// The app does, as it sizes a window: the canvas is given the app's
 76     /// `WindowSettings` size at start and its `desired_size` after (CSS px).
 77     App,
 78     /// The page does: the canvas is wherever its CSS box puts it, the app's
 79     /// size requests are not honoured (a tiling compositor's answer), and a
 80     /// change of the box is a resize.
 81     Page,
 82 }
 83 
 84 /// The fonts an app is run with: a page has no font directory and no
 85 /// fontconfig, so it says both what there is and what the generic families
 86 /// are. A generic left `None` is the first family the set has of a list of
 87 /// well-known ones (DejaVu, Noto, FreeFont, …).
 88 #[derive(Debug, Clone, Default)]
 89 pub struct Fonts {
 90     /// Font files' bytes (TrueType, OpenType, collections), in the order a
 91     /// directory scan would find them: where text falls back to the first
 92     /// face holding a glyph, order decides it.
 93     pub files: Vec<Vec<u8>>,
 94     pub serif: Option<String>,
 95     pub sans_serif: Option<String>,
 96     pub monospace: Option<String>,
 97 }
 98 
 99 impl Fonts {
100     pub fn new(files: Vec<Vec<u8>>) -> Self {
101         Self { files, ..Default::default() }
102     }
103 }
104 
105 /// Run `A` on `canvas`, its text set in `fonts`. Returns once the app is
106 /// up; the loop runs on the page's callbacks from then on, until the app
107 /// asks to exit.
108 ///
109 /// One app per page: the toolkit's context menu is one per thread, and so is
110 /// [`set_wake`].
111 pub async fn run<A: Application>(canvas: HtmlCanvasElement, fonts: Fonts, sizing: Sizing) -> Result<(), JsValue> {
112     // Every font database the toolkit builds from here on — this one, its
113     // own for widget geometry, and the measuring one — loads the page's.
114     crate::page_fonts::provide(fonts.files, fonts.serif, fonts.sans_serif, fonts.monospace);
115     // A page has no environment: its locale is the browser's, and it must be
116     // decided before the first font system is built from it.
117     if let Some(lang) = web_sys::window().and_then(|w| w.navigator().language()) {
118         crate::locale::set_locale(&lang);
119     }
120     // The page's window's interaction state (`crate::window_state`). The loop runs on the
121     // page's callbacks after this returns, so the state is entered for the page's life.
122     std::mem::forget(crate::window_state::enter(&crate::window_state::WindowState::new()));
123     let fs = crate::create_font_system();
124 
125     let mut renderer = WebRenderer::new(canvas.clone()).await?;
126     // The scale is known before the app is built, as the Wayland shell sets
127     // it before `create`: a widget may read it as it is made.
128     let dpr = device_pixel_ratio();
129     crate::scale::set_scale_factor(dpr as f32);
130     let (tx, rx) = std::sync::mpsc::channel();
131     let mut app = A::create(AppSender::from(tx));
132     // The app's persistent GPU resources, as the Wayland shell's
133     // `renderer_init` makes them: once, before the first frame.
134     app.init_3d(&mut renderer);
135     let settings = app.settings();
136     crate::scale::set_app_id(settings.app_id.clone());
137     if let Some(doc) = web_sys::window().and_then(|w| w.document()) {
138         doc.set_title(&settings.title);
139     }
140 
141     let style = canvas.style();
142     if sizing == Sizing::App {
143         set_css_size(&canvas, settings.width, settings.height);
144     }
145     // The canvas can be focused (it hands the focus to the keyboard sink),
146     // draws no focus outline of its own, and keeps touches for the app
147     // rather than panning the page.
148     canvas.set_tab_index(0);
149     let _ = style.set_property("outline", "none");
150     let _ = style.set_property("touch-action", "none");
151     let sink = keyboard_sink()?;
152 
153     let shell = WebShell {
154         app,
155         driver: Driver::new(),
156         redraw: true,
157         exit: false,
158         rx,
159         renderer,
160         fs,
161         swash: cosmic_text::SwashCache::new(),
162         items: Vec::new(),
163         damage_owed: true,
164         canvas: canvas.clone(),
165         sizing,
166         logical: (0.0, 0.0),
167         scale: dpr,
168         just_configured: false,
169         cursor: CursorIcon::Default,
170         mac: is_mac(),
171         sink: sink.clone(),
172         sink_at: None,
173     };
174     let lp = Rc::new(Loop {
175         shell: RefCell::new(shell),
176         pacer: RefCell::new(Pacer::new(settings.title)),
177         sched: RefCell::new(Sched::default()),
178         frame_cb: RefCell::new(None),
179         timer_cb: RefCell::new(None),
180         finger_end_cb: RefCell::new(None),
181         finger_timer: Cell::new(None),
182         held_paste: RefCell::new(None),
183         paste_cb: RefCell::new(None),
184     });
185     lp.shell.borrow_mut().measure();
186     lp.shell.borrow_mut().just_configured = true;
187     Loop::install(&lp, &canvas, &sink)?;
188     lp.wake();
189     Ok(())
190 }
191 
192 thread_local! {
193     /// Callers of [`capture`] waiting on the next frame: each a promise's resolve.
194     static CAPTURES: RefCell<Vec<js_sys::Function>> = const { RefCell::new(Vec::new()) };
195 }
196 
197 /// The next frame the shell draws, read back from the GPU. A fresh frame is
198 /// asked for, as an input event would ask, and this resolves once it is
199 /// drawn. For screenshot tests: a headless browser compositing in software
200 /// leaves a WebGPU canvas out of its page screenshots (and `toDataURL`).
201 pub async fn capture() -> Result<Capture, JsValue> {
202     let promise = js_sys::Promise::new(&mut |resolve, _reject| CAPTURES.with(|c| c.borrow_mut().push(resolve)));
203     crate::backend::app::wake();
204     let got = wasm_bindgen_futures::JsFuture::from(promise).await?;
205     let got: js_sys::Array = got.dyn_into().map_err(|_| JsValue::from_str("cce-ui: the frame was not captured"))?;
206     Ok(Capture {
207         width: got.get(0).as_f64().unwrap_or(0.0) as u32,
208         height: got.get(1).as_f64().unwrap_or(0.0) as u32,
209         rgba: js_sys::Uint8Array::new(&got.get(2)).to_vec(),
210     })
211 }
212 
213 /// The page's side of the run loop, as the Wayland shell's `EngineState` is
214 /// the compositor's.
215 struct WebShell<A: Application> {
216     app: A,
217     driver: Driver,
218     redraw: bool,
219     exit: bool,
220     rx: Receiver<A::Message>,
221     renderer: WebRenderer,
222     fs: cosmic_text::FontSystem,
223     swash: cosmic_text::SwashCache,
224     /// The frame's display-list text, shaped by [`build_frame`].
225     items: Vec<DlText>,
226     damage_owed: bool,
227     canvas: HtmlCanvasElement,
228     sizing: Sizing,
229     /// The canvas's CSS size, and `devicePixelRatio`, as last measured.
230     logical: (f32, f32),
231     scale: f64,
232     just_configured: bool,
233     cursor: CursorIcon,
234     /// Command is the shortcut key here: ⌘Z is undo, as every Mac app has it.
235     mac: bool,
236     /// The hidden textarea that holds the keyboard (see the module doc), and
237     /// where it was last put (page px: left, top, height).
238     sink: HtmlTextAreaElement,
239     sink_at: Option<(f64, f64, f64)>,
240 }
241 
242 impl<A: Application> WebShell<A> {
243     /// Read the canvas's box and the pixel ratio, and size the drawing
244     /// buffer to them. Whether either changed.
245     fn measure(&mut self) -> bool {
246         let dpr = device_pixel_ratio();
247         let w = self.canvas.client_width().max(0) as f32;
248         let h = self.canvas.client_height().max(0) as f32;
249         if (w, h) == self.logical && dpr == self.scale {
250             return false;
251         }
252         self.logical = (w, h);
253         if dpr != self.scale {
254             self.scale = dpr;
255             crate::scale::set_scale_factor(dpr as f32);
256         }
257         let (pw, ph) = self.physical();
258         if self.renderer.size() != (pw, ph) {
259             self.renderer.resize(pw, ph);
260         }
261         self.redraw = true;
262         true
263     }
264 
265     fn physical(&self) -> (u32, u32) {
266         let s = self.scale as f32;
267         ((self.logical.0 * s).round().max(1.0) as u32, (self.logical.1 * s).round().max(1.0) as u32)
268     }
269 
270     fn size(&self) -> LogicalSize {
271         LogicalSize::new(self.logical.0, self.logical.1)
272     }
273 
274     /// Hand the messages posted since the last turn to `update`, as the
275     /// Wayland loop's channel source does between dispatches.
276     fn drain_messages(&mut self) {
277         while let Ok(msg) = self.rx.try_recv() {
278             let mut rebuild = false;
279             self.app.update(msg, &mut rebuild, &mut self.exit);
280             self.redraw |= rebuild;
281         }
282     }
283 
284     fn mods_from(&self, ctrl: bool, shift: bool, alt: bool, meta: bool) -> Modifiers {
285         if self.mac {
286             Modifiers { ctrl: ctrl || meta, shift, alt, logo: false }
287         } else {
288             Modifiers { ctrl, shift, alt, logo: meta }
289         }
290     }
291 
292     fn sync_mods(&mut self, mods: Modifiers) {
293         if mods != self.driver.mods {
294             self.driver.set_modifiers(&mut self.app, mods);
295         }
296     }
297 
298     fn pointer_pos(&mut self, e: &PointerEvent) -> LogicalPosition {
299         let (x, y) = (e.offset_x() as f32, e.offset_y() as f32);
300         self.driver.cursor_pos = (x, y);
301         LogicalPosition::new(x, y)
302     }
303 
304     /// Put the keyboard sink at the editing widget's caret (the canvas's
305     /// corner when nothing is editing), so the input method's candidates
306     /// open there; and cancel a composition a widget dropped. Inside the
307     /// turn: the shell is borrowed, so the blur and focus this raises (and
308     /// the composition's end) reach no handler.
309     fn place_sink(&mut self) {
310         let r = self.canvas.get_bounding_client_rect();
311         let (left, top, h) = match crate::ime::caret() {
312             Some([x, y, _, h]) => (r.left() + x as f64, r.top() + y as f64, (h as f64).max(8.0)),
313             None => (r.left(), r.top(), 16.0),
314         };
315         if self.sink_at != Some((left, top, h)) {
316             self.sink_at = Some((left, top, h));
317             let st = self.sink.style();
318             let _ = st.set_property("left", &format!("{left}px"));
319             let _ = st.set_property("top", &format!("{top}px"));
320             let _ = st.set_property("height", &format!("{h}px"));
321             let _ = st.set_property("font-size", &format!("{}px", (h * 0.8).round()));
322             let _ = st.set_property("line-height", &format!("{h}px"));
323         }
324         if crate::ime::take_reset() {
325             let focused = web_sys::window()
326                 .and_then(|w| w.document())
327                 .and_then(|d| d.active_element())
328                 .is_some_and(|a| a == *self.sink.unchecked_ref::<web_sys::Element>());
329             self.sink.set_value("");
330             if focused {
331                 let _ = self.sink.blur();
332                 let _ = self.sink.focus();
333             }
334         }
335     }
336 
337     fn update_cursor(&mut self) {
338         let (x, y) = self.driver.cursor_pos;
339         let icon = self.driver.cursor_icon_at(&self.app, x, y, self.size());
340         if icon != self.cursor {
341             self.cursor = icon;
342             let _ = self.canvas.style().set_property("cursor", icon.name());
343         }
344     }
345 }
346 
347 impl<A: Application> Shell for WebShell<A> {
348     type App = A;
349 
350     fn turn(&mut self) -> (&mut Driver, Turn<'_, A>) {
351         (&mut self.driver, Turn { app: &mut self.app, redraw: &mut self.redraw, exit: &mut self.exit })
352     }
353 
354     fn app(&self) -> &A {
355         &self.app
356     }
357 
358     fn redraw(&mut self) -> &mut bool {
359         &mut self.redraw
360     }
361 
362     fn exit_requested(&self) -> bool {
363         self.exit
364     }
365 
366     fn take_just_configured(&mut self) -> bool {
367         std::mem::replace(&mut self.just_configured, false)
368     }
369 
370     fn request_size(&mut self, w: u32, h: u32) {
371         if self.sizing == Sizing::App && (w as f32, h as f32) != self.logical {
372             set_css_size(&self.canvas, w, h);
373             self.measure();
374         }
375     }
376 
377     fn sync(&mut self) {
378         // The page laid the canvas out anew, or moved it to another
379         // display: a configure, which wins the next turn over the app's size.
380         if self.measure() {
381             self.just_configured = true;
382         }
383         if CAPTURES.with(|c| !c.borrow().is_empty()) {
384             self.redraw = true;
385         }
386         // No popup surface to host the menu: it is drawn in the canvas and
387         // kept inside it, frames asked for while a page turn animates.
388         if context_menu::is_visible() {
389             if context_menu::is_turning() {
390                 self.redraw = true;
391             }
392             context_menu::set_hosted(false);
393             context_menu::constrain_to(0.0, 0.0, self.logical.0, self.logical.1);
394         }
395     }
396 
397     fn set_title(&mut self, title: &str) {
398         if let Some(doc) = web_sys::window().and_then(|w| w.document()) {
399             doc.set_title(title);
400         }
401     }
402 
403     fn frame_pending(&mut self) -> bool {
404         false
405     }
406 
407     fn configured(&self) -> bool {
408         self.logical.0 > 0.0 && self.logical.1 > 0.0
409     }
410 
411     fn present(&mut self, _fresh: bool) {
412         let size = self.size();
413         let frame = build_frame(&mut self.app, &mut self.fs, size, self.scale, &mut self.damage_owed, &mut self.items);
414         if frame.dl_text {
415             let spans = frame.text_spans(&self.items);
416             self.renderer.prepare_text(&mut self.fs, &mut self.swash, &spans);
417         }
418         // The app's 3D scene, staged last, as the Wayland shell's
419         // `stage_renderer`; true asks for another frame.
420         if self.app.stage_3d(&mut self.renderer, size, self.scale) {
421             self.redraw = true;
422         }
423         let waiting = CAPTURES.with(|c| std::mem::take(&mut *c.borrow_mut()));
424         if !waiting.is_empty() {
425             self.renderer.capture_next_frame();
426         }
427         match self.renderer.draw_frame_2d(frame.frame2d()) {
428             Ok(()) => self.damage_owed = false,
429             Err(e) => {
430                 web_sys::console::error_2(&"cce-ui: frame not drawn:".into(), &e);
431                 self.redraw = true;
432             }
433         }
434         self.place_sink();
435         if waiting.is_empty() {
436             return;
437         }
438         let pending = self.renderer.take_pending_capture();
439         wasm_bindgen_futures::spawn_local(async move {
440             let got = match pending {
441                 Some(p) => p.read().await.ok(),
442                 None => None,
443             };
444             let value: JsValue = match got {
445                 Some(c) => js_sys::Array::of3(&c.width.into(), &c.height.into(), &js_sys::Uint8Array::from(&c.rgba[..])).into(),
446                 None => JsValue::NULL,
447             };
448             for resolve in waiting {
449                 let _ = resolve.call1(&JsValue::NULL, &value);
450             }
451         });
452     }
453 }
454 
455 /// When the next turn is due.
456 #[derive(Default)]
457 struct Sched {
458     /// An animation frame is requested: the next turn is in it.
459     frame: Option<i32>,
460     /// An idle timer is set; it requests the frame when it fires.
461     timer: Option<i32>,
462     /// The app exited: nothing turns again.
463     stopped: bool,
464 }
465 
466 /// The shell, the pacer and the schedule, shared by the page's callbacks.
467 /// The schedule is its own cell, borrowed only briefly and never across a
468 /// call into the app, so a message the app sends mid-turn can wake the loop.
469 struct Loop<A: Application> {
470     shell: RefCell<WebShell<A>>,
471     pacer: RefCell<Pacer>,
472     sched: RefCell<Sched>,
473     frame_cb: RefCell<Option<Closure<dyn FnMut(f64)>>>,
474     timer_cb: RefCell<Option<Closure<dyn FnMut()>>>,
475     finger_end_cb: RefCell<Option<Closure<dyn FnMut()>>>,
476     /// The lift timer the last finger frame set; the next frame cancels it.
477     finger_timer: Cell<Option<i32>>,
478     /// A ⌘/Ctrl+V held back until its `paste` event, and the timer that
479     /// lets it through if no event comes.
480     held_paste: RefCell<Option<HeldKey>>,
481     paste_cb: RefCell<Option<Closure<dyn FnMut()>>>,
482 }
483 
484 /// A key press, kept to dispatch later: the key, its text and the
485 /// modifiers it came with (ctrl, shift, alt, meta).
486 struct HeldKey {
487     key: Key,
488     text: Option<String>,
489     mods: (bool, bool, bool, bool),
490 }
491 
492 /// A browser reports no lift for a two-finger scroll: this long without a
493 /// finger frame is one, and ends the gesture (`ScrollPhase::FingerEnd`) so a
494 /// flick coasts and a side swipe readies its next turn.
495 const FINGER_LIFT: Duration = Duration::from_millis(120);
496 
497 impl<A: Application> Loop<A> {
498     /// Turn the loop at the next animation frame.
499     fn wake(&self) {
500         let mut s = self.sched.borrow_mut();
501         if s.stopped || s.frame.is_some() {
502             return;
503         }
504         let win = window();
505         if let Some(t) = s.timer.take() {
506             win.clear_timeout_with_handle(t);
507         }
508         let cb = self.frame_cb.borrow();
509         if let Some(cb) = cb.as_ref() {
510             s.frame = win.request_animation_frame(cb.as_ref().unchecked_ref()).ok();
511         }
512     }
513 
514     /// One turn, in an animation frame.
515     fn on_frame(&self) {
516         self.sched.borrow_mut().frame = None;
517         let step = {
518             let mut shell = self.shell.borrow_mut();
519             shell.drain_messages();
520             self.pacer.borrow_mut().turn(&mut *shell)
521         };
522         match step {
523             Step::Exit => {
524                 self.sched.borrow_mut().stopped = true;
525                 set_wake(None);
526                 self.shell.borrow_mut().app.on_exit();
527             }
528             Step::Sleep(d) if d <= ACTIVE_DISPATCH => self.wake(),
529             Step::Sleep(d) => {
530                 let mut s = self.sched.borrow_mut();
531                 let cb = self.timer_cb.borrow();
532                 // A frame already requested (the app posted itself a message
533                 // mid-turn) turns sooner than any timer would.
534                 if let (None, None, Some(cb)) = (s.frame, s.timer, cb.as_ref()) {
535                     s.timer = window()
536                         .set_timeout_with_callback_and_timeout_and_arguments_0(cb.as_ref().unchecked_ref(), d.as_millis() as i32)
537                         .ok();
538                 }
539             }
540         }
541     }
542 
543     /// Dispatch an event to the shell, then turn soon. Input after the exit
544     /// is dropped.
545     fn event(&self, f: impl FnOnce(&mut WebShell<A>)) {
546         if self.sched.borrow().stopped {
547             return;
548         }
549         // A page event fired from inside a turn (a focus change the app's
550         // own DOM call caused) finds the shell borrowed: it is dropped
551         // rather than panicking the page.
552         let Ok(mut shell) = self.shell.try_borrow_mut() else { return };
553         f(&mut shell);
554         drop(shell);
555         self.wake();
556     }
557 
558     /// The callbacks: the loop's own two, the wake hook, and the canvas's events.
559     fn install(lp: &Rc<Self>, canvas: &HtmlCanvasElement, sink: &HtmlTextAreaElement) -> Result<(), JsValue> {
560         let l = lp.clone();
561         *lp.frame_cb.borrow_mut() = Some(Closure::new(move |_t: f64| l.on_frame()));
562         let l = lp.clone();
563         *lp.timer_cb.borrow_mut() = Some(Closure::new(move || {
564             l.sched.borrow_mut().timer = None;
565             l.wake();
566         }));
567         let l = lp.clone();
568         *lp.finger_end_cb.borrow_mut() = Some(Closure::new(move || l.finger_lift()));
569         let l = lp.clone();
570         *lp.paste_cb.borrow_mut() = Some(Closure::new(move || l.release_paste()));
571         let l = Rc::downgrade(lp);
572         set_wake(Some(Box::new(move || {
573             if let Some(l) = l.upgrade() {
574                 l.wake();
575             }
576         })));
577 
578         let target: &web_sys::EventTarget = canvas.as_ref();
579         let l = lp.clone();
580         listen(target, "pointermove", false, move |e: PointerEvent| {
581             l.event(|s| {
582                 let pos = s.pointer_pos(&e);
583                 let (driver, t) = s.turn();
584                 driver.pointer_motion(t, pos);
585                 s.update_cursor();
586             })
587         })?;
588         let l = lp.clone();
589         listen(target, "pointerenter", false, move |e: PointerEvent| {
590             l.event(|s| {
591                 let pos = s.pointer_pos(&e);
592                 let (driver, t) = s.turn();
593                 driver.pointer_enter(t, pos);
594                 s.update_cursor();
595             })
596         })?;
597         let l = lp.clone();
598         listen(target, "pointerleave", false, move |_e: PointerEvent| {
599             l.event(|s| {
600                 let (driver, t) = s.turn();
601                 driver.pointer_leave(t);
602             })
603         })?;
604         let l = lp.clone();
605         let c = canvas.clone();
606         let k = sink.clone();
607         listen(target, "pointerdown", true, move |e: PointerEvent| {
608             let Some(btn) = dom_button(e.button()) else { return };
609             // Take the keyboard first: the focus event this fires is
610             // dispatched now, before the shell is borrowed below. Capture
611             // keeps a drag's moves and its release coming to the canvas when
612             // the pointer leaves it, as a Wayland implicit grab does.
613             let _ = k.focus();
614             let _ = c.set_pointer_capture(e.pointer_id());
615             e.prevent_default();
616             l.event(|s| {
617                 let mods = s.mods_from(e.ctrl_key(), e.shift_key(), e.alt_key(), e.meta_key());
618                 s.sync_mods(mods);
619                 let pos = s.pointer_pos(&e);
620                 let site = PressSite { size: s.size(), on_popup: false, can_grab: false, own_edges: false };
621                 let (driver, t) = s.turn();
622                 driver.pointer_press(t, btn, pos, site);
623             })
624         })?;
625         let l = lp.clone();
626         listen(target, "pointerup", false, move |e: PointerEvent| {
627             let Some(btn) = dom_button(e.button()) else { return };
628             l.event(|s| {
629                 let pos = s.pointer_pos(&e);
630                 let (driver, t) = s.turn();
631                 driver.pointer_release(t, btn, pos);
632                 s.update_cursor();
633             })
634         })?;
635         // The right button is the app's, not the page's menu.
636         listen(target, "contextmenu", true, |e: web_sys::Event| e.prevent_default())?;
637         let l = lp.clone();
638         listen(target, "wheel", true, move |e: WheelEvent| {
639             e.prevent_default();
640             let frame = wheel_frame(e.delta_mode(), e.delta_x(), e.delta_y());
641             l.event(|s| {
642                 let mods = s.mods_from(e.ctrl_key(), e.shift_key(), e.alt_key(), e.meta_key());
643                 s.sync_mods(mods);
644                 let pos = LogicalPosition::new(e.offset_x() as f32, e.offset_y() as f32);
645                 s.driver.cursor_pos = (pos.x, pos.y);
646                 let (driver, t) = s.turn();
647                 driver.scroll(t, frame, pos);
648             });
649             if frame.source == Some(ScrollSource::Finger) {
650                 l.arm_finger_lift();
651             }
652         })?;
653         // The canvas focused some other way (Tab, the page's script) hands
654         // the keyboard to the sink.
655         let k = sink.clone();
656         listen(target, "focus", false, move |_e: FocusEvent| {
657             let _ = k.focus();
658         })?;
659         let keys: &web_sys::EventTarget = sink.as_ref();
660         let l = lp.clone();
661         listen(keys, "keydown", true, move |e: KeyboardEvent| l.key(&e, ElementState::Pressed))?;
662         let l = lp.clone();
663         listen(keys, "keyup", true, move |e: KeyboardEvent| l.key(&e, ElementState::Released))?;
664         for (name, focused) in [("focus", true), ("blur", false)] {
665             let l = lp.clone();
666             let c = canvas.clone();
667             listen(keys, name, false, move |e: FocusEvent| {
668                 // Over to the canvas is on its way back.
669                 let to_canvas = e.related_target().is_some_and(|r| AsRef::<JsValue>::as_ref(&r) == AsRef::<JsValue>::as_ref(&c));
670                 if !focused && to_canvas {
671                     return;
672                 }
673                 l.event(|s| {
674                     let (driver, t) = s.turn();
675                     driver.keyboard_focus(t, focused);
676                 })
677             })?;
678         }
679         // The input method's half.
680         let l = lp.clone();
681         let k = sink.clone();
682         listen(keys, "input", false, move |e: InputEvent| {
683             if e.is_composing() {
684                 let text = k.value();
685                 let cursor = utf16_range_to_bytes(&text, k.selection_start().ok().flatten(), k.selection_end().ok().flatten());
686                 l.event(|s| {
687                     let (driver, t) = s.turn();
688                     driver.preedit(t, Some(crate::ime::Preedit { text, cursor }));
689                 });
690             } else if e.input_type() == "insertFromPaste" {
691                 // A paste that reached the sink by the browser's default (a
692                 // handler of the page's swallowed its `paste` event): the
693                 // clipboard's text all the same, for the held ⌘/Ctrl+V.
694                 clipboard::pasted(k.value());
695                 k.set_value("");
696                 l.release_paste();
697             } else {
698                 // Text with no composition: an emoji panel, dictation.
699                 let text = k.value();
700                 k.set_value("");
701                 if !text.is_empty() {
702                     l.event(|s| {
703                         let (driver, t) = s.turn();
704                         driver.commit_text(t, text);
705                     });
706                 }
707             }
708         })?;
709         let l = lp.clone();
710         let k = sink.clone();
711         listen(keys, "compositionend", false, move |e: CompositionEvent| {
712             let text = e.data().unwrap_or_default();
713             k.set_value("");
714             l.event(|s| {
715                 let (driver, t) = s.turn();
716                 driver.preedit(t, None);
717                 let (driver, t) = s.turn();
718                 driver.commit_text(t, text);
719             });
720         })?;
721         // The clipboard's events, raised by the three keys `key` lets
722         // through. They go to the focused element or the body, so they are
723         // heard on the document.
724         if let Some(doc) = web_sys::window().and_then(|w| w.document()) {
725             let doc: &web_sys::EventTarget = doc.as_ref();
726             let l = lp.clone();
727             listen(doc, "paste", true, move |e: ClipboardEvent| {
728                 if let Some(text) = e.clipboard_data().and_then(|d| d.get_data("text/plain").ok()) {
729                     clipboard::pasted(text);
730                 }
731                 e.prevent_default();
732                 l.release_paste();
733             })?;
734             for name in ["copy", "cut"] {
735                 listen(doc, name, true, move |e: ClipboardEvent| {
736                     if let (Some(text), Some(data)) = (clipboard::take_copied(), e.clipboard_data()) {
737                         if data.set_data("text/plain", &text).is_ok() {
738                             e.prevent_default();
739                         }
740                     }
741                 })?;
742             }
743         }
744         // The page laying the canvas out anew is a configure; the turn it
745         // wakes measures the box (`sync`).
746         let l = lp.clone();
747         let observer = Closure::<dyn FnMut()>::new(move || l.wake());
748         let ro = web_sys::ResizeObserver::new(observer.as_ref().unchecked_ref())?;
749         ro.observe(canvas);
750         observer.forget();
751         std::mem::forget(ro);
752         Ok(())
753     }
754 
755     fn key(&self, e: &KeyboardEvent, state: ElementState) {
756         // The input method's key: it composes with it (and a browser that
757         // sends the key confirming a composition after its end still marks
758         // it 229).
759         if e.is_composing() || e.key_code() == 229 {
760             return;
761         }
762         let accel = e.ctrl_key() || e.meta_key();
763         let Some((key, text)) = map_key(&e.key(), accel) else { return };
764         let clip = clipboard_key(&e.key(), accel, e.alt_key());
765         // A clipboard key's default is its clipboard event: the page keeps it.
766         if clip.is_none() && !passes_to_page(e) {
767             e.prevent_default();
768         }
769         // The driver repeats a held key itself, at the toolkit's own rate
770         // (`KEY_REPEAT_DELAY` / `_INTERVAL`), as it does on Wayland, where
771         // the compositor sends one press: the browser's repeats are dropped.
772         if e.repeat() {
773             return;
774         }
775         let mods = (e.ctrl_key(), e.shift_key(), e.alt_key(), e.meta_key());
776         if state == ElementState::Pressed {
777             match clip {
778                 // Held until the `paste` event has handed over the text: it
779                 // is raised after this listener returns, in the same task,
780                 // so a zero timer is the fallback for a browser that raises
781                 // none (the clipboard then reads what it read before).
782                 Some(ClipKey::Paste) => {
783                     self.release_paste();
784                     *self.held_paste.borrow_mut() = Some(HeldKey { key, text, mods });
785                     if let Some(cb) = self.paste_cb.borrow().as_ref() {
786                         let _ = window().set_timeout_with_callback_and_timeout_and_arguments_0(cb.as_ref().unchecked_ref(), 0);
787                     }
788                     return;
789                 }
790                 // A copy left over from a menu click is not this key's.
791                 Some(ClipKey::Copy | ClipKey::Cut) => {
792                     clipboard::take_copied();
793                 }
794                 None => {}
795             }
796         } else {
797             // A release never overtakes the press it ends.
798             self.release_paste();
799         }
800         self.dispatch_key(HeldKey { key, text, mods }, state);
801     }
802 
803     fn dispatch_key(&self, k: HeldKey, state: ElementState) {
804         let (ctrl, shift, alt, meta) = k.mods;
805         self.event(|s| {
806             let mods = s.mods_from(ctrl, shift, alt, meta);
807             s.sync_mods(mods);
808             let (driver, t) = s.turn();
809             driver.key(t, k.key, k.text, state);
810         });
811     }
812 
813     /// Hand a held ⌘/Ctrl+V to the app, if one is held: its `paste` event
814     /// has come, or will not.
815     fn release_paste(&self) {
816         let held = self.held_paste.borrow_mut().take();
817         if let Some(k) = held {
818             self.dispatch_key(k, ElementState::Pressed);
819         }
820     }
821 
822     fn arm_finger_lift(&self) {
823         let win = window();
824         if let Some(t) = self.finger_timer.take() {
825             win.clear_timeout_with_handle(t);
826         }
827         if let Some(cb) = self.finger_end_cb.borrow().as_ref() {
828             self.finger_timer.set(
829                 win.set_timeout_with_callback_and_timeout_and_arguments_0(
830                     cb.as_ref().unchecked_ref(),
831                     FINGER_LIFT.as_millis() as i32,
832                 )
833                 .ok(),
834             );
835         }
836     }
837 
838     /// No finger frame for [`FINGER_LIFT`]: the gesture ended.
839     fn finger_lift(&self) {
840         self.finger_timer.set(None);
841         let frame = ScrollFrame { source: Some(ScrollSource::Finger), stop: true, ..Default::default() };
842         self.event(|s| {
843             let (x, y) = s.driver.cursor_pos;
844             let (driver, t) = s.turn();
845             driver.scroll(t, frame, LogicalPosition::new(x, y));
846         });
847     }
848 }
849 
850 /// The hidden textarea the keyboard goes to (see the module doc): fixed in
851 /// the page, invisible, never hit, and no help offered on what is typed.
852 fn keyboard_sink() -> Result<HtmlTextAreaElement, JsValue> {
853     let doc = window().document().ok_or_else(|| JsValue::from_str("cce-ui: no document"))?;
854     let sink: HtmlTextAreaElement = doc.create_element("textarea")?.dyn_into()?;
855     for (k, v) in [("autocomplete", "off"), ("autocorrect", "off"), ("autocapitalize", "off"), ("spellcheck", "false"), ("aria-hidden", "true")] {
856         sink.set_attribute(k, v)?;
857     }
858     let st = sink.style();
859     for (k, v) in [
860         ("position", "fixed"),
861         ("left", "0px"),
862         ("top", "0px"),
863         ("width", "1px"),
864         ("height", "16px"),
865         ("padding", "0"),
866         ("border", "0"),
867         ("margin", "0"),
868         ("outline", "none"),
869         ("resize", "none"),
870         ("overflow", "hidden"),
871         ("white-space", "pre"),
872         ("opacity", "0"),
873         ("pointer-events", "none"),
874         ("caret-color", "transparent"),
875         ("color", "transparent"),
876         ("background", "transparent"),
877     ] {
878         st.set_property(k, v)?;
879     }
880     doc.body().ok_or_else(|| JsValue::from_str("cce-ui: no body"))?.append_child(&sink)?;
881     Ok(sink)
882 }
883 
884 fn window() -> web_sys::Window {
885     web_sys::window().expect("cce-ui's browser shell runs in a window")
886 }
887 
888 fn device_pixel_ratio() -> f64 {
889     web_sys::window().map_or(1.0, |w| w.device_pixel_ratio())
890 }
891 
892 fn is_mac() -> bool {
893     web_sys::window()
894         .and_then(|w| w.navigator().platform().ok())
895         .is_some_and(|p| p.starts_with("Mac") || p.starts_with("iP"))
896 }
897 
898 fn set_css_size(canvas: &HtmlCanvasElement, w: u32, h: u32) {
899     let style = canvas.style();
900     let _ = style.set_property("width", &format!("{w}px"));
901     let _ = style.set_property("height", &format!("{h}px"));
902 }
903 
904 /// Add `f` as the target's `name` listener for the page's lifetime. A
905 /// `cancelable` one is added `passive: false`, so it may cancel the event's
906 /// default (the page's scroll, menu, focus move or shortcut).
907 fn listen<E: JsCast + 'static>(
908     target: &web_sys::EventTarget,
909     name: &str,
910     cancelable: bool,
911     mut f: impl FnMut(E) + 'static,
912 ) -> Result<(), JsValue> {
913     let cb = Closure::<dyn FnMut(web_sys::Event)>::new(move |e: web_sys::Event| f(e.unchecked_into()));
914     let opts = AddEventListenerOptions::new();
915     opts.set_passive(!cancelable);
916     target.add_event_listener_with_callback_and_add_event_listener_options(name, cb.as_ref().unchecked_ref(), &opts)?;
917     cb.forget();
918     Ok(())
919 }
920 
921 /// A DOM `button` as the driver's. The back and forward buttons are not
922 /// buttons the toolkit has.
923 fn dom_button(b: i16) -> Option<MouseButton> {
924     match b {
925         0 => Some(MouseButton::Left),
926         1 => Some(MouseButton::Middle),
927         2 => Some(MouseButton::Right),
928         _ => None,
929     }
930 }
931 
932 /// Keys the page keeps however the app routes keys: reload and the
933 /// developer tools.
934 fn passes_to_page(e: &KeyboardEvent) -> bool {
935     let k = e.key();
936     let accel = e.ctrl_key() || e.meta_key();
937     k == "F5" || k == "F12" || (accel && (k == "r" || k == "R")) || (accel && e.shift_key() && (k == "I" || k == "J"))
938 }