1use 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#[derive(Clone, Copy, Debug, PartialEq)]
28pub enum ViewportEvent {
29 Resize {
31 width: f32,
33 height: f32,
35 },
36 PointerMove {
38 x: f32,
40 y: f32,
42 },
43 PointerMotion {
46 dx: f32,
48 dy: f32,
50 },
51 PointerButton {
53 button: u8,
55 pressed: bool,
57 },
58 Wheel {
60 delta: f32,
62 },
63 Touch {
65 id: u64,
67 phase: TouchPhase,
69 x: f32,
71 y: f32,
73 },
74 Pick {
76 x: f32,
78 y: f32,
80 },
81}
82
83#[derive(Clone)]
86pub struct Bridge {
87 worker: Worker,
88}
89
90impl Bridge {
91 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 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 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
159pub 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#[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#[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#[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}