use crate::launcher::AppSettings;
use cranpose_app_shell::{default_root_key, AppShell};
use cranpose_platform_web::WebPlatform;
use cranpose_render_wgpu::WgpuRenderer;
use std::cell::RefCell;
use std::rc::Rc;
use std::sync::Arc;
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
use web_sys::{HtmlCanvasElement, MouseEvent, PointerEvent, WheelEvent};
const WEB_WHEEL_LINE_DELTA_PIXELS: f32 = 40.0;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum WebBackendPreference {
Auto,
WebGpu,
Gl,
}
pub async fn run(
canvas_id: &str,
settings: AppSettings,
content: impl FnMut() + 'static,
) -> Result<(), JsValue> {
console_error_panic_hook::set_once();
let window = web_sys::window().ok_or("no global window exists")?;
let document = window
.document()
.ok_or("should have a document on window")?;
let canvas = document
.get_element_by_id(canvas_id)
.ok_or_else(|| format!("canvas with id '{}' not found", canvas_id))?
.dyn_into::<HtmlCanvasElement>()?;
let scale_factor = window.device_pixel_ratio();
let width = settings.initial_width;
let height = settings.initial_height;
canvas.set_width((width as f64 * scale_factor) as u32);
canvas.set_height((height as f64 * scale_factor) as u32);
if let Some(html_element) = canvas.dyn_ref::<web_sys::HtmlElement>() {
let style = html_element.style();
style.set_property("width", &format!("{}px", width))?;
style.set_property("height", &format!("{}px", height))?;
style.set_property("touch-action", "none")?;
}
let backend_preference = requested_web_backend(&window);
let instance_desc = wgpu::InstanceDescriptor {
backends: instance_backends(backend_preference),
..Default::default()
};
let instance = match backend_preference {
WebBackendPreference::Auto => {
wgpu::util::new_instance_with_webgpu_detection(&instance_desc).await
}
WebBackendPreference::WebGpu | WebBackendPreference::Gl => {
wgpu::Instance::new(&instance_desc)
}
};
let surface = instance
.create_surface(wgpu::SurfaceTarget::Canvas(canvas.clone()))
.map_err(|e| format!("failed to create surface: {:?}", e))?;
let adapter = instance
.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::HighPerformance,
compatible_surface: Some(&surface),
force_fallback_adapter: false,
})
.await
.map_err(|e| format!("failed to find suitable adapter: {:?}", e))?;
let adapter_info = adapter.get_info();
let adapter_limits = adapter.limits();
let required_limits =
required_limits_for_web_backend(adapter_info.backend, adapter_limits.clone());
log::info!(
"Web backend preference={:?}, selected backend={:?}, max_texture_dimension_2d={}",
backend_preference,
adapter_info.backend,
adapter_limits.max_texture_dimension_2d
);
let (device, queue) = adapter
.request_device(&wgpu::DeviceDescriptor {
label: Some("Main Device"),
required_features: wgpu::Features::empty(),
required_limits,
experimental_features: wgpu::ExperimentalFeatures::disabled(),
memory_hints: wgpu::MemoryHints::default(),
trace: wgpu::Trace::Off,
})
.await
.map_err(|e| format!("failed to create device: {:?}", e))?;
let surface_caps = surface.get_capabilities(&adapter);
let surface_format = surface_caps
.formats
.iter()
.copied()
.find(|f| f.is_srgb())
.unwrap_or(surface_caps.formats[0]);
let present_mode = crate::present_mode::select_present_mode(&surface_caps);
let surface_config = wgpu::SurfaceConfiguration {
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
format: surface_format,
width: (width as f64 * scale_factor) as u32,
height: (height as f64 * scale_factor) as u32,
present_mode,
alpha_mode: surface_caps.alpha_modes[0],
view_formats: vec![],
desired_maximum_frame_latency: 2,
};
surface.configure(&device, &surface_config);
let mut surface_config = surface_config;
let (actual_width, actual_height, effective_scale) =
if adapter_info.backend == wgpu::Backend::BrowserWebGpu {
(surface_config.width, surface_config.height, scale_factor)
} else {
let probe = surface
.get_current_texture()
.map_err(|e| format!("failed to probe surface texture: {e:?}"))?;
let actual_width = probe.texture.width();
let actual_height = probe.texture.height();
probe.present();
let effective_scale =
if actual_width < surface_config.width || actual_height < surface_config.height {
let fit_x = actual_width as f64 / width as f64;
let fit_y = actual_height as f64 / height as f64;
let s = fit_x.min(fit_y);
surface_config.width = actual_width;
surface_config.height = actual_height;
s
} else {
scale_factor
};
(actual_width, actual_height, effective_scale)
};
let fonts: &[&[u8]] = settings.fonts.unwrap_or(&[]);
log::info!("Web renderer startup: {} font(s)", fonts.len());
let mut renderer = WgpuRenderer::new(fonts);
renderer.init_gpu(
Arc::new(device),
Arc::new(queue),
surface_format,
adapter_info.backend,
);
renderer.set_root_scale(effective_scale as f32);
cranpose_ui::set_density(effective_scale as f32);
let app = Rc::new(RefCell::new(AppShell::new(
renderer,
default_root_key(),
content,
)));
let platform = Rc::new(RefCell::new(WebPlatform::default()));
platform.borrow_mut().set_scale_factor(effective_scale);
app.borrow_mut()
.set_buffer_size(actual_width, actual_height);
app.borrow_mut().set_viewport(width as f32, height as f32);
let surface = Rc::new(surface);
let surface_config = Rc::new(RefCell::new(surface_config));
{
let app = app.clone();
let platform = platform.clone();
let closure = Closure::wrap(Box::new(move |event: MouseEvent| {
let x = event.offset_x() as f64;
let y = event.offset_y() as f64;
let logical = platform.borrow().pointer_position(x, y);
if let Ok(mut app_mut) = app.try_borrow_mut() {
app_mut.set_cursor(logical.x, logical.y);
}
}) as Box<dyn FnMut(_)>);
canvas.add_event_listener_with_callback("mousemove", closure.as_ref().unchecked_ref())?;
closure.forget();
}
{
let app = app.clone();
let platform = platform.clone();
let closure = Closure::wrap(Box::new(move |event: MouseEvent| {
let x = event.offset_x() as f64;
let y = event.offset_y() as f64;
let logical = platform.borrow().pointer_position(x, y);
if let Ok(mut app_mut) = app.try_borrow_mut() {
app_mut.set_cursor(logical.x, logical.y);
app_mut.pointer_pressed();
}
}) as Box<dyn FnMut(_)>);
canvas.add_event_listener_with_callback("mousedown", closure.as_ref().unchecked_ref())?;
closure.forget();
}
{
let app = app.clone();
let platform = platform.clone();
let closure = Closure::wrap(Box::new(move |event: MouseEvent| {
let x = event.offset_x() as f64;
let y = event.offset_y() as f64;
let logical = platform.borrow().pointer_position(x, y);
if let Ok(mut app_mut) = app.try_borrow_mut() {
app_mut.set_cursor(logical.x, logical.y);
app_mut.pointer_released();
}
}) as Box<dyn FnMut(_)>);
canvas.add_event_listener_with_callback("mouseup", closure.as_ref().unchecked_ref())?;
closure.forget();
}
{
let app = app.clone();
let platform = platform.clone();
let wheel_canvas = canvas.clone();
let closure = Closure::wrap(Box::new(move |event: WheelEvent| {
let x = event.offset_x() as f64;
let y = event.offset_y() as f64;
let logical = platform.borrow().pointer_position(x, y);
let mut delta_x = event.delta_x() as f32;
let mut delta_y = event.delta_y() as f32;
match event.delta_mode() {
WheelEvent::DOM_DELTA_PIXEL => {}
WheelEvent::DOM_DELTA_LINE => {
delta_x *= WEB_WHEEL_LINE_DELTA_PIXELS;
delta_y *= WEB_WHEEL_LINE_DELTA_PIXELS;
}
WheelEvent::DOM_DELTA_PAGE => {
let page_width = wheel_canvas.client_width().max(1) as f32;
let page_height = wheel_canvas.client_height().max(1) as f32;
delta_x *= page_width;
delta_y *= page_height;
}
_ => {}
}
if event.alt_key() {
if delta_x.abs() <= f32::EPSILON {
delta_x = delta_y;
}
delta_y = 0.0;
}
if let Ok(mut app_mut) = app.try_borrow_mut() {
app_mut.set_cursor(logical.x, logical.y);
if app_mut.pointer_scrolled(delta_x, delta_y) {
event.prevent_default();
}
}
}) as Box<dyn FnMut(_)>);
canvas.add_event_listener_with_callback("wheel", closure.as_ref().unchecked_ref())?;
closure.forget();
}
{
let app = app.clone();
let platform = platform.clone();
let closure = Closure::wrap(Box::new(move |event: PointerEvent| {
event.prevent_default();
let x = event.offset_x() as f64;
let y = event.offset_y() as f64;
let logical = platform.borrow().pointer_position(x, y);
if let Ok(mut app_mut) = app.try_borrow_mut() {
app_mut.set_cursor(logical.x, logical.y);
}
}) as Box<dyn FnMut(_)>);
canvas.add_event_listener_with_callback("pointermove", closure.as_ref().unchecked_ref())?;
closure.forget();
}
{
let app = app.clone();
let platform = platform.clone();
let closure = Closure::wrap(Box::new(move |event: PointerEvent| {
event.prevent_default();
let x = event.offset_x() as f64;
let y = event.offset_y() as f64;
let logical = platform.borrow().pointer_position(x, y);
if let Ok(mut app_mut) = app.try_borrow_mut() {
app_mut.set_cursor(logical.x, logical.y);
app_mut.pointer_pressed();
}
}) as Box<dyn FnMut(_)>);
canvas.add_event_listener_with_callback("pointerdown", closure.as_ref().unchecked_ref())?;
closure.forget();
}
{
let app = app.clone();
let platform = platform.clone();
let closure = Closure::wrap(Box::new(move |event: PointerEvent| {
event.prevent_default();
let x = event.offset_x() as f64;
let y = event.offset_y() as f64;
let logical = platform.borrow().pointer_position(x, y);
if let Ok(mut app_mut) = app.try_borrow_mut() {
app_mut.set_cursor(logical.x, logical.y);
app_mut.pointer_released();
}
}) as Box<dyn FnMut(_)>);
canvas.add_event_listener_with_callback("pointerup", closure.as_ref().unchecked_ref())?;
closure.forget();
}
{
let app = app.clone();
let closure = Closure::wrap(Box::new(move |event: PointerEvent| {
event.prevent_default();
if let Ok(mut app_mut) = app.try_borrow_mut() {
app_mut.cancel_gesture();
}
}) as Box<dyn FnMut(_)>);
canvas
.add_event_listener_with_callback("pointercancel", closure.as_ref().unchecked_ref())?;
closure.forget();
}
{
let app = app.clone();
let closure = Closure::wrap(Box::new(move |event: web_sys::KeyboardEvent| {
use cranpose_app_shell::{KeyCode, KeyEvent, KeyEventType, Modifiers};
let key_code = match event.code().as_str() {
"KeyA" => KeyCode::A,
"KeyB" => KeyCode::B,
"KeyC" => KeyCode::C,
"KeyD" => KeyCode::D,
"KeyE" => KeyCode::E,
"KeyF" => KeyCode::F,
"KeyG" => KeyCode::G,
"KeyH" => KeyCode::H,
"KeyI" => KeyCode::I,
"KeyJ" => KeyCode::J,
"KeyK" => KeyCode::K,
"KeyL" => KeyCode::L,
"KeyM" => KeyCode::M,
"KeyN" => KeyCode::N,
"KeyO" => KeyCode::O,
"KeyP" => KeyCode::P,
"KeyQ" => KeyCode::Q,
"KeyR" => KeyCode::R,
"KeyS" => KeyCode::S,
"KeyT" => KeyCode::T,
"KeyU" => KeyCode::U,
"KeyV" => KeyCode::V,
"KeyW" => KeyCode::W,
"KeyX" => KeyCode::X,
"KeyY" => KeyCode::Y,
"KeyZ" => KeyCode::Z,
"Digit0" => KeyCode::Digit0,
"Digit1" => KeyCode::Digit1,
"Digit2" => KeyCode::Digit2,
"Digit3" => KeyCode::Digit3,
"Digit4" => KeyCode::Digit4,
"Digit5" => KeyCode::Digit5,
"Digit6" => KeyCode::Digit6,
"Digit7" => KeyCode::Digit7,
"Digit8" => KeyCode::Digit8,
"Digit9" => KeyCode::Digit9,
"ArrowUp" => KeyCode::ArrowUp,
"ArrowDown" => KeyCode::ArrowDown,
"ArrowLeft" => KeyCode::ArrowLeft,
"ArrowRight" => KeyCode::ArrowRight,
"Home" => KeyCode::Home,
"End" => KeyCode::End,
"PageUp" => KeyCode::PageUp,
"PageDown" => KeyCode::PageDown,
"Backspace" => KeyCode::Backspace,
"Delete" => KeyCode::Delete,
"Enter" | "NumpadEnter" => KeyCode::Enter,
"Tab" => KeyCode::Tab,
"Space" => KeyCode::Space,
"Escape" => KeyCode::Escape,
"Minus" => KeyCode::Minus,
"Equal" => KeyCode::Equal,
"BracketLeft" => KeyCode::BracketLeft,
"BracketRight" => KeyCode::BracketRight,
"Backslash" => KeyCode::Backslash,
"Semicolon" => KeyCode::Semicolon,
"Quote" => KeyCode::Quote,
"Comma" => KeyCode::Comma,
"Period" => KeyCode::Period,
"Slash" => KeyCode::Slash,
"Backquote" => KeyCode::Backquote,
_ => KeyCode::Unknown,
};
let modifiers = Modifiers {
shift: event.shift_key(),
ctrl: event.ctrl_key(),
alt: event.alt_key(),
meta: event.meta_key(),
};
let text = {
let key = event.key();
if key.len() == 1 {
key
} else {
String::new()
}
};
let key_event = KeyEvent {
key_code,
text,
modifiers,
event_type: KeyEventType::KeyDown,
};
if let Ok(mut app_mut) = app.try_borrow_mut() {
if app_mut.on_key_event(&key_event) {
event.prevent_default();
}
}
}) as Box<dyn FnMut(_)>);
document.add_event_listener_with_callback("keydown", closure.as_ref().unchecked_ref())?;
closure.forget();
}
{
let app = app.clone();
let closure = Closure::wrap(Box::new(move |event: web_sys::KeyboardEvent| {
use cranpose_app_shell::{KeyCode, KeyEvent, KeyEventType, Modifiers};
let key_code = match event.code().as_str() {
"KeyA" => KeyCode::A,
"KeyB" => KeyCode::B,
"KeyC" => KeyCode::C,
"KeyD" => KeyCode::D,
"KeyE" => KeyCode::E,
"KeyF" => KeyCode::F,
"KeyG" => KeyCode::G,
"KeyH" => KeyCode::H,
"KeyI" => KeyCode::I,
"KeyJ" => KeyCode::J,
"KeyK" => KeyCode::K,
"KeyL" => KeyCode::L,
"KeyM" => KeyCode::M,
"KeyN" => KeyCode::N,
"KeyO" => KeyCode::O,
"KeyP" => KeyCode::P,
"KeyQ" => KeyCode::Q,
"KeyR" => KeyCode::R,
"KeyS" => KeyCode::S,
"KeyT" => KeyCode::T,
"KeyU" => KeyCode::U,
"KeyV" => KeyCode::V,
"KeyW" => KeyCode::W,
"KeyX" => KeyCode::X,
"KeyY" => KeyCode::Y,
"KeyZ" => KeyCode::Z,
"Digit0" => KeyCode::Digit0,
"Digit1" => KeyCode::Digit1,
"Digit2" => KeyCode::Digit2,
"Digit3" => KeyCode::Digit3,
"Digit4" => KeyCode::Digit4,
"Digit5" => KeyCode::Digit5,
"Digit6" => KeyCode::Digit6,
"Digit7" => KeyCode::Digit7,
"Digit8" => KeyCode::Digit8,
"Digit9" => KeyCode::Digit9,
"ArrowUp" => KeyCode::ArrowUp,
"ArrowDown" => KeyCode::ArrowDown,
"ArrowLeft" => KeyCode::ArrowLeft,
"ArrowRight" => KeyCode::ArrowRight,
"Backspace" => KeyCode::Backspace,
"Delete" => KeyCode::Delete,
"Enter" | "NumpadEnter" => KeyCode::Enter,
"Tab" => KeyCode::Tab,
"Space" => KeyCode::Space,
_ => KeyCode::Unknown,
};
let modifiers = Modifiers {
shift: event.shift_key(),
ctrl: event.ctrl_key(),
alt: event.alt_key(),
meta: event.meta_key(),
};
let key_event = KeyEvent {
key_code,
text: String::new(), modifiers,
event_type: KeyEventType::KeyUp,
};
if let Ok(mut app_mut) = app.try_borrow_mut() {
app_mut.on_key_event(&key_event);
}
}) as Box<dyn FnMut(_)>);
document.add_event_listener_with_callback("keyup", closure.as_ref().unchecked_ref())?;
closure.forget();
}
{
let app = app.clone();
let closure = Closure::wrap(Box::new(move |event: web_sys::ClipboardEvent| {
if let Some(data) = event.clipboard_data() {
if let Ok(text) = data.get_data("text/plain") {
if !text.is_empty() {
if let Ok(mut app_mut) = app.try_borrow_mut() {
if app_mut.on_paste(&text) {
event.prevent_default();
}
}
}
}
}
}) as Box<dyn FnMut(_)>);
document.add_event_listener_with_callback("paste", closure.as_ref().unchecked_ref())?;
closure.forget();
}
{
let app = app.clone();
let closure = Closure::wrap(Box::new(move |event: web_sys::ClipboardEvent| {
if let Ok(mut app_mut) = app.try_borrow_mut() {
if let Some(text) = app_mut.on_copy() {
if let Some(data) = event.clipboard_data() {
let _ = data.set_data("text/plain", &text);
event.prevent_default();
}
}
}
}) as Box<dyn FnMut(_)>);
document.add_event_listener_with_callback("copy", closure.as_ref().unchecked_ref())?;
closure.forget();
}
{
let app = app.clone();
let closure = Closure::wrap(Box::new(move |event: web_sys::ClipboardEvent| {
if let Ok(mut app_mut) = app.try_borrow_mut() {
if let Some(text) = app_mut.on_cut() {
if let Some(data) = event.clipboard_data() {
let _ = data.set_data("text/plain", &text);
event.prevent_default();
}
}
}
}) as Box<dyn FnMut(_)>);
document.add_event_listener_with_callback("cut", closure.as_ref().unchecked_ref())?;
closure.forget();
}
let render_loop = Rc::new(RefCell::new(None));
let render_loop_clone = render_loop.clone();
*render_loop.borrow_mut() = Some(Closure::wrap(Box::new(move || {
app.borrow_mut().update();
let config = surface_config.borrow();
match surface.get_current_texture() {
Ok(output) => {
let view = output
.texture
.create_view(&wgpu::TextureViewDescriptor::default());
let render_width = output.texture.width();
let render_height = output.texture.height();
{
let mut app_mut = app.borrow_mut();
if let Err(err) = app_mut
.renderer()
.render(&view, render_width, render_height)
{
log::error!("render failed: {:?}", err);
}
}
output.present();
}
Err(wgpu::SurfaceError::Lost) | Err(wgpu::SurfaceError::Outdated) => {
let mut app_mut = app.borrow_mut();
let device = app_mut.renderer().device();
surface.configure(device, &*config);
}
Err(wgpu::SurfaceError::OutOfMemory) => {
log::error!("Out of memory");
}
Err(wgpu::SurfaceError::Timeout) => {
log::debug!("Surface timeout, skipping frame");
}
Err(wgpu::SurfaceError::Other) => {
log::error!("Surface other error, skipping frame");
}
}
request_animation_frame(render_loop_clone.borrow().as_ref().unwrap());
}) as Box<dyn FnMut()>));
request_animation_frame(render_loop.borrow().as_ref().unwrap());
Ok(())
}
fn request_animation_frame(f: &Closure<dyn FnMut()>) {
web_sys::window()
.unwrap()
.request_animation_frame(f.as_ref().unchecked_ref())
.expect("should register `requestAnimationFrame` OK");
}
fn requested_web_backend(window: &web_sys::Window) -> WebBackendPreference {
let query = window.location().search().unwrap_or_default();
for pair in query.trim_start_matches('?').split('&') {
let Some((key, value)) = pair.split_once('=') else {
continue;
};
if key != "backend" {
continue;
}
return match value {
"webgpu" => WebBackendPreference::WebGpu,
"gl" => WebBackendPreference::Gl,
_ => WebBackendPreference::Auto,
};
}
WebBackendPreference::Gl
}
fn instance_backends(preference: WebBackendPreference) -> wgpu::Backends {
match preference {
WebBackendPreference::Auto => wgpu::Backends::BROWSER_WEBGPU | wgpu::Backends::GL,
WebBackendPreference::WebGpu => wgpu::Backends::BROWSER_WEBGPU,
WebBackendPreference::Gl => wgpu::Backends::GL,
}
}
fn required_limits_for_web_backend(
backend: wgpu::Backend,
adapter_limits: wgpu::Limits,
) -> wgpu::Limits {
match backend {
wgpu::Backend::BrowserWebGpu => wgpu::Limits::default().using_resolution(adapter_limits),
_ => wgpu::Limits::downlevel_webgl2_defaults().using_resolution(adapter_limits),
}
}