Skip to main content

uzor_web/
lib.rs

1//! Web backend for uzor using WebAssembly
2//!
3//! This crate provides the web platform implementation for uzor,
4//! supporting browsers via WebAssembly (WASM).
5
6#![allow(dead_code)]
7
8use std::cell::RefCell;
9use std::collections::VecDeque;
10use std::rc::Rc;
11
12use wasm_bindgen::prelude::*;
13use wasm_bindgen::JsCast;
14use web_sys::{
15    HtmlCanvasElement, Window, Document, Event, MouseEvent, KeyboardEvent,
16    WheelEvent, TouchEvent, CompositionEvent,
17};
18
19use uzor::input::events::KeyCode;
20use uzor::input::state::{ModifierKeys, MouseButton};
21use uzor::platform::{
22    backends::PlatformBackend,
23    types::{PlatformError, WindowId, SystemIntegration},
24    ImeEvent, PlatformEvent, SystemTheme, WindowConfig,
25};
26use uzor::input::cursor::CursorIcon;
27
28pub use uzor;
29
30// =============================================================================
31// WebPlatform - Main Platform Backend
32// =============================================================================
33
34/// Web platform backend for uzor
35///
36/// This struct handles all browser integration including canvas management,
37/// event handling, clipboard operations, and system integration.
38#[derive(Clone)]
39pub struct WebPlatform {
40    state: Rc<RefCell<WebPlatformState>>,
41}
42
43struct WebPlatformState {
44    window: Window,
45    document: Document,
46    canvas: HtmlCanvasElement,
47    window_id: WindowId,
48    config: WindowConfig,
49    event_queue: VecDeque<PlatformEvent>,
50    scale_factor: f64,
51    cursor_icon: CursorIcon,
52    cursor_visible: bool,
53    ime_position: (f64, f64),
54    ime_allowed: bool,
55    // Event listener closures (kept alive)
56    _listeners: Vec<EventListener>,
57}
58
59struct EventListener {
60    _closure: Closure<dyn FnMut(Event)>,
61}
62
63impl WebPlatform {
64    /// Create a new WebPlatform from a canvas element ID
65    ///
66    /// # Errors
67    ///
68    /// Returns an error if:
69    /// - No window object is available
70    /// - No document object is available
71    /// - Canvas element with the given ID is not found
72    /// - Canvas element is not an HTMLCanvasElement
73    pub fn new(canvas_id: &str) -> Result<Self, String> {
74        // Get window and document
75        let window = web_sys::window()
76            .ok_or_else(|| "No window object available".to_string())?;
77
78        let document = window
79            .document()
80            .ok_or_else(|| "No document object available".to_string())?;
81
82        // Get canvas element
83        let canvas = document
84            .get_element_by_id(canvas_id)
85            .ok_or_else(|| format!("Canvas element '{}' not found", canvas_id))?
86            .dyn_into::<HtmlCanvasElement>()
87            .map_err(|_| format!("Element '{}' is not a canvas", canvas_id))?;
88
89        // Get device pixel ratio
90        let scale_factor = window.device_pixel_ratio();
91
92        // Create initial config from canvas size
93        let width = canvas.client_width() as u32;
94        let height = canvas.client_height() as u32;
95        let config = WindowConfig {
96            title: "Web Canvas".to_string(),
97            width,
98            height,
99            ..WindowConfig::default()
100        };
101
102        let state = Rc::new(RefCell::new(WebPlatformState {
103            window,
104            document,
105            canvas,
106            window_id: WindowId::new(),
107            config,
108            event_queue: VecDeque::new(),
109            scale_factor,
110            cursor_icon: CursorIcon::Default,
111            cursor_visible: true,
112            ime_position: (0.0, 0.0),
113            ime_allowed: false,
114            _listeners: Vec::new(),
115        }));
116
117        // Setup event listeners
118        Self::setup_event_listeners(&state)?;
119
120        Ok(Self { state })
121    }
122
123    /// Get the underlying canvas element
124    pub fn canvas(&self) -> HtmlCanvasElement {
125        self.state.borrow().canvas.clone()
126    }
127
128    fn setup_event_listeners(state: &Rc<RefCell<WebPlatformState>>) -> Result<(), String> {
129        let mut state_mut = state.borrow_mut();
130        let canvas = state_mut.canvas.clone();
131        let canvas_target = canvas.clone().dyn_into::<web_sys::EventTarget>()
132            .map_err(|_| "Canvas is not an EventTarget")?;
133
134        // Mouse events
135        Self::add_mouse_listener(&mut state_mut, &canvas_target, "mousedown", state)?;
136        Self::add_mouse_listener(&mut state_mut, &canvas_target, "mousemove", state)?;
137        Self::add_mouse_listener(&mut state_mut, &canvas_target, "mouseup", state)?;
138        Self::add_mouse_listener(&mut state_mut, &canvas_target, "mouseenter", state)?;
139        Self::add_mouse_listener(&mut state_mut, &canvas_target, "mouseleave", state)?;
140
141        // Wheel events
142        Self::add_wheel_listener(&mut state_mut, &canvas_target, state)?;
143
144        // Touch events
145        Self::add_touch_listener(&mut state_mut, &canvas_target, "touchstart", state)?;
146        Self::add_touch_listener(&mut state_mut, &canvas_target, "touchmove", state)?;
147        Self::add_touch_listener(&mut state_mut, &canvas_target, "touchend", state)?;
148        Self::add_touch_listener(&mut state_mut, &canvas_target, "touchcancel", state)?;
149
150        // Keyboard events
151        Self::add_keyboard_listener(&mut state_mut, &canvas_target, "keydown", state)?;
152        Self::add_keyboard_listener(&mut state_mut, &canvas_target, "keyup", state)?;
153
154        // Focus events
155        Self::add_focus_listener(&mut state_mut, &canvas_target, state)?;
156
157        // IME events
158        Self::add_ime_listener(&mut state_mut, &canvas_target, "compositionstart", state)?;
159        Self::add_ime_listener(&mut state_mut, &canvas_target, "compositionupdate", state)?;
160        Self::add_ime_listener(&mut state_mut, &canvas_target, "compositionend", state)?;
161
162        Ok(())
163    }
164
165    fn add_mouse_listener(
166        state_mut: &mut WebPlatformState,
167        target: &web_sys::EventTarget,
168        event_type: &str,
169        state_ref: &Rc<RefCell<WebPlatformState>>,
170    ) -> Result<(), String> {
171        let state_clone = state_ref.clone();
172        let event_type_str = event_type.to_string();
173
174        let closure = Closure::wrap(Box::new(move |event: Event| {
175            if let Ok(mouse_event) = event.dyn_into::<MouseEvent>() {
176                let mut state = state_clone.borrow_mut();
177                let platform_event = Self::map_mouse_event(&event_type_str, &mouse_event);
178                if let Some(evt) = platform_event {
179                    state.event_queue.push_back(evt);
180                }
181            }
182        }) as Box<dyn FnMut(Event)>);
183
184        target.add_event_listener_with_callback(event_type, closure.as_ref().unchecked_ref())
185            .map_err(|_| format!("Failed to add {} listener", event_type))?;
186
187        state_mut._listeners.push(EventListener { _closure: closure });
188        Ok(())
189    }
190
191    fn add_wheel_listener(
192        state_mut: &mut WebPlatformState,
193        target: &web_sys::EventTarget,
194        state_ref: &Rc<RefCell<WebPlatformState>>,
195    ) -> Result<(), String> {
196        let state_clone = state_ref.clone();
197
198        let closure = Closure::wrap(Box::new(move |event: Event| {
199            event.prevent_default();
200
201            if let Ok(wheel_event) = event.dyn_into::<WheelEvent>() {
202                let mut state = state_clone.borrow_mut();
203                let dx = wheel_event.delta_x();
204                let dy = wheel_event.delta_y();
205
206                state.event_queue.push_back(PlatformEvent::Scroll {
207                    dx: -dx,
208                    dy: -dy
209                });
210            }
211        }) as Box<dyn FnMut(Event)>);
212
213        target.add_event_listener_with_callback("wheel", closure.as_ref().unchecked_ref())
214            .map_err(|_| "Failed to add wheel listener")?;
215
216        state_mut._listeners.push(EventListener { _closure: closure });
217        Ok(())
218    }
219
220    fn add_touch_listener(
221        state_mut: &mut WebPlatformState,
222        target: &web_sys::EventTarget,
223        event_type: &str,
224        state_ref: &Rc<RefCell<WebPlatformState>>,
225    ) -> Result<(), String> {
226        let state_clone = state_ref.clone();
227        let event_type_str = event_type.to_string();
228
229        let closure = Closure::wrap(Box::new(move |event: Event| {
230            event.prevent_default();
231
232            if let Ok(touch_event) = event.dyn_into::<TouchEvent>() {
233                let mut state = state_clone.borrow_mut();
234                let events = Self::map_touch_event(&event_type_str, &touch_event);
235                for evt in events {
236                    state.event_queue.push_back(evt);
237                }
238            }
239        }) as Box<dyn FnMut(Event)>);
240
241        target.add_event_listener_with_callback(event_type, closure.as_ref().unchecked_ref())
242            .map_err(|_| format!("Failed to add {} listener", event_type))?;
243
244        state_mut._listeners.push(EventListener { _closure: closure });
245        Ok(())
246    }
247
248    fn add_keyboard_listener(
249        state_mut: &mut WebPlatformState,
250        target: &web_sys::EventTarget,
251        event_type: &str,
252        state_ref: &Rc<RefCell<WebPlatformState>>,
253    ) -> Result<(), String> {
254        let state_clone = state_ref.clone();
255        let event_type_str = event_type.to_string();
256
257        let closure = Closure::wrap(Box::new(move |event: Event| {
258            if let Ok(keyboard_event) = event.dyn_into::<KeyboardEvent>() {
259                let mut state = state_clone.borrow_mut();
260                let platform_event = Self::map_keyboard_event(&event_type_str, &keyboard_event);
261                if let Some(evt) = platform_event {
262                    state.event_queue.push_back(evt);
263                }
264            }
265        }) as Box<dyn FnMut(Event)>);
266
267        target.add_event_listener_with_callback(event_type, closure.as_ref().unchecked_ref())
268            .map_err(|_| format!("Failed to add {} listener", event_type))?;
269
270        state_mut._listeners.push(EventListener { _closure: closure });
271        Ok(())
272    }
273
274    fn add_focus_listener(
275        state_mut: &mut WebPlatformState,
276        target: &web_sys::EventTarget,
277        state_ref: &Rc<RefCell<WebPlatformState>>,
278    ) -> Result<(), String> {
279        let state_clone = state_ref.clone();
280
281        let focus_closure = Closure::wrap(Box::new(move |_event: Event| {
282            let mut state = state_clone.borrow_mut();
283            state.event_queue.push_back(PlatformEvent::WindowFocused(true));
284        }) as Box<dyn FnMut(Event)>);
285
286        target.add_event_listener_with_callback("focus", focus_closure.as_ref().unchecked_ref())
287            .map_err(|_| "Failed to add focus listener")?;
288
289        state_mut._listeners.push(EventListener { _closure: focus_closure });
290
291        let state_clone2 = state_ref.clone();
292        let blur_closure = Closure::wrap(Box::new(move |_event: Event| {
293            let mut state = state_clone2.borrow_mut();
294            state.event_queue.push_back(PlatformEvent::WindowFocused(false));
295        }) as Box<dyn FnMut(Event)>);
296
297        target.add_event_listener_with_callback("blur", blur_closure.as_ref().unchecked_ref())
298            .map_err(|_| "Failed to add blur listener")?;
299
300        state_mut._listeners.push(EventListener { _closure: blur_closure });
301
302        Ok(())
303    }
304
305    fn add_ime_listener(
306        state_mut: &mut WebPlatformState,
307        target: &web_sys::EventTarget,
308        event_type: &str,
309        state_ref: &Rc<RefCell<WebPlatformState>>,
310    ) -> Result<(), String> {
311        let state_clone = state_ref.clone();
312        let event_type_str = event_type.to_string();
313
314        let closure = Closure::wrap(Box::new(move |event: Event| {
315            if let Ok(composition_event) = event.dyn_into::<CompositionEvent>() {
316                let mut state = state_clone.borrow_mut();
317                let ime_event = Self::map_ime_event(&event_type_str, &composition_event);
318                if let Some(evt) = ime_event {
319                    state.event_queue.push_back(PlatformEvent::Ime(evt));
320                }
321            }
322        }) as Box<dyn FnMut(Event)>);
323
324        target.add_event_listener_with_callback(event_type, closure.as_ref().unchecked_ref())
325            .map_err(|_| format!("Failed to add {} listener", event_type))?;
326
327        state_mut._listeners.push(EventListener { _closure: closure });
328        Ok(())
329    }
330
331    fn map_mouse_event(event_type: &str, event: &MouseEvent) -> Option<PlatformEvent> {
332        let x = event.offset_x() as f64;
333        let y = event.offset_y() as f64;
334        let button = Self::map_mouse_button(event.button());
335
336        match event_type {
337            "mousedown" => Some(PlatformEvent::PointerDown { x, y, button }),
338            "mouseup" => Some(PlatformEvent::PointerUp { x, y, button }),
339            "mousemove" => Some(PlatformEvent::PointerMoved { x, y }),
340            "mouseenter" => Some(PlatformEvent::PointerEntered),
341            "mouseleave" => Some(PlatformEvent::PointerLeft),
342            _ => None,
343        }
344    }
345
346    fn map_touch_event(event_type: &str, event: &TouchEvent) -> Vec<PlatformEvent> {
347        let mut events = Vec::new();
348
349        match event_type {
350            "touchstart" => {
351                let touches = event.changed_touches();
352                for i in 0..touches.length() {
353                    if let Some(touch) = touches.item(i) {
354                        events.push(PlatformEvent::TouchStart {
355                            id: touch.identifier() as u64,
356                            x: touch.client_x() as f64,
357                            y: touch.client_y() as f64,
358                        });
359                    }
360                }
361            }
362            "touchmove" => {
363                let touches = event.changed_touches();
364                for i in 0..touches.length() {
365                    if let Some(touch) = touches.item(i) {
366                        events.push(PlatformEvent::TouchMove {
367                            id: touch.identifier() as u64,
368                            x: touch.client_x() as f64,
369                            y: touch.client_y() as f64,
370                        });
371                    }
372                }
373            }
374            "touchend" => {
375                let touches = event.changed_touches();
376                for i in 0..touches.length() {
377                    if let Some(touch) = touches.item(i) {
378                        events.push(PlatformEvent::TouchEnd {
379                            id: touch.identifier() as u64,
380                            x: touch.client_x() as f64,
381                            y: touch.client_y() as f64,
382                        });
383                    }
384                }
385            }
386            "touchcancel" => {
387                let touches = event.changed_touches();
388                for i in 0..touches.length() {
389                    if let Some(touch) = touches.item(i) {
390                        events.push(PlatformEvent::TouchCancel {
391                            id: touch.identifier() as u64,
392                        });
393                    }
394                }
395            }
396            _ => {}
397        }
398
399        events
400    }
401
402    fn map_keyboard_event(event_type: &str, event: &KeyboardEvent) -> Option<PlatformEvent> {
403        let key = Self::map_keycode(&event.code());
404        let modifiers = Self::get_modifiers(event);
405
406        match event_type {
407            "keydown" => Some(PlatformEvent::KeyDown { key, modifiers }),
408            "keyup" => Some(PlatformEvent::KeyUp { key, modifiers }),
409            _ => None,
410        }
411    }
412
413    fn map_ime_event(event_type: &str, event: &CompositionEvent) -> Option<ImeEvent> {
414        match event_type {
415            "compositionstart" => Some(ImeEvent::Enabled),
416            "compositionupdate" => {
417                let data = event.data().unwrap_or_default();
418                Some(ImeEvent::Preedit(data, None))
419            }
420            "compositionend" => {
421                let data = event.data().unwrap_or_default();
422                Some(ImeEvent::Commit(data))
423            }
424            _ => None,
425        }
426    }
427
428    fn map_mouse_button(button: i16) -> MouseButton {
429        match button {
430            0 => MouseButton::Left,
431            1 => MouseButton::Middle,
432            2 => MouseButton::Right,
433            _ => MouseButton::Left,
434        }
435    }
436
437    fn get_modifiers(event: &KeyboardEvent) -> ModifierKeys {
438        ModifierKeys {
439            shift: event.shift_key(),
440            ctrl: event.ctrl_key(),
441            alt: event.alt_key(),
442            meta: event.meta_key(),
443        }
444    }
445
446    fn map_keycode(code: &str) -> KeyCode {
447        match code {
448            // Letters
449            "KeyA" => KeyCode::A,
450            "KeyB" => KeyCode::B,
451            "KeyC" => KeyCode::C,
452            "KeyD" => KeyCode::D,
453            "KeyE" => KeyCode::E,
454            "KeyF" => KeyCode::F,
455            "KeyG" => KeyCode::G,
456            "KeyH" => KeyCode::H,
457            "KeyI" => KeyCode::I,
458            "KeyJ" => KeyCode::J,
459            "KeyK" => KeyCode::K,
460            "KeyL" => KeyCode::L,
461            "KeyM" => KeyCode::M,
462            "KeyN" => KeyCode::N,
463            "KeyO" => KeyCode::O,
464            "KeyP" => KeyCode::P,
465            "KeyQ" => KeyCode::Q,
466            "KeyR" => KeyCode::R,
467            "KeyS" => KeyCode::S,
468            "KeyT" => KeyCode::T,
469            "KeyU" => KeyCode::U,
470            "KeyV" => KeyCode::V,
471            "KeyW" => KeyCode::W,
472            "KeyX" => KeyCode::X,
473            "KeyY" => KeyCode::Y,
474            "KeyZ" => KeyCode::Z,
475
476            // Numbers
477            "Digit0" => KeyCode::Num0,
478            "Digit1" => KeyCode::Num1,
479            "Digit2" => KeyCode::Num2,
480            "Digit3" => KeyCode::Num3,
481            "Digit4" => KeyCode::Num4,
482            "Digit5" => KeyCode::Num5,
483            "Digit6" => KeyCode::Num6,
484            "Digit7" => KeyCode::Num7,
485            "Digit8" => KeyCode::Num8,
486            "Digit9" => KeyCode::Num9,
487
488            // Special keys
489            "Enter" => KeyCode::Enter,
490            "Escape" => KeyCode::Escape,
491            "Backspace" => KeyCode::Backspace,
492            "Tab" => KeyCode::Tab,
493            "Space" => KeyCode::Space,
494
495            // Arrow keys
496            "ArrowLeft" => KeyCode::ArrowLeft,
497            "ArrowRight" => KeyCode::ArrowRight,
498            "ArrowUp" => KeyCode::ArrowUp,
499            "ArrowDown" => KeyCode::ArrowDown,
500
501            // Function keys
502            "F1" => KeyCode::F1,
503            "F2" => KeyCode::F2,
504            "F3" => KeyCode::F3,
505            "F4" => KeyCode::F4,
506            "F5" => KeyCode::F5,
507            "F6" => KeyCode::F6,
508            "F7" => KeyCode::F7,
509            "F8" => KeyCode::F8,
510            "F9" => KeyCode::F9,
511            "F10" => KeyCode::F10,
512            "F11" => KeyCode::F11,
513            "F12" => KeyCode::F12,
514
515            // Other
516            "Delete" => KeyCode::Delete,
517            "Home" => KeyCode::Home,
518            "End" => KeyCode::End,
519            "PageUp" => KeyCode::PageUp,
520            "PageDown" => KeyCode::PageDown,
521
522            _ => KeyCode::Unknown,
523        }
524    }
525
526    fn cursor_icon_to_css(icon: CursorIcon) -> &'static str {
527        icon.css_name()
528    }
529}
530
531// =============================================================================
532// Send + Sync Implementation (WASM is single-threaded)
533// =============================================================================
534
535// SAFETY: WebPlatform is only used in single-threaded WASM contexts.
536// JavaScript/WASM doesn't have threads that would make Rc unsafe.
537unsafe impl Send for WebPlatform {}
538unsafe impl Sync for WebPlatform {}
539
540// =============================================================================
541// Trait Implementations
542// =============================================================================
543
544impl PlatformBackend for WebPlatform {
545    fn name(&self) -> &'static str {
546        todo!("not yet implemented for this platform")
547    }
548
549    fn create_window(&mut self, config: WindowConfig) -> Result<WindowId, PlatformError> {
550        let mut state = self.state.borrow_mut();
551
552        // Update canvas size
553        let canvas = &state.canvas;
554        canvas.set_width((config.width as f64 * state.scale_factor) as u32);
555        canvas.set_height((config.height as f64 * state.scale_factor) as u32);
556
557        // Update title (if document title)
558        state.document.set_title(&config.title);
559
560        state.config = config;
561        state.event_queue.push_back(PlatformEvent::WindowCreated);
562
563        Ok(state.window_id)
564    }
565
566    fn close_window(&mut self, _window_id: WindowId) -> Result<(), PlatformError> {
567        let mut state = self.state.borrow_mut();
568        state.event_queue.push_back(PlatformEvent::WindowDestroyed);
569        Ok(())
570    }
571
572    fn primary_window(&self) -> Option<WindowId> {
573        todo!("not yet implemented for this platform")
574    }
575
576    fn poll_events(&mut self) -> Vec<PlatformEvent> {
577        todo!("not yet implemented for this platform")
578    }
579
580    fn request_redraw(&self, _id: WindowId) {
581        // No-op for now: web redraws are driven by requestAnimationFrame
582    }
583}
584
585impl SystemIntegration for WebPlatform {
586    fn get_clipboard(&self) -> Option<String> {
587        todo!("not yet implemented for this platform")
588    }
589
590    fn set_clipboard(&self, _text: &str) {
591        todo!("not yet implemented for this platform")
592    }
593
594    fn get_system_theme(&self) -> Option<SystemTheme> {
595        let state = self.state.borrow();
596
597        // Use matchMedia to detect dark mode
598        if let Ok(Some(media_query)) = state.window.match_media("(prefers-color-scheme: dark)") {
599            if media_query.matches() {
600                return Some(SystemTheme::Dark);
601            }
602        }
603
604        Some(SystemTheme::Light)
605    }
606}
607
608
609#[cfg(test)]
610mod tests {
611    use super::*;
612
613    #[test]
614    fn test_keycode_mapping() {
615        assert_eq!(WebPlatform::map_keycode("KeyA"), KeyCode::A);
616        assert_eq!(WebPlatform::map_keycode("Digit5"), KeyCode::Num5);
617        assert_eq!(WebPlatform::map_keycode("Enter"), KeyCode::Enter);
618        assert_eq!(WebPlatform::map_keycode("ArrowLeft"), KeyCode::ArrowLeft);
619        assert_eq!(WebPlatform::map_keycode("Unknown"), KeyCode::Unknown);
620    }
621
622    #[test]
623    fn test_mouse_button_mapping() {
624        assert_eq!(WebPlatform::map_mouse_button(0), MouseButton::Left);
625        assert_eq!(WebPlatform::map_mouse_button(1), MouseButton::Middle);
626        assert_eq!(WebPlatform::map_mouse_button(2), MouseButton::Right);
627    }
628
629    #[test]
630    fn test_cursor_icon_css() {
631        assert_eq!(WebPlatform::cursor_icon_to_css(CursorIcon::Default), "default");
632        assert_eq!(WebPlatform::cursor_icon_to_css(CursorIcon::PointingHand), "pointer");
633        assert_eq!(WebPlatform::cursor_icon_to_css(CursorIcon::Text), "text");
634        assert_eq!(WebPlatform::cursor_icon_to_css(CursorIcon::Grab), "grab");
635        assert_eq!(WebPlatform::cursor_icon_to_css(CursorIcon::ResizeVertical), "ns-resize");
636    }
637}