Skip to main content

nightshade_api/web/
viewport.rs

1//! A canvas surface that hands rendering to a web worker over an `OffscreenCanvas`,
2//! translating DOM pointer, wheel, touch, and resize events into a neutral event enum.
3
4use std::collections::HashMap;
5
6use crate::transfer::{attach, envelope_with};
7use crate::wire::{APP_KEY, CANVAS_KEY, MESSAGE_KEY, TouchPhase};
8use leptos::html;
9use leptos::prelude::*;
10use serde::Serialize;
11use wasm_bindgen::prelude::*;
12use wasm_bindgen::{JsCast, JsValue};
13use web_sys::{
14    HtmlCanvasElement, MessageEvent, MouseEvent, OffscreenCanvas, PointerEvent, ResizeObserver,
15    WheelEvent, Worker, WorkerOptions, WorkerType,
16};
17
18const CLICK_DRAG_THRESHOLD: f32 = 5.0;
19const MAX_RENDER_DPR: f64 = 2.0;
20
21type MessageClosure = Closure<dyn FnMut(MessageEvent)>;
22type WheelClosure = Closure<dyn FnMut(WheelEvent)>;
23type ResizeObservation = (ResizeObserver, Closure<dyn FnMut()>);
24
25/// A normalized input or layout event emitted by `Viewport`, with coordinates in
26/// physical (device-pixel) space, ready to forward to the render worker.
27#[derive(Clone, Copy, Debug, PartialEq)]
28pub enum ViewportEvent {
29    /// The surface was resized to these physical dimensions.
30    Resize {
31        /// New width in physical pixels.
32        width: f32,
33        /// New height in physical pixels.
34        height: f32,
35    },
36    /// The pointer moved to this physical position.
37    PointerMove {
38        /// X in physical pixels.
39        x: f32,
40        /// Y in physical pixels.
41        y: f32,
42    },
43    /// Raw relative motion while the pointer is locked to the canvas,
44    /// emitted in place of `PointerMove`.
45    PointerMotion {
46        /// Horizontal motion in physical pixels.
47        dx: f32,
48        /// Vertical motion in physical pixels.
49        dy: f32,
50    },
51    /// A mouse button changed state.
52    PointerButton {
53        /// The button index.
54        button: u8,
55        /// Whether the button is now pressed.
56        pressed: bool,
57    },
58    /// The wheel scrolled by this vertical delta.
59    Wheel {
60        /// Vertical scroll delta.
61        delta: f32,
62    },
63    /// A touch point changed state.
64    Touch {
65        /// Identifier of the touch point.
66        id: u64,
67        /// The phase of the touch.
68        phase: TouchPhase,
69        /// X in physical pixels.
70        x: f32,
71        /// Y in physical pixels.
72        y: f32,
73    },
74    /// A click or tap that did not drag, requesting a pick at this position.
75    Pick {
76        /// X in physical pixels.
77        x: f32,
78        /// Y in physical pixels.
79        y: f32,
80    },
81}
82
83/// A handle to the render worker for posting serde-serializable messages, with an
84/// optional transferable `OffscreenCanvas`.
85#[derive(Clone)]
86pub struct Bridge {
87    worker: Worker,
88}
89
90impl Bridge {
91    /// Serializes `message` and posts it to the worker under the message envelope key.
92    pub fn send<Message: Serialize>(&self, message: &Message) {
93        let envelope = js_sys::Object::new();
94        let value = serde_wasm_bindgen::to_value(message).unwrap_or(JsValue::NULL);
95        let _ = js_sys::Reflect::set(&envelope, &JsValue::from_str(MESSAGE_KEY), &value);
96        let _ = self.worker.post_message(&envelope);
97    }
98
99    /// Posts an application message to the worker under the app envelope
100    /// key, with named binary attachments whose buffers ride the transfer
101    /// list.
102    pub fn send_app<Message: Serialize>(
103        &self,
104        message: &Message,
105        attachments: &[(String, Vec<u8>)],
106    ) {
107        let Some(envelope) = envelope_with(APP_KEY, message) else {
108            return;
109        };
110        if attachments.is_empty() {
111            let _ = self.worker.post_message(&envelope);
112            return;
113        }
114        let transfer = js_sys::Array::new();
115        if !attach(&envelope, &transfer, attachments) {
116            return;
117        }
118        let _ = self.worker.post_message_with_transfer(&envelope, &transfer);
119    }
120
121    /// Posts `message` to the worker while transferring ownership of `canvas` so the
122    /// worker can render into it off the main thread.
123    pub fn send_with_canvas<Message: Serialize>(
124        &self,
125        message: &Message,
126        canvas: &OffscreenCanvas,
127    ) {
128        let envelope = js_sys::Object::new();
129        let value = serde_wasm_bindgen::to_value(message).unwrap_or(JsValue::NULL);
130        let _ = js_sys::Reflect::set(&envelope, &JsValue::from_str(MESSAGE_KEY), &value);
131        let _ = js_sys::Reflect::set(&envelope, &JsValue::from_str(CANVAS_KEY), canvas);
132        let transfer = js_sys::Array::of1(canvas);
133        let _ = self.worker.post_message_with_transfer(&envelope, &transfer);
134    }
135}
136
137struct ViewportResources {
138    worker: Worker,
139    _on_message: MessageClosure,
140    _wheel: Option<WheelClosure>,
141    observer: Option<ResizeObservation>,
142}
143
144#[derive(Clone, Copy, Default)]
145struct DragState {
146    button: Option<u8>,
147    last_x: f32,
148    last_y: f32,
149    moved: f32,
150}
151
152#[derive(Clone, Copy)]
153struct TouchTrack {
154    last_x: f32,
155    last_y: f32,
156    moved: f32,
157}
158
159/// Reports whether the current browser exposes WebGPU (`navigator.gpu`).
160pub fn webgpu_supported() -> bool {
161    let Some(window) = web_sys::window() else {
162        return false;
163    };
164    let Ok(navigator) = js_sys::Reflect::get(window.as_ref(), &JsValue::from_str("navigator"))
165    else {
166        return false;
167    };
168    js_sys::Reflect::get(&navigator, &JsValue::from_str("gpu"))
169        .map(|gpu| !gpu.is_undefined() && !gpu.is_null())
170        .unwrap_or(false)
171}
172
173/// Renders `children` when WebGPU is available, or an explanatory fallback card when it is not.
174#[component]
175pub fn WebGpuGate(children: ChildrenFn) -> impl IntoView {
176    if webgpu_supported() {
177        children().into_any()
178    } else {
179        view! {
180            <div class="nightshade-webgpu-gate">
181                <div class="nightshade-webgpu-gate-card">
182                    <h1>"WebGPU not available"</h1>
183                    <p>
184                        "This app renders through WebGPU in a web worker. Open it in a browser with WebGPU and OffscreenCanvas-in-workers support (Chromium 113+, Firefox 141+)."
185                    </p>
186                </div>
187            </div>
188        }
189        .into_any()
190    }
191}
192
193/// A full-surface overlay with a spinner and `message`, shown until `ready` becomes true.
194#[component]
195pub fn Loader(
196    ready: RwSignal<bool>,
197    #[prop(into, optional, default = "Starting the renderer…".to_string())] message: String,
198) -> impl IntoView {
199    view! {
200        <Show when=move || !ready.get() fallback=|| ()>
201            <div class="nightshade-loader-overlay">
202                <div class="nightshade-loader-card">
203                    <span class="nightshade-spinner"></span>
204                    {message.clone()}
205                </div>
206            </div>
207        </Show>
208    }
209}
210
211/// A render surface backed by a web worker. On mount it sizes a canvas to the
212/// device pixel ratio, transfers it to an `OffscreenCanvas`, spawns the module
213/// worker at `worker_url`, and reports the `Bridge`, canvas, and dimensions through
214/// `on_connect`. It forwards pointer, wheel, touch, and resize events as
215/// `ViewportEvent`s to `on_input`, worker messages to `on_message`, failures to
216/// the optional `on_error`, and toggles the `grabbing` signal during pointer
217/// interaction.
218#[component]
219pub fn Viewport(
220    #[prop(into)] worker_url: String,
221    grabbing: RwSignal<bool>,
222    on_input: Callback<ViewportEvent>,
223    on_connect: Callback<(Bridge, OffscreenCanvas, f32, f32)>,
224    on_message: Callback<JsValue>,
225    #[prop(optional)] on_error: Option<Callback<String>>,
226) -> impl IntoView {
227    let canvas_ref = NodeRef::<html::Canvas>::new();
228    let drag = StoredValue::new(DragState::default());
229    let touches = StoredValue::new(HashMap::<i32, TouchTrack>::new());
230    let rect_offset = StoredValue::new((0.0_f64, 0.0_f64));
231    let connected = StoredValue::new(false);
232    let worker_url = StoredValue::new(worker_url);
233    let resources = StoredValue::new_local(None::<ViewportResources>);
234
235    Effect::new(move |_| {
236        let Some(canvas) = canvas_ref.get() else {
237            return;
238        };
239        if connected.get_value() {
240            return;
241        }
242        let dpr = render_dpr() as f32;
243        let rect = canvas.get_bounding_client_rect();
244        let width = rect.width() as f32 * dpr;
245        let height = rect.height() as f32 * dpr;
246        canvas.set_width(width as u32);
247        canvas.set_height(height as u32);
248        let Ok(offscreen) = canvas.transfer_control_to_offscreen() else {
249            report(
250                &on_error,
251                "failed to transfer canvas to an offscreen surface",
252            );
253            return;
254        };
255        let Some((bridge, on_message_closure)) = spawn_worker(&worker_url.get_value(), on_message)
256        else {
257            report(&on_error, "failed to spawn the render worker");
258            return;
259        };
260        let wheel = attach_wheel(&canvas, on_input);
261        let observer = observe_resize(canvas, on_input);
262        connected.set_value(true);
263        resources.set_value(Some(ViewportResources {
264            worker: bridge.worker.clone(),
265            _on_message: on_message_closure,
266            _wheel: wheel,
267            observer,
268        }));
269        on_connect.run((bridge, offscreen, width, height));
270    });
271
272    on_cleanup(move || {
273        resources.update_value(|slot| {
274            if let Some(active) = slot.take() {
275                active.worker.terminate();
276                if let Some((observer, _closure)) = active.observer {
277                    observer.disconnect();
278                }
279            }
280        });
281    });
282
283    let on_pointerdown = move |event: PointerEvent| {
284        if let Some(canvas) = canvas_ref.get() {
285            let rect = canvas.get_bounding_client_rect();
286            rect_offset.set_value((rect.left(), rect.top()));
287        }
288        if event.pointer_type() == "touch" {
289            let id = event.pointer_id();
290            touches.update_value(|map| {
291                map.insert(
292                    id,
293                    TouchTrack {
294                        last_x: event.client_x() as f32,
295                        last_y: event.client_y() as f32,
296                        moved: 0.0,
297                    },
298                );
299            });
300            if let Some(canvas) = canvas_ref.get() {
301                let _ = canvas.set_pointer_capture(id);
302            }
303            let (x, y) = physical(rect_offset.get_value(), event.client_x(), event.client_y());
304            on_input.run(ViewportEvent::Touch {
305                id: id as u64,
306                phase: TouchPhase::Started,
307                x,
308                y,
309            });
310            grabbing.set(true);
311            return;
312        }
313        let button = event.button().max(0) as u8;
314        drag.update_value(|state| {
315            state.button = Some(button);
316            state.last_x = event.client_x() as f32;
317            state.last_y = event.client_y() as f32;
318            state.moved = 0.0;
319        });
320        if let Some(canvas) = canvas_ref.get() {
321            let _ = canvas.set_pointer_capture(event.pointer_id());
322            let (x, y) = physical(rect_offset.get_value(), event.client_x(), event.client_y());
323            on_input.run(ViewportEvent::PointerMove { x, y });
324            on_input.run(ViewportEvent::PointerButton {
325                button,
326                pressed: true,
327            });
328        }
329        grabbing.set(true);
330    };
331
332    let on_pointermove = move |event: PointerEvent| {
333        if pointer_locked(&canvas_ref) {
334            let dpr = render_dpr() as f32;
335            on_input.run(ViewportEvent::PointerMotion {
336                dx: event.movement_x() as f32 * dpr,
337                dy: event.movement_y() as f32 * dpr,
338            });
339            return;
340        }
341        if event.pointer_type() == "touch" {
342            let id = event.pointer_id();
343            touches.update_value(|map| {
344                if let Some(track) = map.get_mut(&id) {
345                    let x = event.client_x() as f32;
346                    let y = event.client_y() as f32;
347                    track.moved += (x - track.last_x).abs() + (y - track.last_y).abs();
348                    track.last_x = x;
349                    track.last_y = y;
350                }
351            });
352            let (x, y) = physical(rect_offset.get_value(), event.client_x(), event.client_y());
353            on_input.run(ViewportEvent::Touch {
354                id: id as u64,
355                phase: TouchPhase::Moved,
356                x,
357                y,
358            });
359            return;
360        }
361        drag.update_value(|state| {
362            let x = event.client_x() as f32;
363            let y = event.client_y() as f32;
364            state.moved += (x - state.last_x).abs() + (y - state.last_y).abs();
365            state.last_x = x;
366            state.last_y = y;
367        });
368        let (x, y) = physical(rect_offset.get_value(), event.client_x(), event.client_y());
369        on_input.run(ViewportEvent::PointerMove { x, y });
370    };
371
372    let on_pointerup = move |event: PointerEvent| {
373        if event.pointer_type() == "touch" {
374            let id = event.pointer_id();
375            let (moved, count) = touches.with_value(|map| {
376                (
377                    map.get(&id).map(|track| track.moved).unwrap_or(0.0),
378                    map.len(),
379                )
380            });
381            touches.update_value(|map| {
382                map.remove(&id);
383            });
384            if let Some(canvas) = canvas_ref.get() {
385                let _ = canvas.release_pointer_capture(id);
386            }
387            let (x, y) = physical(rect_offset.get_value(), event.client_x(), event.client_y());
388            on_input.run(ViewportEvent::Touch {
389                id: id as u64,
390                phase: TouchPhase::Ended,
391                x,
392                y,
393            });
394            if count == 1 && moved < CLICK_DRAG_THRESHOLD {
395                on_input.run(ViewportEvent::Pick { x, y });
396            }
397            if touches.with_value(HashMap::is_empty) {
398                grabbing.set(false);
399            }
400            return;
401        }
402        let (button, moved) = drag.with_value(|state| (state.button, state.moved));
403        drag.update_value(|state| state.button = None);
404        grabbing.set(false);
405        if let Some(canvas) = canvas_ref.get() {
406            let _ = canvas.release_pointer_capture(event.pointer_id());
407            let (x, y) = physical(rect_offset.get_value(), event.client_x(), event.client_y());
408            on_input.run(ViewportEvent::PointerButton {
409                button: event.button().max(0) as u8,
410                pressed: false,
411            });
412            if button == Some(0) && moved < CLICK_DRAG_THRESHOLD {
413                on_input.run(ViewportEvent::Pick { x, y });
414            }
415        }
416    };
417
418    let on_pointercancel = move |event: PointerEvent| {
419        if event.pointer_type() != "touch" {
420            return;
421        }
422        let id = event.pointer_id();
423        touches.update_value(|map| {
424            map.remove(&id);
425        });
426        if let Some(canvas) = canvas_ref.get() {
427            let _ = canvas.release_pointer_capture(id);
428        }
429        let (x, y) = physical(rect_offset.get_value(), event.client_x(), event.client_y());
430        on_input.run(ViewportEvent::Touch {
431            id: id as u64,
432            phase: TouchPhase::Cancelled,
433            x,
434            y,
435        });
436        if touches.with_value(HashMap::is_empty) {
437            grabbing.set(false);
438        }
439    };
440
441    let on_contextmenu = move |event: MouseEvent| event.prevent_default();
442
443    let canvas_class = move || {
444        if grabbing.get() {
445            "nightshade-viewport-canvas grabbing"
446        } else {
447            "nightshade-viewport-canvas"
448        }
449    };
450
451    view! {
452        <div class="nightshade-viewport">
453            <canvas
454                id="canvas"
455                node_ref=canvas_ref
456                class=canvas_class
457                on:pointerdown=on_pointerdown
458                on:pointermove=on_pointermove
459                on:pointerup=on_pointerup
460                on:pointercancel=on_pointercancel
461                on:contextmenu=on_contextmenu
462            ></canvas>
463        </div>
464    }
465}
466
467fn report(on_error: &Option<Callback<String>>, message: &str) {
468    if let Some(callback) = on_error {
469        callback.run(message.to_string());
470    }
471}
472
473fn pointer_locked(canvas_ref: &NodeRef<html::Canvas>) -> bool {
474    let Some(canvas) = canvas_ref.get_untracked() else {
475        return false;
476    };
477    web_sys::window()
478        .and_then(|window| window.document())
479        .and_then(|document| document.pointer_lock_element())
480        .is_some_and(|locked| {
481            let element: &web_sys::Element = canvas.as_ref();
482            locked == *element
483        })
484}
485
486fn spawn_worker(url: &str, on_message: Callback<JsValue>) -> Option<(Bridge, MessageClosure)> {
487    let options = WorkerOptions::new();
488    options.set_type(WorkerType::Module);
489    let worker = Worker::new_with_options(url, &options).ok()?;
490    let handler = Closure::<dyn FnMut(MessageEvent)>::new(move |event: MessageEvent| {
491        on_message.run(event.data());
492    });
493    worker.set_onmessage(Some(handler.as_ref().unchecked_ref()));
494    Some((Bridge { worker }, handler))
495}
496
497fn render_dpr() -> f64 {
498    web_sys::window()
499        .map(|window| window.device_pixel_ratio().min(MAX_RENDER_DPR))
500        .unwrap_or(1.0)
501}
502
503fn physical(offset: (f64, f64), client_x: i32, client_y: i32) -> (f32, f32) {
504    let dpr = render_dpr();
505    (
506        ((client_x as f64 - offset.0) * dpr) as f32,
507        ((client_y as f64 - offset.1) * dpr) as f32,
508    )
509}
510
511fn attach_wheel(
512    canvas: &HtmlCanvasElement,
513    on_input: Callback<ViewportEvent>,
514) -> Option<WheelClosure> {
515    let handler = Closure::<dyn FnMut(WheelEvent)>::new(move |event: WheelEvent| {
516        event.prevent_default();
517        on_input.run(ViewportEvent::Wheel {
518            delta: event.delta_y() as f32,
519        });
520    });
521    let options = web_sys::AddEventListenerOptions::new();
522    options.set_passive(false);
523    canvas
524        .add_event_listener_with_callback_and_add_event_listener_options(
525            "wheel",
526            handler.as_ref().unchecked_ref(),
527            &options,
528        )
529        .ok()?;
530    Some(handler)
531}
532
533fn observe_resize(
534    canvas: HtmlCanvasElement,
535    on_input: Callback<ViewportEvent>,
536) -> Option<ResizeObservation> {
537    let resize_canvas = canvas.clone();
538    let handler = Closure::<dyn FnMut()>::new(move || {
539        let dpr = render_dpr() as f32;
540        let rect = resize_canvas.get_bounding_client_rect();
541        on_input.run(ViewportEvent::Resize {
542            width: rect.width() as f32 * dpr,
543            height: rect.height() as f32 * dpr,
544        });
545    });
546    let observer = ResizeObserver::new(handler.as_ref().unchecked_ref()).ok()?;
547    observer.observe(&canvas);
548
549    let window_canvas = canvas.clone();
550    let on_window_resize = Closure::<dyn FnMut()>::new(move || {
551        let dpr = render_dpr() as f32;
552        let rect = window_canvas.get_bounding_client_rect();
553        on_input.run(ViewportEvent::Resize {
554            width: rect.width() as f32 * dpr,
555            height: rect.height() as f32 * dpr,
556        });
557    });
558    if let Some(window) = web_sys::window() {
559        let _ = window
560            .add_event_listener_with_callback("resize", on_window_resize.as_ref().unchecked_ref());
561    }
562    on_window_resize.forget();
563
564    Some((observer, handler))
565}