use std::collections::HashMap;
use crate::transfer::{attach, envelope_with};
use crate::wire::{APP_KEY, CANVAS_KEY, MESSAGE_KEY, TouchPhase};
use leptos::html;
use leptos::prelude::*;
use serde::Serialize;
use wasm_bindgen::prelude::*;
use wasm_bindgen::{JsCast, JsValue};
use web_sys::{
HtmlCanvasElement, MessageEvent, MouseEvent, OffscreenCanvas, PointerEvent, ResizeObserver,
WheelEvent, Worker, WorkerOptions, WorkerType,
};
const CLICK_DRAG_THRESHOLD: f32 = 5.0;
const MAX_RENDER_DPR: f64 = 2.0;
type MessageClosure = Closure<dyn FnMut(MessageEvent)>;
type WheelClosure = Closure<dyn FnMut(WheelEvent)>;
type ResizeObservation = (ResizeObserver, Closure<dyn FnMut()>);
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum ViewportEvent {
Resize {
width: f32,
height: f32,
},
PointerMove {
x: f32,
y: f32,
},
PointerMotion {
dx: f32,
dy: f32,
},
PointerButton {
button: u8,
pressed: bool,
},
Wheel {
delta: f32,
},
Touch {
id: u64,
phase: TouchPhase,
x: f32,
y: f32,
},
Pick {
x: f32,
y: f32,
},
}
#[derive(Clone)]
pub struct Bridge {
worker: Worker,
}
impl Bridge {
pub fn send<Message: Serialize>(&self, message: &Message) {
let envelope = js_sys::Object::new();
let value = serde_wasm_bindgen::to_value(message).unwrap_or(JsValue::NULL);
let _ = js_sys::Reflect::set(&envelope, &JsValue::from_str(MESSAGE_KEY), &value);
let _ = self.worker.post_message(&envelope);
}
pub fn send_app<Message: Serialize>(
&self,
message: &Message,
attachments: &[(String, Vec<u8>)],
) {
let Some(envelope) = envelope_with(APP_KEY, message) else {
return;
};
if attachments.is_empty() {
let _ = self.worker.post_message(&envelope);
return;
}
let transfer = js_sys::Array::new();
if !attach(&envelope, &transfer, attachments) {
return;
}
let _ = self.worker.post_message_with_transfer(&envelope, &transfer);
}
pub fn send_with_canvas<Message: Serialize>(
&self,
message: &Message,
canvas: &OffscreenCanvas,
) {
let envelope = js_sys::Object::new();
let value = serde_wasm_bindgen::to_value(message).unwrap_or(JsValue::NULL);
let _ = js_sys::Reflect::set(&envelope, &JsValue::from_str(MESSAGE_KEY), &value);
let _ = js_sys::Reflect::set(&envelope, &JsValue::from_str(CANVAS_KEY), canvas);
let transfer = js_sys::Array::of1(canvas);
let _ = self.worker.post_message_with_transfer(&envelope, &transfer);
}
}
struct ViewportResources {
worker: Worker,
_on_message: MessageClosure,
_wheel: Option<WheelClosure>,
observer: Option<ResizeObservation>,
}
#[derive(Clone, Copy, Default)]
struct DragState {
button: Option<u8>,
last_x: f32,
last_y: f32,
moved: f32,
}
#[derive(Clone, Copy)]
struct TouchTrack {
last_x: f32,
last_y: f32,
moved: f32,
}
pub fn webgpu_supported() -> bool {
let Some(window) = web_sys::window() else {
return false;
};
let Ok(navigator) = js_sys::Reflect::get(window.as_ref(), &JsValue::from_str("navigator"))
else {
return false;
};
js_sys::Reflect::get(&navigator, &JsValue::from_str("gpu"))
.map(|gpu| !gpu.is_undefined() && !gpu.is_null())
.unwrap_or(false)
}
#[component]
pub fn WebGpuGate(children: ChildrenFn) -> impl IntoView {
if webgpu_supported() {
children().into_any()
} else {
view! {
<div class="nightshade-webgpu-gate">
<div class="nightshade-webgpu-gate-card">
<h1>"WebGPU not available"</h1>
<p>
"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+)."
</p>
</div>
</div>
}
.into_any()
}
}
#[component]
pub fn Loader(
ready: RwSignal<bool>,
#[prop(into, optional, default = "Starting the renderer…".to_string())] message: String,
) -> impl IntoView {
view! {
<Show when=move || !ready.get() fallback=|| ()>
<div class="nightshade-loader-overlay">
<div class="nightshade-loader-card">
<span class="nightshade-spinner"></span>
{message.clone()}
</div>
</div>
</Show>
}
}
#[component]
pub fn Viewport(
#[prop(into)] worker_url: String,
grabbing: RwSignal<bool>,
on_input: Callback<ViewportEvent>,
on_connect: Callback<(Bridge, OffscreenCanvas, f32, f32)>,
on_message: Callback<JsValue>,
#[prop(optional)] on_error: Option<Callback<String>>,
) -> impl IntoView {
let canvas_ref = NodeRef::<html::Canvas>::new();
let drag = StoredValue::new(DragState::default());
let touches = StoredValue::new(HashMap::<i32, TouchTrack>::new());
let rect_offset = StoredValue::new((0.0_f64, 0.0_f64));
let connected = StoredValue::new(false);
let worker_url = StoredValue::new(worker_url);
let resources = StoredValue::new_local(None::<ViewportResources>);
Effect::new(move |_| {
let Some(canvas) = canvas_ref.get() else {
return;
};
if connected.get_value() {
return;
}
let dpr = render_dpr() as f32;
let rect = canvas.get_bounding_client_rect();
let width = rect.width() as f32 * dpr;
let height = rect.height() as f32 * dpr;
canvas.set_width(width as u32);
canvas.set_height(height as u32);
let Ok(offscreen) = canvas.transfer_control_to_offscreen() else {
report(
&on_error,
"failed to transfer canvas to an offscreen surface",
);
return;
};
let Some((bridge, on_message_closure)) = spawn_worker(&worker_url.get_value(), on_message)
else {
report(&on_error, "failed to spawn the render worker");
return;
};
let wheel = attach_wheel(&canvas, on_input);
let observer = observe_resize(canvas, on_input);
connected.set_value(true);
resources.set_value(Some(ViewportResources {
worker: bridge.worker.clone(),
_on_message: on_message_closure,
_wheel: wheel,
observer,
}));
on_connect.run((bridge, offscreen, width, height));
});
on_cleanup(move || {
resources.update_value(|slot| {
if let Some(active) = slot.take() {
active.worker.terminate();
if let Some((observer, _closure)) = active.observer {
observer.disconnect();
}
}
});
});
let on_pointerdown = move |event: PointerEvent| {
if let Some(canvas) = canvas_ref.get() {
let rect = canvas.get_bounding_client_rect();
rect_offset.set_value((rect.left(), rect.top()));
}
if event.pointer_type() == "touch" {
let id = event.pointer_id();
touches.update_value(|map| {
map.insert(
id,
TouchTrack {
last_x: event.client_x() as f32,
last_y: event.client_y() as f32,
moved: 0.0,
},
);
});
if let Some(canvas) = canvas_ref.get() {
let _ = canvas.set_pointer_capture(id);
}
let (x, y) = physical(rect_offset.get_value(), event.client_x(), event.client_y());
on_input.run(ViewportEvent::Touch {
id: id as u64,
phase: TouchPhase::Started,
x,
y,
});
grabbing.set(true);
return;
}
let button = event.button().max(0) as u8;
drag.update_value(|state| {
state.button = Some(button);
state.last_x = event.client_x() as f32;
state.last_y = event.client_y() as f32;
state.moved = 0.0;
});
if let Some(canvas) = canvas_ref.get() {
let _ = canvas.set_pointer_capture(event.pointer_id());
let (x, y) = physical(rect_offset.get_value(), event.client_x(), event.client_y());
on_input.run(ViewportEvent::PointerMove { x, y });
on_input.run(ViewportEvent::PointerButton {
button,
pressed: true,
});
}
grabbing.set(true);
};
let on_pointermove = move |event: PointerEvent| {
if pointer_locked(&canvas_ref) {
let dpr = render_dpr() as f32;
on_input.run(ViewportEvent::PointerMotion {
dx: event.movement_x() as f32 * dpr,
dy: event.movement_y() as f32 * dpr,
});
return;
}
if event.pointer_type() == "touch" {
let id = event.pointer_id();
touches.update_value(|map| {
if let Some(track) = map.get_mut(&id) {
let x = event.client_x() as f32;
let y = event.client_y() as f32;
track.moved += (x - track.last_x).abs() + (y - track.last_y).abs();
track.last_x = x;
track.last_y = y;
}
});
let (x, y) = physical(rect_offset.get_value(), event.client_x(), event.client_y());
on_input.run(ViewportEvent::Touch {
id: id as u64,
phase: TouchPhase::Moved,
x,
y,
});
return;
}
drag.update_value(|state| {
let x = event.client_x() as f32;
let y = event.client_y() as f32;
state.moved += (x - state.last_x).abs() + (y - state.last_y).abs();
state.last_x = x;
state.last_y = y;
});
let (x, y) = physical(rect_offset.get_value(), event.client_x(), event.client_y());
on_input.run(ViewportEvent::PointerMove { x, y });
};
let on_pointerup = move |event: PointerEvent| {
if event.pointer_type() == "touch" {
let id = event.pointer_id();
let (moved, count) = touches.with_value(|map| {
(
map.get(&id).map(|track| track.moved).unwrap_or(0.0),
map.len(),
)
});
touches.update_value(|map| {
map.remove(&id);
});
if let Some(canvas) = canvas_ref.get() {
let _ = canvas.release_pointer_capture(id);
}
let (x, y) = physical(rect_offset.get_value(), event.client_x(), event.client_y());
on_input.run(ViewportEvent::Touch {
id: id as u64,
phase: TouchPhase::Ended,
x,
y,
});
if count == 1 && moved < CLICK_DRAG_THRESHOLD {
on_input.run(ViewportEvent::Pick { x, y });
}
if touches.with_value(HashMap::is_empty) {
grabbing.set(false);
}
return;
}
let (button, moved) = drag.with_value(|state| (state.button, state.moved));
drag.update_value(|state| state.button = None);
grabbing.set(false);
if let Some(canvas) = canvas_ref.get() {
let _ = canvas.release_pointer_capture(event.pointer_id());
let (x, y) = physical(rect_offset.get_value(), event.client_x(), event.client_y());
on_input.run(ViewportEvent::PointerButton {
button: event.button().max(0) as u8,
pressed: false,
});
if button == Some(0) && moved < CLICK_DRAG_THRESHOLD {
on_input.run(ViewportEvent::Pick { x, y });
}
}
};
let on_pointercancel = move |event: PointerEvent| {
if event.pointer_type() != "touch" {
return;
}
let id = event.pointer_id();
touches.update_value(|map| {
map.remove(&id);
});
if let Some(canvas) = canvas_ref.get() {
let _ = canvas.release_pointer_capture(id);
}
let (x, y) = physical(rect_offset.get_value(), event.client_x(), event.client_y());
on_input.run(ViewportEvent::Touch {
id: id as u64,
phase: TouchPhase::Cancelled,
x,
y,
});
if touches.with_value(HashMap::is_empty) {
grabbing.set(false);
}
};
let on_contextmenu = move |event: MouseEvent| event.prevent_default();
let canvas_class = move || {
if grabbing.get() {
"nightshade-viewport-canvas grabbing"
} else {
"nightshade-viewport-canvas"
}
};
view! {
<div class="nightshade-viewport">
<canvas
id="canvas"
node_ref=canvas_ref
class=canvas_class
on:pointerdown=on_pointerdown
on:pointermove=on_pointermove
on:pointerup=on_pointerup
on:pointercancel=on_pointercancel
on:contextmenu=on_contextmenu
></canvas>
</div>
}
}
fn report(on_error: &Option<Callback<String>>, message: &str) {
if let Some(callback) = on_error {
callback.run(message.to_string());
}
}
fn pointer_locked(canvas_ref: &NodeRef<html::Canvas>) -> bool {
let Some(canvas) = canvas_ref.get_untracked() else {
return false;
};
web_sys::window()
.and_then(|window| window.document())
.and_then(|document| document.pointer_lock_element())
.is_some_and(|locked| {
let element: &web_sys::Element = canvas.as_ref();
locked == *element
})
}
fn spawn_worker(url: &str, on_message: Callback<JsValue>) -> Option<(Bridge, MessageClosure)> {
let options = WorkerOptions::new();
options.set_type(WorkerType::Module);
let worker = Worker::new_with_options(url, &options).ok()?;
let handler = Closure::<dyn FnMut(MessageEvent)>::new(move |event: MessageEvent| {
on_message.run(event.data());
});
worker.set_onmessage(Some(handler.as_ref().unchecked_ref()));
Some((Bridge { worker }, handler))
}
fn render_dpr() -> f64 {
web_sys::window()
.map(|window| window.device_pixel_ratio().min(MAX_RENDER_DPR))
.unwrap_or(1.0)
}
fn physical(offset: (f64, f64), client_x: i32, client_y: i32) -> (f32, f32) {
let dpr = render_dpr();
(
((client_x as f64 - offset.0) * dpr) as f32,
((client_y as f64 - offset.1) * dpr) as f32,
)
}
fn attach_wheel(
canvas: &HtmlCanvasElement,
on_input: Callback<ViewportEvent>,
) -> Option<WheelClosure> {
let handler = Closure::<dyn FnMut(WheelEvent)>::new(move |event: WheelEvent| {
event.prevent_default();
on_input.run(ViewportEvent::Wheel {
delta: event.delta_y() as f32,
});
});
let options = web_sys::AddEventListenerOptions::new();
options.set_passive(false);
canvas
.add_event_listener_with_callback_and_add_event_listener_options(
"wheel",
handler.as_ref().unchecked_ref(),
&options,
)
.ok()?;
Some(handler)
}
fn observe_resize(
canvas: HtmlCanvasElement,
on_input: Callback<ViewportEvent>,
) -> Option<ResizeObservation> {
let resize_canvas = canvas.clone();
let handler = Closure::<dyn FnMut()>::new(move || {
let dpr = render_dpr() as f32;
let rect = resize_canvas.get_bounding_client_rect();
on_input.run(ViewportEvent::Resize {
width: rect.width() as f32 * dpr,
height: rect.height() as f32 * dpr,
});
});
let observer = ResizeObserver::new(handler.as_ref().unchecked_ref()).ok()?;
observer.observe(&canvas);
let window_canvas = canvas.clone();
let on_window_resize = Closure::<dyn FnMut()>::new(move || {
let dpr = render_dpr() as f32;
let rect = window_canvas.get_bounding_client_rect();
on_input.run(ViewportEvent::Resize {
width: rect.width() as f32 * dpr,
height: rect.height() as f32 * dpr,
});
});
if let Some(window) = web_sys::window() {
let _ = window
.add_event_listener_with_callback("resize", on_window_resize.as_ref().unchecked_ref());
}
on_window_resize.forget();
Some((observer, handler))
}