1use 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#[derive(Clone, Copy, Debug, PartialEq)]
27pub enum ViewportEvent {
28 Resize {
30 width: f32,
32 height: f32,
34 },
35 PointerMove {
37 x: f32,
39 y: f32,
41 },
42 PointerButton {
44 button: u8,
46 pressed: bool,
48 },
49 Wheel {
51 delta: f32,
53 },
54 Touch {
56 id: u64,
58 phase: TouchPhase,
60 x: f32,
62 y: f32,
64 },
65 Pick {
67 x: f32,
69 y: f32,
71 },
72}
73
74#[derive(Clone)]
77pub struct Bridge {
78 worker: Worker,
79}
80
81impl Bridge {
82 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 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
128pub 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#[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#[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#[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}