StratoSDK is a framework with a declarative approach similar to Flutter/React, written and designed entirely for Rust.
| 1 | pub(crate) mod hidden_input; |
| 2 | pub(crate) mod mobile_detection; |
| 3 | pub(crate) mod soft_keyboard; |
| 4 | |
| 5 | use gloo::events::{EventListener, EventListenerOptions}; |
| 6 | use wasm_bindgen::{JsCast, UnwrapThrowExt}; |
| 7 | |
| 8 | use crate::{keymap::Keystroke, windowing::winit::app::CustomEvent}; |
| 9 | |
| 10 | pub use hidden_input::{HiddenInput, HiddenInputEvent, InputCallback}; |
| 11 | pub use mobile_detection::{is_mobile_device, is_mobile_user_agent}; |
| 12 | pub use soft_keyboard::{SoftKeyboardInput, SoftKeyboardManager, SoftKeyboardState}; |
| 13 | |
| 14 | // Re-export a couple winit types and modules as the concrete implementations |
| 15 | // for the wasm platform. |
| 16 | pub use crate::windowing::winit::app::App; |
| 17 | |
| 18 | // Re-export the functions from the core crate. |
| 19 | pub use strato_ui_core::platform::wasm::*; |
| 20 | |
| 21 | use super::KEYS_TO_IGNORE; |
| 22 | |
| 23 | fn get_visual_viewport_dimensions() -> Option<(f32, f32)> { |
| 24 | let window = gloo::utils::window(); |
| 25 | let vv = js_sys::Reflect::get(&window, &"visualViewport".into()).ok()?; |
| 26 | let width = js_sys::Reflect::get(&vv, &"width".into()) |
| 27 | .ok() |
| 28 | .and_then(|v| v.as_f64())? as f32; |
| 29 | let height = js_sys::Reflect::get(&vv, &"height".into()) |
| 30 | .ok() |
| 31 | .and_then(|v| v.as_f64())? as f32; |
| 32 | (width > 0.0 && height > 0.0).then_some((width, height)) |
| 33 | } |
| 34 | |
| 35 | /// Listens for visual viewport changes (e.g., soft keyboard appearing) on mobile. |
| 36 | pub(crate) fn setup_visual_viewport_resize_listener( |
| 37 | event_loop_proxy: winit::event_loop::EventLoopProxy<CustomEvent>, |
| 38 | ) { |
| 39 | if !mobile_detection::is_mobile_device() { |
| 40 | return; |
| 41 | } |
| 42 | |
| 43 | let window = gloo::utils::window(); |
| 44 | let visual_viewport = js_sys::Reflect::get(&window, &"visualViewport".into()) |
| 45 | .ok() |
| 46 | .and_then(|v| v.dyn_into::<web_sys::EventTarget>().ok()); |
| 47 | |
| 48 | let Some(visual_viewport) = visual_viewport else { |
| 49 | log::warn!("Visual viewport API not available"); |
| 50 | return; |
| 51 | }; |
| 52 | |
| 53 | // Fire once immediately so the first render uses the correct visual viewport height. |
| 54 | if let Some((width, height)) = get_visual_viewport_dimensions() { |
| 55 | let _ = event_loop_proxy.send_event(CustomEvent::VisualViewportResized { width, height }); |
| 56 | } |
| 57 | |
| 58 | EventListener::new(&visual_viewport, "resize", move |_| { |
| 59 | if let Some((width, height)) = get_visual_viewport_dimensions() { |
| 60 | log::debug!("Visual viewport resized to {}x{}", width, height); |
| 61 | let _ = |
| 62 | event_loop_proxy.send_event(CustomEvent::VisualViewportResized { width, height }); |
| 63 | } |
| 64 | }) |
| 65 | .forget(); |
| 66 | } |
| 67 | |
| 68 | /// Adds an event listener to the main canvas element which calls preventDefault on all important |
| 69 | /// events except for those we explicitly want to pass through to the browser. |
| 70 | pub(crate) fn add_prevent_default_listener(canvas: &web_sys::HtmlCanvasElement) { |
| 71 | // Event types where we unconditionally call prevent_default. |
| 72 | let events_types_to_prevent = [ |
| 73 | "touchstart", |
| 74 | "wheel", |
| 75 | "contextmenu", |
| 76 | "pointerdown", |
| 77 | "pointermove", |
| 78 | ]; |
| 79 | |
| 80 | // Keyboard events where we call prevent_default in some cases. |
| 81 | let key_events_to_partially_prevent = ["keyup", "keydown"]; |
| 82 | |
| 83 | for event_type in events_types_to_prevent.into_iter() { |
| 84 | let prevent_default_listener = Box::new(EventListener::new_with_options( |
| 85 | canvas, |
| 86 | event_type, |
| 87 | EventListenerOptions::enable_prevent_default(), |
| 88 | move |event| { |
| 89 | event.prevent_default(); |
| 90 | }, |
| 91 | )); |
| 92 | |
| 93 | // We want this to live for the lifetime of the page and we're never going to need to |
| 94 | // interact with it again, so we leak it so it can live forever. |
| 95 | Box::leak(prevent_default_listener); |
| 96 | } |
| 97 | |
| 98 | for event_type in key_events_to_partially_prevent.into_iter() { |
| 99 | let prevent_default_listener = Box::new(EventListener::new_with_options( |
| 100 | canvas, |
| 101 | event_type, |
| 102 | EventListenerOptions::enable_prevent_default(), |
| 103 | move |event| { |
| 104 | let event = event.dyn_ref::<web_sys::KeyboardEvent>().unwrap_throw(); |
| 105 | let keystroke = Keystroke { |
| 106 | ctrl: event.ctrl_key(), |
| 107 | alt: event.alt_key(), |
| 108 | shift: event.shift_key(), |
| 109 | cmd: event.meta_key(), // The browser's 'meta' corresponds to our 'command'. |
| 110 | meta: false, |
| 111 | key: event.key(), |
| 112 | }; |
| 113 | |
| 114 | let allow_default_event = KEYS_TO_IGNORE.contains(&keystroke); |
| 115 | if !allow_default_event { |
| 116 | event.prevent_default(); |
| 117 | } |
| 118 | }, |
| 119 | )); |
| 120 | Box::leak(prevent_default_listener); |
| 121 | } |
| 122 | } |
| 123 | |
| 124 | pub(crate) fn add_paste_listener(event_loop_proxy: winit::event_loop::EventLoopProxy<CustomEvent>) { |
| 125 | EventListener::new(&gloo::utils::document(), "paste", move |event| { |
| 126 | let event = event.dyn_ref::<web_sys::ClipboardEvent>().unwrap_throw(); |
| 127 | let Some(data) = event.clipboard_data() else { |
| 128 | log::warn!("Received paste event without clipboard data."); |
| 129 | return; |
| 130 | }; |
| 131 | |
| 132 | let content = crate::clipboard::ClipboardContent { |
| 133 | plain_text: data.get_data("text").unwrap_or_default(), |
| 134 | html: data |
| 135 | .get_data("text/html") |
| 136 | .ok() |
| 137 | .and_then(|s| (!s.is_empty()).then_some(s)), // Set this to None if the html data is empty |
| 138 | ..Default::default() |
| 139 | }; |
| 140 | |
| 141 | let _ = event_loop_proxy.send_event(CustomEvent::Clipboard( |
| 142 | crate::windowing::winit::app::ClipboardEvent::Paste(content), |
| 143 | )); |
| 144 | }) |
| 145 | .forget(); |
| 146 | } |
| 147 | |
| 148 | pub(crate) fn add_network_connection_listener( |
| 149 | event_loop_proxy: winit::event_loop::EventLoopProxy<CustomEvent>, |
| 150 | ) { |
| 151 | let event_loop_proxy_clone = event_loop_proxy.clone(); |
| 152 | |
| 153 | EventListener::new(&gloo::utils::window(), "offline", move |_event| { |
| 154 | let _ = event_loop_proxy_clone |
| 155 | .send_event(crate::windowing::winit::app::CustomEvent::InternetDisconnected); |
| 156 | }) |
| 157 | .forget(); |
| 158 | |
| 159 | EventListener::new(&gloo::utils::window(), "online", move |_event| { |
| 160 | let _ = event_loop_proxy |
| 161 | .send_event(crate::windowing::winit::app::CustomEvent::InternetConnected); |
| 162 | }) |
| 163 | .forget(); |
| 164 | } |
| 165 | |
| 166 | pub(crate) fn add_system_theme_listener( |
| 167 | event_loop_proxy: winit::event_loop::EventLoopProxy<CustomEvent>, |
| 168 | ) { |
| 169 | // This could alternatively be written as a listener on "(prefers-color-scheme: light)". |
| 170 | if let Ok(Some(media_query_list)) = |
| 171 | gloo::utils::window().match_media("(prefers-color-scheme: dark)") |
| 172 | { |
| 173 | EventListener::new(&media_query_list, "change", move |_event| { |
| 174 | let _ = event_loop_proxy |
| 175 | .send_event(crate::windowing::winit::app::CustomEvent::SystemThemeChanged); |
| 176 | }) |
| 177 | .forget(); |
| 178 | } |
| 179 | } |
| 180 |