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 }