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