Seregon/StratoSDK

StratoSDK is a framework with a declarative approach similar to Flutter/React, written and designed entirely for Rust.

Rust/27.3 KB/No license
crates/strato-ui-renderer/src/windowing/winit/event_loop/mod.rs
1mod key_events;
2 
3#[cfg(test)]
4mod drag_drop_tests;
5 
6use std::collections::HashMap;
7use std::mem::ManuallyDrop;
8 
9#[cfg(target_os = "linux")]
10use crate::notification::RequestPermissionsOutcome;
11 
12use futures_util::future::LocalBoxFuture;
13use futures_util::stream::AbortHandle;
14use instant::{Duration, Instant};
15use pathfinder_geometry::rect::RectF;
16use pathfinder_geometry::vector::{vec2f, Vector2F};
17use winit::dpi::{LogicalPosition, LogicalSize, PhysicalPosition};
18use winit::event::Ime as ImeEvent;
19use winit::event_loop::EventLoopProxy;
20use winit::keyboard::{self, KeyCode};
21use winit::window::WindowId as WinitWindowId;
22use winit::{
23 event::{ElementState, Event, MouseButton, StartCause, Touch, TouchPhase, WindowEvent},
24 event_loop::{ActiveEventLoop, ControlFlow},
25};
26 
27use crate::actions::StandardAction;
28use crate::event::ModifiersState;
29use crate::platform::NotificationInfo;
30use crate::platform::OperatingSystem;
31use crate::platform::{
32 self,
33 app::{AppCallbackDispatcher, ApproveTerminateResult},
34 TerminationMode, WindowContext,
35};
36use crate::r#async::Timer;
37use crate::rendering::wgpu::renderer;
38use crate::windowing::winit::app::RequestPermissionsCallback;
39use crate::windowing::winit::window::MIN_WINDOW_SIZE;
40use crate::Event::{ClearMarkedText, SetMarkedText, TypedCharacters};
41use crate::{AppContext, WindowId};
42 
43#[cfg(target_family = "wasm")]
44use wasm_bindgen::JsCast;
45 
46use super::app::ClipboardEvent;
47use super::window::DEFAULT_TITLEBAR_HEIGHT;
48use super::CustomEvent;
49 
50#[cfg(windows)]
51use super::windows::{add_network_connection_listener, WindowsNetworkConnectionPoint};
52 
53use self::key_events::convert_keyboard_input_event;
54 
55/// This is the time duration beyond which clicks get treated as separate single clicks instead of
56/// double-click, triple-click, etc.
57const MULTI_CLICK_INTERVAL: Duration = Duration::from_millis(400);
58 
59/// The debounce timeout for drag-and-drop files. Multiple DroppedFile events
60/// are received within this time window and then combined into a single DragAndDropFiles event.
61/// This timeout ensures all files in a multi-file drag operation are batched together efficiently.
62const DRAG_DROP_DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(50);
63 
64/// Distance (in logical pixels) before a touch input is considered a drag. Flutter uses 18.
65const MAX_TAP_DISTANCE: f64 = 18.;
66 
67/// Duration to hold before a touch becomes a right-click (context menu).
68/// Matches the iOS and Android platform default of 500ms.
69const LONG_PRESS_DURATION: Duration = Duration::from_millis(500);
70 
71/// Momentum scrolling configuration. Math is as follows:
72/// Each tick (every MOMENTUM_FRAME_INTERVAL):
73///
74/// 1. Decay velocity: v = v * MOMENTUM_DECAY^(elapsed / MOMENTUM_DECAY_INTERVAL)
75/// 2. Check stop condition: if |v| < MOMENTUM_MIN_VELOCITY, cancel the animation.
76/// 3. Compute scroll delta: delta = v * elapsed
77///
78/// Decay is between iOS normal (0.984/8ms) and fast (0.923/8ms).
79const MOMENTUM_DECAY: f32 = 0.968; // Every interval, velocity is multiplied by this factor.
80const MOMENTUM_DECAY_INTERVAL: f32 = 0.008; // Time period (seconds) over which MOMENTUM_DECAY is applied
81const MOMENTUM_FRAME_INTERVAL: Duration = Duration::from_millis(8); //Controls how often the momentum scroll tick fires.
82 // Higher values means it fires less often (choppier)
83const MOMENTUM_THRESHOLD: f32 = 50.0; // Min-velocity to start momentum scroll, Android standards
84const MOMENTUM_MIN_VELOCITY: f32 = 1.0; // When velocity falls below this, scrolling stops. 1.0 is subpixel
85const MOMENTUM_MAX_VELOCITY: f32 = 2000.0; // Hard cap on momentum initial velocity (px/s)
86const MIN_VELOCITY_TIME_DELTA: f32 = 0.004; // Floor for time deltas to prevent spikes from batched events
87 
88/// TryFrom implementation for converting winit's `KeyCode` to
89/// `crate::platform::keyboard::KeyCode`.
90/// Only converts modifier keys and fails for all other keys.
91fn try_from_winit_keycode(keycode: &KeyCode) -> Result<crate::platform::keyboard::KeyCode, ()> {
92 match keycode {
93 KeyCode::AltLeft => Ok(crate::platform::keyboard::KeyCode::AltLeft),
94 KeyCode::AltRight => Ok(crate::platform::keyboard::KeyCode::AltRight),
95 KeyCode::ShiftLeft => Ok(crate::platform::keyboard::KeyCode::ShiftLeft),
96 KeyCode::ShiftRight => Ok(crate::platform::keyboard::KeyCode::ShiftRight),
97 KeyCode::ControlLeft => Ok(crate::platform::keyboard::KeyCode::ControlLeft),
98 KeyCode::ControlRight => Ok(crate::platform::keyboard::KeyCode::ControlRight),
99 KeyCode::SuperLeft => Ok(crate::platform::keyboard::KeyCode::SuperLeft),
100 KeyCode::SuperRight => Ok(crate::platform::keyboard::KeyCode::SuperRight),
101 // Note that the Fn key is not well identified on Windows laptops (e.g.
102 // winit wasn't able to identify it correctly on ThinkPad). But, if it's
103 // identified, we still pass it on to the UI framework.
104 KeyCode::Fn => Ok(crate::platform::keyboard::KeyCode::Fn),
105 _ => Err(()),
106 }
107}
108 
109/// Data needed to detect double/triple-click.
110struct MouseButtonPressState {
111 pressed_at: Instant,
112 button_pressed: MouseButton,
113 click_count: u32,
114}
115 
116#[derive(Debug)]
117/// Purpose of the touch event.
118enum TouchPurpose {
119 Select,
120 Scroll(Touch),
121 /// A tap that hasn't yet been classified.
122 /// Stores (initial touch, click count, start time for long-press detection).
123 Tap(Touch, u32, Instant),
124 /// Dragging the window via touch in the titlebar region.
125 /// Stores the starting touch position (window-relative).
126 WindowDrag {
127 start_touch: PhysicalPosition<f64>,
128 },
129}
130 
131/// Tracks scroll velocity during active touch scrolling and momentum scrolling.
132#[derive(Debug, Clone, Copy)]
133struct ScrollVelocity {
134 velocity: Vector2F,
135 last_update: Instant,
136}
137 
138/// The set of state we need to track per-window across frames.
139struct WindowState {
140 /// The UI framework's identifier for the window in question (not to be
141 /// confused with winit's identifer for the window).
142 window_id: crate::WindowId,
143 /// The last known modifier key (ctr/alt/etc) state.
144 modifiers: keyboard::ModifiersState,
145 /// Whether the left Alt key is currently pressed. `ModifiersState` does not distinguish
146 /// between left and right Alt, so we track per-side state by watching `KeyboardInput`
147 /// events for `KeyCode::AltLeft`/`KeyCode::AltRight`. Used by the extra-meta-keys setting
148 /// to apply the left/right-alt-as-meta preferences correctly.
149 left_alt_pressed: bool,
150 /// Whether the right Alt key is currently pressed. See [`Self::left_alt_pressed`].
151 right_alt_pressed: bool,
152 /// The currently-pressed mouse button, if any. Set back to None when the button is released.
153 ///
154 /// This ultimately should be a HashSet of mouse buttons, as more than one
155 /// can be held down at a time.
156 current_mouse_button_pressed: Option<MouseButton>,
157 /// The last mouse button pressed. This persists after the button is released because it needs
158 /// to keep that state to detect double/triple-click.
159 last_mouse_button_pressed: Option<MouseButtonPressState>,
160 /// The last known cursor position, measured in logical pixels.
161 last_cursor_position: winit::dpi::LogicalPosition<f32>,
162 /// Drag-and-drop files are received as separate DroppedFile events per file.
163 /// We collect them and debounce them to create a single consistent DragAndDropFiles event.
164 pending_drag_drop_files: Vec<String>,
165 /// Track if we have a debounce timer already running for this window.
166 has_pending_drag_drop_timer: bool,
167 /// The purpose of the last touch event.
168 last_touch_purpose: Option<TouchPurpose>,
169 /// Tracks scroll velocity during active touch scrolling and momentum scrolling.
170 /// Active phase determined through `momentum_scroll_abort.is_some()`.
171 scroll_velocity: Option<ScrollVelocity>,
172 /// Abort handle for momentum scrolling timer. Present only during the momentum phase.
173 momentum_scroll_abort: Option<AbortHandle>,
174 /// For touch events, stores whether soft keyboard was requested during LeftMouseDown.
175 /// This is needed because touch keyboard updates are deferred to LeftMouseUp, but the
176 /// UI element only requests the keyboard during LeftMouseDown.
177 #[cfg(target_family = "wasm")]
178 pending_soft_keyboard_request: bool,
179}
180 
181impl WindowState {
182 fn new(window_id: crate::WindowId) -> Self {
183 Self {
184 window_id,
185 modifiers: Default::default(),
186 left_alt_pressed: false,
187 right_alt_pressed: false,
188 current_mouse_button_pressed: None,
189 last_mouse_button_pressed: None,
190 last_cursor_position: Default::default(),
191 pending_drag_drop_files: Vec::new(),
192 has_pending_drag_drop_timer: false,
193 last_touch_purpose: None,
194 scroll_velocity: None,
195 momentum_scroll_abort: None,
196 #[cfg(target_family = "wasm")]
197 pending_soft_keyboard_request: false,
198 }
199 }
200 
201 /// Cancels ongoing momentum scroll, clearing both the animation timer and velocity state.
202 fn cancel_momentum_scroll(&mut self) {
203 if let Some(abort_handle) = self.momentum_scroll_abort.take() {
204 abort_handle.abort();
205 }
206 self.scroll_velocity = None;
207 }
208 
209 /// When a mouse button is pressed, save it to [`Self::current_mouse_button_pressed`] so that
210 /// we can detect dragging. Also save it to [`Self::last_mouse_button_pressed`] for
211 /// double/triple-click. Returns the calculated click_count.
212 fn determine_click_count_and_update_button_state(&mut self, button: MouseButton) -> u32 {
213 self.current_mouse_button_pressed = Some(button);
214 let now = Instant::now();
215 // Increment the click_count if the button type is the same and the duration is faster than
216 // MULTI_CLICK_INTERVAL.
217 let click_count = self
218 .last_mouse_button_pressed
219 .take()
220 .filter(|old_state| {
221 old_state.button_pressed == button
222 && now.duration_since(old_state.pressed_at) <= MULTI_CLICK_INTERVAL
223 })
224 .map(|old_state| old_state.click_count + 1)
225 .unwrap_or(1);
226 let new_state = MouseButtonPressState {
227 pressed_at: now,
228 button_pressed: button,
229 click_count,
230 };
231 self.last_mouse_button_pressed = Some(new_state);
232 click_count
233 }
234}
235 
236/// An extension trait to add helpful methods to [`winit::dpi::LogicalPosition`].
237trait LogicalPositionExt {
238 /// Converts the [`LogicalPosition`] into a [`Vector2F`].
239 fn to_vec2f(&self) -> Vector2F;
240}
241 
242impl LogicalPositionExt for winit::dpi::LogicalPosition<f32> {
243 fn to_vec2f(&self) -> Vector2F {
244 Vector2F::new(self.x, self.y)
245 }
246}
247 
248/// The state that we need to track across frames in order to properly convert
249/// winit events into strato_ui events.
250#[derive(Default)]
251struct State {
252 windows: HashMap<winit::window::WindowId, WindowState>,
253 pending_active_window_change: Option<ActiveWindowChange>,
254 
255 #[cfg(windows)]
256 network_connection_listener: Option<WindowsNetworkConnectionPoint>,
257}
258 
259/// This enum holds the state needed to convert multiple emitted
260/// [`winit::event::WindowEvent::Focused`] events into a single
261/// [`CustomEvent::ActiveWindowChanged`].
262#[derive(Copy, Clone, Debug)]
263enum ActiveWindowChange {
264 /// When we see `WindowEvent::Focused(false)` from winit, we store this variant.
265 FocusOut,
266 /// When we see `WindowEvent::Focused(true)` from winit, we store this variant and save the ID
267 /// of the newly focused window.
268 FocusIn(winit::window::WindowId),
269}
270 
271fn from_winit_modifiers_state(state: keyboard::ModifiersState) -> ModifiersState {
272 ModifiersState {
273 alt: state.alt_key(),
274 cmd: state.super_key(),
275 shift: state.shift_key(),
276 ctrl: state.control_key(),
277 // TODO(advait): Implement the function key for winit.
278 // Note there is no function_key() function to use here.
279 func: false,
280 }
281}
282 
283/// Handles the `TouchPhase::Started` phase of a touch event.
284///
285/// Emits a `LeftMouseDown` event to begin a potential tap, selection, scroll, or window drag.
286fn convert_touch_started(
287 touch: Touch,
288 window_state: &mut WindowState,
289 scale_factor: f32,
290) -> Option<ConvertedEvent> {
291 if window_state.last_touch_purpose.is_some() {
292 return None;
293 }
294 
295 // Cancel any ongoing momentum scroll when user touches screen (iOS behavior).
296 window_state.cancel_momentum_scroll();
297 
298 window_state.last_cursor_position = touch.location.to_logical(scale_factor as f64);
299 let click_count = window_state.determine_click_count_and_update_button_state(MouseButton::Left);
300 window_state.current_mouse_button_pressed = None;
301 
302 // Store click_count and start time for double-tap and long-press detection.
303 window_state.last_touch_purpose = Some(TouchPurpose::Tap(touch, click_count, Instant::now()));
304 
305 Some(ConvertedEvent::Event(crate::event::Event::LeftMouseDown {
306 position: window_state.last_cursor_position.to_vec2f(),
307 click_count,
308 is_first_mouse: false,
309 modifiers: from_winit_modifiers_state(window_state.modifiers),
310 }))
311}
312 
313/// Handles the `TouchPhase::Moved` phase of a touch event.
314fn convert_touch_moved(
315 touch: Touch,
316 window_state: &mut WindowState,
317 scale_factor: f32,
318 titlebar_height: f32,
319) -> Option<ConvertedEvent> {
320 match window_state.last_touch_purpose {
321 Some(TouchPurpose::Tap(last_touch, click_count, start_time)) => {
322 // Compute deltas in logical pixels for consistent behavior across DPI settings.
323 let current_logical: LogicalPosition<f64> =
324 touch.location.to_logical(scale_factor as f64);
325 let last_logical: LogicalPosition<f64> =
326 last_touch.location.to_logical(scale_factor as f64);
327 let delta_x = current_logical.x - last_logical.x;
328 let delta_y = current_logical.y - last_logical.y;
329 
330 // Not moved enough to classify gesture yet
331 if delta_x.abs() <= MAX_TAP_DISTANCE && delta_y.abs() <= MAX_TAP_DISTANCE {
332 return None;
333 }
334 
335 window_state.last_cursor_position = touch.location.to_logical(scale_factor as f64);
336 
337 // Double-tap + drag = text selection
338 if click_count >= 2 {
339 window_state.last_touch_purpose = Some(TouchPurpose::Select);
340 return Some(ConvertedEvent::Event(
341 crate::event::Event::LeftMouseDragged {
342 position: window_state.last_cursor_position.to_vec2f(),
343 modifiers: from_winit_modifiers_state(window_state.modifiers),
344 },
345 ));
346 }
347 
348 // Touch in titlebar = window drag
349 let initial_pos: winit::dpi::LogicalPosition<f32> =
350 last_touch.location.to_logical(scale_factor as f64);
351 if initial_pos.y < titlebar_height && !cfg!(target_family = "wasm") {
352 let start_touch = last_touch.location;
353 window_state.last_touch_purpose = Some(TouchPurpose::WindowDrag { start_touch });
354 return Some(ConvertedEvent::MoveWindowBy {
355 current_touch: touch.location,
356 start_touch,
357 });
358 }
359 
360 // Single tap + swipe = scroll (default)
361 window_state.last_touch_purpose = Some(TouchPurpose::Scroll(last_touch));
362 let elapsed = start_time
363 .elapsed()
364 .as_secs_f32()
365 .max(MIN_VELOCITY_TIME_DELTA);
366 window_state.scroll_velocity = Some(ScrollVelocity {
367 velocity: Vector2F::new(delta_x as f32, delta_y as f32) / elapsed,
368 last_update: Instant::now(),
369 });
370 Some(ConvertedEvent::Event(crate::event::Event::ScrollWheel {
371 position: window_state.last_cursor_position.to_vec2f(),
372 delta: Vector2F::new(delta_x as f32, delta_y as f32),
373 precise: true,
374 modifiers: from_winit_modifiers_state(window_state.modifiers),
375 }))
376 }
377 Some(TouchPurpose::Scroll(last_touch)) => {
378 // Continue scrolling. Use logical pixels for consistent scroll speed across DPI.
379 window_state.last_touch_purpose = Some(TouchPurpose::Scroll(touch));
380 let current_logical: LogicalPosition<f64> =
381 touch.location.to_logical(scale_factor as f64);
382 let last_logical: LogicalPosition<f64> =
383 last_touch.location.to_logical(scale_factor as f64);
384 let delta_x = current_logical.x - last_logical.x;
385 let delta_y = current_logical.y - last_logical.y;
386 window_state.last_cursor_position = current_logical.cast();
387 
388 // Update velocity for momentum scrolling.
389 let now = Instant::now();
390 let delta = Vector2F::new(delta_x as f32, delta_y as f32);
391 let time_delta = window_state
392 .scroll_velocity
393 .map(|v| now.duration_since(v.last_update).as_secs_f32())
394 .unwrap_or(MOMENTUM_DECAY_INTERVAL)
395 .max(MIN_VELOCITY_TIME_DELTA);
396 window_state.scroll_velocity = Some(ScrollVelocity {
397 velocity: delta / time_delta,
398 last_update: now,
399 });
400 
401 Some(ConvertedEvent::Event(crate::event::Event::ScrollWheel {
402 position: window_state.last_cursor_position.to_vec2f(),
403 delta,
404 precise: true,
405 modifiers: from_winit_modifiers_state(window_state.modifiers),
406 }))
407 }
408 Some(TouchPurpose::Select) => {
409 // Continue selecting.
410 window_state.last_cursor_position = touch.location.to_logical(scale_factor as f64);
411 Some(ConvertedEvent::Event(
412 crate::event::Event::LeftMouseDragged {
413 position: window_state.last_cursor_position.to_vec2f(),
414 modifiers: from_winit_modifiers_state(window_state.modifiers),
415 },
416 ))
417 }
418 Some(TouchPurpose::WindowDrag { start_touch }) => Some(ConvertedEvent::MoveWindowBy {
419 current_touch: touch.location,
420 start_touch,
421 }),
422 None => None,
423 }
424}
425 
426/// Handles the `TouchPhase::Ended` phase of a touch event.
427///
428/// Note: This function intentionally does NOT clear `last_touch_purpose` for normal taps.
429/// The purpose is cleared later in `handle_converted_warpui_event` after soft keyboard
430/// logic runs, which needs to check if the touch was still a Tap (vs Scroll/Select/WindowDrag).
431fn convert_touch_ended(
432 touch: Touch,
433 window_state: &mut WindowState,
434 scale_factor: f32,
435) -> Option<ConvertedEvent> {
436 // Check the purpose but don't clear it yet - we'll clear it later
437 // in handle_converted_warpui_event after checking if we need to
438 // update the soft keyboard.
439 let is_long_press =
440 if let Some(TouchPurpose::Tap(_, _, start_time)) = &window_state.last_touch_purpose {
441 start_time.elapsed() >= LONG_PRESS_DURATION
442 } else {
443 false
444 };
445 
446 let is_window_drag = matches!(
447 &window_state.last_touch_purpose,
448 Some(TouchPurpose::WindowDrag { .. })
449 );
450 
451 window_state.last_cursor_position = touch.location.to_logical(scale_factor as f64);
452 window_state.current_mouse_button_pressed = None;
453 
454 // Long press: still in Tap state and held longer than LONG_PRESS_DURATION
455 if is_long_press {
456 // Clear the purpose here since we're returning early
457 window_state.last_touch_purpose = None;
458 return Some(ConvertedEvent::Event(crate::event::Event::RightMouseDown {
459 position: window_state.last_cursor_position.to_vec2f(),
460 cmd: window_state.modifiers.super_key(),
461 shift: window_state.modifiers.shift_key(),
462 click_count: 1,
463 }));
464 }
465 
466 // WindowDrag doesn't need a mouse up event
467 if is_window_drag {
468 // Clear the purpose here since we're returning early
469 window_state.last_touch_purpose = None;
470 return None;
471 }
472 
473 // Don't clear last_touch_purpose yet - it will be cleared in
474 // handle_converted_warpui_event after soft keyboard logic runs.
475 Some(ConvertedEvent::Event(crate::event::Event::LeftMouseUp {
476 position: window_state.last_cursor_position.to_vec2f(),
477 modifiers: from_winit_modifiers_state(window_state.modifiers),
478 }))
479}
480 
481/// Handles the `TouchPhase::Cancelled` phase of a touch event.
482///
483/// Cancelled touches only clean up state without triggering any action events.
484fn convert_touch_cancelled(
485 touch: Touch,
486 window_state: &mut WindowState,
487 scale_factor: f32,
488) -> Option<ConvertedEvent> {
489 window_state.last_touch_purpose.take();
490 window_state.last_cursor_position = touch.location.to_logical(scale_factor as f64);
491 window_state.current_mouse_button_pressed = None;
492 None
493}
494 
495/// A structure to manage state
496/// [`winit::event_loop::EventLoop`] and generate the appropriate callbacks into
497/// the UI framework.
498pub(super) struct EventLoop {
499 ui_app: crate::App,
500 callbacks: AppCallbackDispatcher,
501 init_fn: Option<platform::app::AppInitCallbackFn>,
502 window_class: Option<String>,
503 state: State,
504 proxy: EventLoopProxy<CustomEvent>,
505 ime_enabled: bool,
506 /// Whether to downrank non-NVIDIA vulkan adapters. This is set to true when we detect a DRI3
507 /// error that occurs when trying to present against a non-NVIDIA Vulkan adapter when the
508 /// PRIME Profile is set to "Performance" mode. It's not fully clear why this error occurs. Our
509 /// theory is that when the PRIME performance profile is enabled (which indicates to NVIDIA
510 /// Optimus that the machine should _only_ render to the NVIDIA GPU), Optimus determines that
511 /// the Integrated GPU won't be rendered to and puts it in an idle / partially loaded state that
512 /// will eventually trigger these DRI3 `BadMatch` errors when we attempt to render to it.
513 downrank_non_nvidia_vulkan_adapters: bool,
514 /// Soft keyboard manager for mobile WASM.
515 #[cfg(target_family = "wasm")]
516 soft_keyboard_manager: Option<std::rc::Rc<crate::platform::wasm::SoftKeyboardManager>>,
517}
518 
519impl EventLoop {
520 pub fn new(
521 ui_app: crate::App,
522 callbacks: platform::AppCallbacks,
523 init_fn: impl FnOnce(&mut AppContext, LocalBoxFuture<'static, crate::App>) + 'static,
524 window_class: Option<String>,
525 proxy: EventLoopProxy<CustomEvent>,
526 ) -> Self {
527 Self {
528 ui_app: ui_app.clone(),
529 callbacks: AppCallbackDispatcher::new(callbacks, ui_app),
530 init_fn: Some(Box::new(init_fn)),
531 window_class,
532 state: Default::default(),
533 proxy,
534 ime_enabled: false,
535 downrank_non_nvidia_vulkan_adapters: false,
536 #[cfg(target_family = "wasm")]
537 soft_keyboard_manager: None,
538 }
539 }
540 
541 /// Handles a single [`winit::event::Event`].
542 pub fn handle_event(&mut self, evt: Event<CustomEvent>, window_target: &ActiveEventLoop) {
543 window_target.set_control_flow(ControlFlow::Wait);
544 
545 match evt {
546 Event::NewEvents(StartCause::Init) => {
547 #[cfg(target_os = "linux")]
548 {
549 let windowing_system =
550 if winit::platform::x11::ActiveEventLoopExtX11::is_x11(window_target) {
551 crate::windowing::WindowingSystem::X11
552 } else {
553 crate::windowing::WindowingSystem::Wayland
554 };
555 log::info!("Running app with windowing system: {windowing_system:?}");
556 if let Err(err) = super::app::WINDOWING_SYSTEM.set(windowing_system) {
557 log::warn!("Could not set global static for windowing system: {err:?}");
558 }
559 }
560 
561 if let Some(init_fn) = self.init_fn.take() {
562 self.callbacks.initialize_app(init_fn);
563 }
564 
565 // Start listening for various platform events.
566 #[cfg(target_os = "linux")]
567 {
568 super::linux::watch_suspend_resume_changes(
569 self.proxy.clone(),
570 &self.ui_app.background_executor(),
571 );
572 super::linux::watch_desktop_settings_changes(
573 self.proxy.clone(),
574 &self.ui_app.background_executor(),
575 );
576 if self.callbacks.has_internet_reachability_changed_callback() {
577 super::linux::watch_network_status_changed(
578 self.proxy.clone(),
579 &self.ui_app.background_executor(),
580 );
581 }
582 }
583 
584 #[cfg(windows)]
585 match add_network_connection_listener(self.proxy.clone()) {
586 Ok(listener) => {
587 self.state.network_connection_listener = Some(listener);
588 }
589 Err(e) => {
590 log::warn!("Creating a network connection listener failed: {e:?}");
591 }
592 }
593 
594 // Initialize soft keyboard support on mobile WASM devices.
595 #[cfg(target_family = "wasm")]
596 {
597 self.initialize_soft_keyboard();
598 }
599 }
600 Event::UserEvent(CustomEvent::OpenWindow {
601 window_id,
602 window_options,
603 }) => {
604 let Some((window, is_tiling_window_manager)) = self.ui_app.update(|ctx| {
605 let window = ctx.windows().platform_window(window_id)?;
606 let is_tiling_window_manager = ctx.windows().is_tiling_window_manager();
607 Some((window, is_tiling_window_manager))
608 }) else {
609 return;
610 };
611 
612 let window = downcast_window(window.as_ref());
613 
614 match window.open_window(
615 window_target,
616 window_options,
617 &self.window_class,
618 is_tiling_window_manager,
619 self.downrank_non_nvidia_vulkan_adapters,
620 ) {
621 Ok(winit_window_id) => {
622 let window_state = WindowState::new(window_id);
623 self.state.windows.insert(winit_window_id, window_state);
624 // Now that the window has opened and we know its
625 // actual size, notify the framework that the window
626 // size may have (almost certainly) changed.
627 self.callbacks.for_window(window).window_resized(window);
628 }
629 Err(err) => {
630 log::error!("Failed to open window: {err:#}");
631 // Tell the app that the window is "closing".
632 self.callbacks.window_will_close(window_id);
633 }
634 }
635 }
636 Event::UserEvent(CustomEvent::RunTask(task)) => {
637 let task = ManuallyDrop::into_inner(task);
638 task.run();
639 }
640 Event::UserEvent(CustomEvent::Terminate(termination_mode)) => {
641 if let ApproveTerminateResult::Terminate =
642 self.terminate_app_requested(termination_mode)
643 {
644 window_target.exit();
645 }
646 }
647 Event::UserEvent(CustomEvent::UpdateUIApp(callback)) => {
648 self.ui_app.update(callback);
649 }
650 Event::UserEvent(CustomEvent::GlobalShortcutTriggered(shortcut)) => {
651 self.callbacks.global_shortcut_triggered(shortcut)
652 }
653 Event::UserEvent(CustomEvent::CloseWindow {
654 window_id,
655 termination_mode,
656 }) => {
657 if let Some(winit_window_id) = self
658 .state
659 .windows
660 .iter()
661 .find(|(_, v)| v.window_id == window_id)
662 .map(|(k, _)| k)
663 .cloned()
664 {
665 self.close_window_requested(
666 window_id,
667 winit_window_id,
668 termination_mode,
669 window_target,
670 )
671 }
672 }
673 Event::UserEvent(CustomEvent::ActiveWindowChanged) => {
674 let app_was_active = self
675 .ui_app
676 .read(|ctx| ctx.windows().active_window())
677 .is_some();
678 
679 let active_window_id = match self.state.pending_active_window_change.take() {
680 None => return,
681 Some(ActiveWindowChange::FocusOut) => None,
682 Some(ActiveWindowChange::FocusIn(window_id)) => Some(window_id),
683 };
684 let active_window_id = active_window_id
685 .and_then(|window_id| self.state.windows.get(&window_id))
686 .map(|state| state.window_id);
687 self.callbacks.active_window_changed(active_window_id);
688 
689 // If the application became active or inactive, invoke the appropriate callback.
690 let app_is_active = active_window_id.is_some();
691 match (app_was_active, app_is_active) {
692 (false, true) => self.callbacks.app_became_active(),
693 (true, false) => self.callbacks.app_resigned_active(),
694 _ => {}
695 };
696 }
697 Event::UserEvent(CustomEvent::RequestUserAttention { window_id }) => {
698 self.ui_app.update(|ctx| {
699 if ctx.windows().active_window() == Some(window_id) {
700 // The current window is already active, early return since requesting user attention would be
701 // a noop.
702 return;
703 }
704 
705 let Some(window) = ctx.windows().platform_window(window_id) else {
706 return;
707 };
708 
709 let window = downcast_window(window.as_ref());
710 window.request_user_attention();
711 
712 // To mimic the behavior on Mac, we only request user attention for 1 second before then stopping
713 // the request. This is especially needed on x11 since the app icon will bounce in perpetuity until
714 // is explicitly told to stop.
715 let event_loop_proxy = self.proxy.clone();
716 ctx.foreground_executor()
717 .spawn(async move {
718 Timer::after(Duration::from_secs(1)).await;
719 let _ = event_loop_proxy
720 .send_event(CustomEvent::StopRequestingUserAttention { window_id });
721 })
722 .detach();
723 });
724 }
725 Event::UserEvent(CustomEvent::StopRequestingUserAttention { window_id }) => {
726 self.ui_app.update(|ctx| {
727 let Some(window) = ctx.windows().platform_window(window_id) else {
728 return;
729 };
730 
731 let window = downcast_window(window.as_ref());
732 window.stop_requesting_user_attention();
733 });
734 }
735 Event::UserEvent(CustomEvent::Clipboard(clipboard_event)) => {
736 self.handle_clipboard_event(clipboard_event);
737 }
738 Event::UserEvent(CustomEvent::SetCursorShape(cursor)) => {
739 self.ui_app.update(|ctx| {
740 let Some(window_id) = ctx.windows().active_window() else {
741 return;
742 };
743 
744 let Some(window) = ctx.windows().platform_window(window_id) else {
745 return;
746 };
747 
748 let winit_window = downcast_window(window.as_ref());
749 winit_window.set_cursor_icon(cursor);
750 });
751 }
752 Event::UserEvent(CustomEvent::ActiveCursorPositionUpdated) => {
753 if self.ime_enabled {
754 self.update_ime_position();
755 }
756 }
757 Event::UserEvent(CustomEvent::AboutToSleep) => {
758 #[cfg(target_os = "linux")]
759 self.prepare_for_sleep_on_linux(window_target);
760 
761 self.callbacks.cpu_will_sleep();
762 }
763 Event::UserEvent(CustomEvent::ResumedFromSleep) => {
764 #[cfg(target_os = "linux")]
765 self.resume_from_sleep_on_linux();
766 
767 self.callbacks.cpu_awakened();
768 }
769 
770 Event::UserEvent(CustomEvent::InternetConnected) => {
771 self.callbacks.internet_reachability_changed(true);
772 }
773 Event::UserEvent(CustomEvent::InternetDisconnected) => {
774 self.callbacks.internet_reachability_changed(false)
775 }
776 Event::UserEvent(CustomEvent::SystemThemeChanged) => {
777 self.callbacks.os_appearance_changed();
778 }
779 Event::WindowEvent {
780 window_id: _,
781 event: WindowEvent::ThemeChanged(_new_theme),
782 } => {
783 self.callbacks.os_appearance_changed();
784 }
785 Event::UserEvent(CustomEvent::SendNotification {
786 notification_info,
787 window_id,
788 }) => {
789 self.send_notification(notification_info, window_id);
790 }
791 Event::UserEvent(CustomEvent::FocusWindow { window_id }) => {
792 self.focus_window(window_id);
793 }
794 Event::UserEvent(CustomEvent::RequestNotificationPermissions(callback)) => {
795 self.request_notification_permissions(callback);
796 }
797 Event::UserEvent(CustomEvent::DragAndDropFilesDebounced { window_id }) => {
798 self.handle_debounced_drag_drop(window_id);
799 }
800 #[cfg(target_family = "wasm")]
801 Event::UserEvent(CustomEvent::SoftKeyboardInput(input)) => {
802 self.handle_soft_keyboard_input(input);
803 }
804 #[cfg(target_family = "wasm")]
805 Event::UserEvent(CustomEvent::VisualViewportResized { width, height }) => {
806 self.handle_visual_viewport_resize(width, height);
807 }
808 Event::UserEvent(CustomEvent::MomentumScroll { window_id }) => {
809 let Some(window_state) = self.state.windows.get_mut(&window_id) else {
810 return;
811 };
812 let Some(mut velocity) = window_state.scroll_velocity else {
813 return;
814 };
815 
816 let now = Instant::now();
817 let elapsed = now.duration_since(velocity.last_update).as_secs_f32();
818 velocity.last_update = now;
819 
820 // Apply time-based decay: v_new = v_old * decay^(Δt/interval)
821 let decay_factor = MOMENTUM_DECAY.powf(elapsed / MOMENTUM_DECAY_INTERVAL);
822 velocity.velocity *= decay_factor;
823 
824 if velocity.velocity.length() < MOMENTUM_MIN_VELOCITY {
825 window_state.cancel_momentum_scroll();
826 return;
827 }
828 
829 window_state.scroll_velocity = Some(velocity);
830 
831 // Convert velocity (px/sec) to scroll delta using elapsed time.
832 let delta = velocity.velocity * elapsed;
833 let position = window_state.last_cursor_position.to_vec2f();
834 self.handle_converted_warpui_event(
835 window_id,
836 crate::event::Event::ScrollWheel {
837 position,
838 delta,
839 precise: true,
840 modifiers: ModifiersState::default(),
841 },
842 );
843 }
844 Event::WindowEvent {
845 window_id,
846 event: WindowEvent::RedrawRequested,
847 } => self.redraw_window(window_id, window_target),
848 Event::WindowEvent {
849 window_id: winit_window_id,
850 event: WindowEvent::CloseRequested,
851 } => {
852 if let Some(state) = self.state.windows.get(&winit_window_id) {
853 self.close_window_requested(
854 state.window_id,
855 winit_window_id,
856 TerminationMode::Cancellable,
857 window_target,
858 );
859 }
860 }
861 Event::WindowEvent {
862 event: WindowEvent::Destroyed,
863 ..
864 } => {
865 // TODO(vorporeal): Should we be calling approve_termination() here?
866 // i.e.: should we invoke a helper shared with CustomEvent::Terminate?
867 if cfg!(not(target_os = "macos")) && self.state.windows.is_empty() {
868 window_target.exit();
869 }
870 
871 // When a window loses focus, winit will emit [`WindowEvent::Focused(false)`].
872 // However, that doesn't fire when a window is closed. So, we trigger that code path
873 // from here to make sure the app knows to update its active window.
874 self.state.pending_active_window_change = Some(ActiveWindowChange::FocusOut);
875 let _ = self.proxy.send_event(CustomEvent::ActiveWindowChanged);
876 }
877 Event::WindowEvent {
878 window_id,
879 event: WindowEvent::Ime(evt),
880 } => {
881 self.handle_ime_event(window_id, evt);
882 }
883 Event::WindowEvent {
884 window_id,
885 event:
886 WindowEvent::ScaleFactorChanged {
887 scale_factor,
888 mut inner_size_writer,
889 },
890 } => {
891 // The following correction is only needed on Windows.
892 if !cfg!(windows) {
893 return;
894 }
895 let Some(window_state) = self.state.windows.get(&window_id) else {
896 return;
897 };
898 let Some(window) = self
899 .ui_app
900 .read(|ctx| ctx.windows().platform_window(window_state.window_id))
901 else {
902 return;
903 };
904 
905 // There is a winit bug such that events which cause a window to switch displays to
906 // one with a different scale factor resize the Warp window to an absurdly small
907 // size, <157, 25> on my system when I repro it. Events include unplugging a
908 // display, changing a display from extended to mirrored, and the like. We work
909 // around that by listening for [`WindowEvent::ScaleFactorChanged`] and changing
910 // the size back up to the minimum dimensions.
911 let size = window.as_ctx().size();
912 let mut size = LogicalSize::new(size.x() as f64, size.y() as f64);
913 let mut request_new_size = false;
914 if size.width < MIN_WINDOW_SIZE.width {
915 size.width = MIN_WINDOW_SIZE.width;
916 request_new_size = true;
917 }
918 if size.height < MIN_WINDOW_SIZE.height {
919 size.height = MIN_WINDOW_SIZE.height;
920 request_new_size = true;
921 }
922 if request_new_size {
923 if let Err(err) =
924 inner_size_writer.request_inner_size(size.to_physical(scale_factor))
925 {
926 log::warn!("unable to correct window size: {err:#}");
927 }
928 }
929 }
930 Event::WindowEvent { window_id, event } => self.handle_window_event(window_id, event),
931 Event::LoopExiting => {
932 // Hide all open windows such that, if the application takes a
933 // second or two to clean up before exiting, this isn't visible
934 // to the end user.
935 self.ui_app.update(|ctx| {
936 use crate::SingletonEntity as _;
937 crate::windowing::WindowManager::handle(ctx).update(
938 ctx,
939 |window_manager, _| {
940 window_manager.hide_app();
941 },
942 );
943 });
944 
945 #[cfg(windows)]
946 if let Some(network_listener) = self.state.network_connection_listener.take() {
947 network_listener.clean_up();
948 }
949 
950 self.callbacks.app_will_terminate();
951 
952 // On non-web platforms, immediately terminate the process instead of returning
953 // from the event loop. This matches the behavior of
954 // `[NSApp terminate]` on macOS, and may avoid some at-exit
955 // crashes that produce noise in our crash reporting data.
956 // On web, it's not possible to exit cleanly, so just return from the event loop
957 // instead.
958 #[cfg(not(target_family = "wasm"))]
959 std::process::exit(0);
960 }
961 
962 _ => {}
963 }
964 }
965 
966 fn redraw_window(
967 &mut self,
968 window_id: winit::window::WindowId,
969 window_target: &ActiveEventLoop,
970 ) {
971 let Some(window_id) = self
972 .state
973 .windows
974 .get(&window_id)
975 .map(|state| state.window_id)
976 else {
977 log::warn!("Redraw requested for a window for which we have no state");
978 return;
979 };
980 let Some(window) = self
981 .ui_app
982 .read(|ctx| ctx.windows().platform_window(window_id))
983 else {
984 log::warn!("Unable to retrieve platform window from app");
985 return;
986 };
987 
988 let window = downcast_window(window.as_ref());
989 
990 #[cfg(target_os = "linux")]
991 if crate::windowing::winit::linux::take_encountered_bad_match_from_dri3_fence_from_fd() {
992 log::warn!("Encountered a DRI3FenceFromFd error, forcing use of the NVIDIA GPU and recreating resources...");
993 self.downrank_non_nvidia_vulkan_adapters = true;
994 
995 self.ui_app.update(|ctx| {
996 for window_id in ctx.window_ids() {
997 let Some(window) = ctx.windows().platform_window(window_id) else {
998 return;
999 };
1000 
1001 let winit_window = downcast_window(window.as_ref());
1002 winit_window.recreate_renderer(self.downrank_non_nvidia_vulkan_adapters);
1003 }
1004 })
1005 }
1006 
1007 let render_result = (|| {
1008 // Before building the scene, make sure the window size is up-to-date, to ensure
1009 // that the scene is built at a size that matches the size we're about to render at.
1010 window.update_size_if_needed()?;
1011 
1012 let new_scene = if !window.has_scene() {
1013 Some(self.callbacks.for_window(window).build_scene(window))
1014 } else {
1015 None
1016 };
1017 
1018 self.callbacks
1019 .with_mutable_app_context(|ctx| window.render(new_scene, ctx.font_cache()))
1020 })();
1021 
1022 match render_result {
1023 Ok(_) => self.callbacks.for_window(window).frame_drawn(),
1024 Err(err) => {
1025 log::warn!("Failed to render frame: {err:#}");
1026 self.callbacks.for_window(window).frame_failed_to_draw();
1027 
1028 match err {
1029 // If we failed to configure the surface, or...
1030 renderer::Error::SurfaceConfigureError { .. }
1031 // If the device was lost, or...
1032 | renderer::Error::SurfaceError(renderer::GetSurfaceTextureError::Lost)
1033 | renderer::Error::DeviceLost
1034 // If we ran into any other wgpu error -
1035 | renderer::Error::Unknown(_)=> {
1036 log::warn!("Recreating the renderer in an attempt to recover...");
1037 window.drop_renderer(Box::new(window_target.owned_display_handle()));
1038 window.recreate_renderer(self.downrank_non_nvidia_vulkan_adapters);
1039 }
1040 _ => {}
1041 }
1042 }
1043 }
1044 }
1045 
1046 /// Handles a [`winit::event::WindowEvent`].
1047 fn handle_window_event(&mut self, window_id: winit::window::WindowId, evt: WindowEvent) {
1048 let Some(event) = self.convert_window_event(window_id, evt) else {
1049 return;
1050 };
1051 let Some(window_state) = self.state.windows.get_mut(&window_id) else {
1052 return;
1053 };
1054 let Some(window) = self
1055 .ui_app
1056 .read(|ctx| ctx.windows().platform_window(window_state.window_id))
1057 else {
1058 return;
1059 };
1060 
1061 match event {
1062 ConvertedEvent::Event(event) => {
1063 self.handle_converted_warpui_event(window_id, event);
1064 }
1065 ConvertedEvent::Resize => {
1066 let window = downcast_window(window.as_ref());
1067 window.handle_resize();
1068 self.callbacks.for_window(window).window_resized(window);
1069 self.callbacks.window_resized();
1070 }
1071 ConvertedEvent::ModifierKeyChanged { key_code, state } => {
1072 let mut window_callbacks = self.callbacks.for_window(window.as_ref());
1073 window_callbacks.dispatch_event(crate::event::Event::ModifierKeyChanged {
1074 key_code,
1075 state: match state {
1076 ElementState::Pressed => crate::event::KeyState::Pressed,
1077 ElementState::Released => crate::event::KeyState::Released,
1078 },
1079 });
1080 }
1081 ConvertedEvent::KeyDownWithTypedCharacters { chars, event } => {
1082 // To match the behavior of macOS: first try to dispatch the underlying keydown
1083 // event. If it was not handled (and doesn't include the cmd modifier), send a
1084 // `TypedCharacters` event. (We don't send `TypedCharacters` events for keypresess
1085 // that include the cmd key because they are assumed to be
1086 // intended as OS-level or application-level shortcuts. This matches the behavior
1087 // on macOS.)
1088 let cmd_pressed = match &event {
1089 crate::event::Event::KeyDown { keystroke, .. } => keystroke.cmd,
1090 _ => false,
1091 };
1092 
1093 let mut window_callbacks = self.callbacks.for_window(window.as_ref());
1094 let result = window_callbacks.dispatch_event(event);
1095 if !result.handled && !cmd_pressed {
1096 if let Some(chars) = chars {
1097 window_callbacks.dispatch_event(TypedCharacters { chars });
1098 }
1099 }
1100 }
1101 ConvertedEvent::WindowMoved { new_position } => {
1102 let window = downcast_window(window.as_ref());
1103 let scale_factor = self.ui_app.update(|ctx| {
1104 ctx.windows()
1105 .platform_window(window_state.window_id)
1106 .expect("window should exist")
1107 .backing_scale_factor()
1108 });
1109 let position = new_position.to_logical(scale_factor.into());
1110 let size = window.size();
1111 self.callbacks
1112 .for_window(window)
1113 .window_moved(RectF::new(vec2f(position.x, position.y), size));
1114 self.callbacks.window_moved();
1115 }
1116 ConvertedEvent::MoveWindowBy {
1117 current_touch,
1118 start_touch,
1119 } => {
1120 let winit_window = downcast_window(window.as_ref());
1121 if let Some(current_window) = winit_window.outer_position() {
1122 // target = current_window + (current_touch - start_touch)
1123 let target_x =
1124 (current_window.x as f64 + current_touch.x - start_touch.x) as i32;
1125 let target_y =
1126 (current_window.y as f64 + current_touch.y - start_touch.y) as i32;
1127 winit_window.set_outer_position(PhysicalPosition::new(target_x, target_y));
1128 }
1129 }
1130 }
1131 }
1132 
1133 /// Converts a [`winit::event::WindowEvent`] into a [`ConvertedEvent`], returning [`None`] if
1134 /// there is no equivalent/the event should be ignored.
1135 fn convert_window_event(
1136 &mut self,
1137 window_id: winit::window::WindowId,
1138 evt: winit::event::WindowEvent,
1139 ) -> Option<ConvertedEvent> {
1140 let window_state = self.state.windows.get_mut(&window_id)?;
1141 let scale_factor = self.ui_app.update(|ctx| {
1142 ctx.windows()
1143 .platform_window(window_state.window_id)
1144 .expect("window should exist")
1145 .backing_scale_factor()
1146 });
1147 match evt {
1148 WindowEvent::ModifiersChanged(modifiers) => {
1149 let state = modifiers.state();
1150 window_state.modifiers = state;
1151 // If Alt is no longer held at all, clear both per-side flags as a safety net
1152 // in case a key-release event was dropped (e.g. released while the window was
1153 // unfocused).
1154 if !state.alt_key() {
1155 window_state.left_alt_pressed = false;
1156 window_state.right_alt_pressed = false;
1157 }
1158 Some(ConvertedEvent::Event(
1159 crate::event::Event::ModifierStateChanged {
1160 mouse_position: window_state.last_cursor_position.to_vec2f(),
1161 modifiers: from_winit_modifiers_state(state),
1162 // TODO: when we need key codes for voice input on Linux/Windows, we'll need to populate this!
1163 key_code: None,
1164 },
1165 ))
1166 }
1167 WindowEvent::CursorMoved { position, .. } => {
1168 window_state.last_cursor_position = position.to_logical(scale_factor as f64);
1169 match window_state.current_mouse_button_pressed {
1170 Some(MouseButton::Left) => Some(ConvertedEvent::Event(
1171 crate::event::Event::LeftMouseDragged {
1172 position: window_state.last_cursor_position.to_vec2f(),
1173 modifiers: from_winit_modifiers_state(window_state.modifiers),
1174 },
1175 )),
1176 _ => Some(ConvertedEvent::Event(crate::event::Event::MouseMoved {
1177 position: window_state.last_cursor_position.to_vec2f(),
1178 cmd: window_state.modifiers.super_key(),
1179 shift: window_state.modifiers.shift_key(),
1180 is_synthetic: false,
1181 })),
1182 }
1183 }
1184 WindowEvent::MouseInput { state, button, .. } => match state {
1185 ElementState::Pressed => {
1186 let click_count =
1187 window_state.determine_click_count_and_update_button_state(button);
1188 match button {
1189 MouseButton::Left => {
1190 // ctrl-click should actually be registered as a right-click on mac
1191 let ctrl_click = window_state.modifiers.control_key();
1192 if ctrl_click && OperatingSystem::get().is_mac() {
1193 Some(ConvertedEvent::Event(crate::event::Event::RightMouseDown {
1194 position: window_state.last_cursor_position.to_vec2f(),
1195 cmd: window_state.modifiers.super_key(),
1196 shift: window_state.modifiers.shift_key(),
1197 click_count,
1198 }))
1199 } else {
1200 Some(ConvertedEvent::Event(crate::event::Event::LeftMouseDown {
1201 position: window_state.last_cursor_position.to_vec2f(),
1202 click_count,
1203 is_first_mouse: false,
1204 modifiers: from_winit_modifiers_state(window_state.modifiers),
1205 }))
1206 }
1207 }
1208 MouseButton::Right => {
1209 Some(ConvertedEvent::Event(crate::event::Event::RightMouseDown {
1210 position: window_state.last_cursor_position.to_vec2f(),
1211 cmd: window_state.modifiers.super_key(),
1212 shift: window_state.modifiers.shift_key(),
1213 click_count,
1214 }))
1215 }
1216 MouseButton::Middle => Some(ConvertedEvent::Event(
1217 crate::event::Event::MiddleMouseDown {
1218 position: window_state.last_cursor_position.to_vec2f(),
1219 cmd: window_state.modifiers.super_key(),
1220 shift: window_state.modifiers.shift_key(),
1221 click_count,
1222 },
1223 )),
1224 _ => None,
1225 }
1226 }
1227 ElementState::Released => {
1228 window_state.current_mouse_button_pressed = None;
1229 match button {
1230 MouseButton::Left => {
1231 Some(ConvertedEvent::Event(crate::event::Event::LeftMouseUp {
1232 position: window_state.last_cursor_position.to_vec2f(),
1233 modifiers: from_winit_modifiers_state(window_state.modifiers),
1234 }))
1235 }
1236 _ => None,
1237 }
1238 }
1239 },
1240 // Handle and convert touch events into mouse events.
1241 WindowEvent::Touch(touch) => match touch.phase {
1242 TouchPhase::Started => convert_touch_started(touch, window_state, scale_factor),
1243 TouchPhase::Moved => {
1244 let titlebar_height = self
1245 .ui_app
1246 .read(|ctx| ctx.windows().platform_window(window_state.window_id))
1247 .map(|w| downcast_window(w.as_ref()).titlebar_height())
1248 .unwrap_or(DEFAULT_TITLEBAR_HEIGHT);
1249 convert_touch_moved(touch, window_state, scale_factor, titlebar_height)
1250 }
1251 TouchPhase::Ended => convert_touch_ended(touch, window_state, scale_factor),
1252 TouchPhase::Cancelled => convert_touch_cancelled(touch, window_state, scale_factor),
1253 },
1254 WindowEvent::MouseWheel { delta, .. } => {
1255 let (precise, delta) = match delta {
1256 winit::event::MouseScrollDelta::LineDelta(horiz, vert) => {
1257 (false, Vector2F::new(horiz, vert))
1258 }
1259 winit::event::MouseScrollDelta::PixelDelta(px) => {
1260 (true, px.to_logical(scale_factor as f64).to_vec2f())
1261 }
1262 };
1263 Some(ConvertedEvent::Event(crate::event::Event::ScrollWheel {
1264 position: window_state.last_cursor_position.to_vec2f(),
1265 delta,
1266 precise,
1267 modifiers: from_winit_modifiers_state(window_state.modifiers),
1268 }))
1269 }
1270 WindowEvent::KeyboardInput {
1271 event,
1272 is_synthetic,
1273 ..
1274 } => {
1275 // Track per-side Alt press state so that the extra-meta-keys setting can
1276 // distinguish between left Alt and right Alt. `ModifiersState` alone does
1277 // not expose which side of a modifier was pressed.
1278 if let keyboard::PhysicalKey::Code(keycode) = &event.physical_key {
1279 let is_pressed = event.state == ElementState::Pressed;
1280 match keycode {
1281 KeyCode::AltLeft => window_state.left_alt_pressed = is_pressed,
1282 KeyCode::AltRight => window_state.right_alt_pressed = is_pressed,
1283 _ => {}
1284 }
1285 }
1286 
1287 // If the event is a modifier key, just by itself, we handle it specially, issuing
1288 // the appropriate Warp-side event (ModifierKeyChanged).
1289 if let (None, keyboard::PhysicalKey::Code(keycode)) =
1290 (&event.text, &event.physical_key)
1291 {
1292 if let Ok(mapped_keycode) = try_from_winit_keycode(keycode) {
1293 return Some(ConvertedEvent::ModifierKeyChanged {
1294 key_code: mapped_keycode,
1295 state: event.state,
1296 });
1297 }
1298 }
1299 
1300 let event_text = event.text.as_ref().map(|text| text.to_string());
1301 let warp_ui_event =
1302 convert_keyboard_input_event(event, window_state, is_synthetic)?;
1303 Some(ConvertedEvent::KeyDownWithTypedCharacters {
1304 chars: event_text,
1305 event: warp_ui_event,
1306 })
1307 }
1308 WindowEvent::Resized(_) => Some(ConvertedEvent::Resize),
1309 WindowEvent::Focused(is_focused) => {
1310 // On mobile WASM, ignore focus-out events. The soft keyboard's hidden input
1311 // causes spurious focus events, and mobile doesn't have the concept of
1312 // "unfocused windows" anyway - you're either in the app or switched away entirely.
1313 #[cfg(target_family = "wasm")]
1314 if !is_focused && crate::platform::wasm::is_mobile_device() {
1315 return None;
1316 }
1317 
1318 // Clear tracked per-side Alt state when we lose focus so that a release
1319 // event dropped while another window had focus can't leave us believing a
1320 // side is still held.
1321 if !is_focused {
1322 window_state.left_alt_pressed = false;
1323 window_state.right_alt_pressed = false;
1324 }
1325 
1326 // On the next tick of the event loop, notify the ui_app that focus has
1327 // transferred, but only if there isn't already one of these
1328 // [`CustomEvent::ActiveWindowChanged`] pending. This coalesces multiple
1329 // `WindowEvent::Focused` events into a single CustomEvent.
1330 if self.state.pending_active_window_change.is_none() {
1331 let _ = self.proxy.send_event(CustomEvent::ActiveWindowChanged);
1332 }
1333 if is_focused {
1334 self.state.pending_active_window_change =
1335 Some(ActiveWindowChange::FocusIn(window_id));
1336 } else {
1337 self.state.pending_active_window_change = Some(ActiveWindowChange::FocusOut);
1338 }
1339 None
1340 }
1341 WindowEvent::DroppedFile(path_buf) => {
1342 let Some(path) = path_buf.as_os_str().to_str() else {
1343 log::warn!("Failed to convert dropped file path to UTF-8: {path_buf:?}");
1344 return None;
1345 };
1346 
1347 // Add this file to the pending list
1348 window_state.pending_drag_drop_files.push(path.to_string());
1349 
1350 // Only schedule a debounced event if we don't already have one running
1351 if !window_state.has_pending_drag_drop_timer {
1352 window_state.has_pending_drag_drop_timer = true;
1353 let proxy = self.proxy.clone();
1354 // Wait to collect all files before sending one event for all of them
1355 self.ui_app.update(|ctx| {
1356 ctx.foreground_executor()
1357 .spawn(async move {
1358 Timer::after(DRAG_DROP_DEBOUNCE_TIMEOUT).await;
1359 let _ = proxy.send_event(CustomEvent::DragAndDropFilesDebounced {
1360 window_id,
1361 });
1362 })
1363 .detach();
1364 });
1365 }
1366 None // Use debounced event instead of immediate processing
1367 }
1368 WindowEvent::Moved(new_position) => Some(ConvertedEvent::WindowMoved { new_position }),
1369 _ => None,
1370 }
1371 }
1372 
1373 #[allow(unused_variables)]
1374 fn send_notification(&mut self, notification_info: NotificationInfo, window_id: WindowId) {
1375 let proxy = self.proxy.clone();
1376 self.ui_app.update(|ctx| {
1377 ctx.background_executor()
1378 .spawn(async move {
1379 crate::windowing::winit::notifications::send_notification(
1380 notification_info,
1381 window_id,
1382 proxy,
1383 )
1384 .await;
1385 })
1386 .detach()
1387 });
1388 }
1389 
1390 fn focus_window(&mut self, window_id: WindowId) {
1391 self.ui_app.update(|ctx| {
1392 let Some(window) = ctx.windows().platform_window(window_id) else {
1393 return;
1394 };
1395 let window = downcast_window(window.as_ref());
1396 window.focus();
1397 });
1398 }
1399 
1400 #[allow(unused_variables)]
1401 fn request_notification_permissions(&mut self, callback: RequestPermissionsCallback) {
1402 let proxy = self.proxy.clone();
1403 self.ui_app.update(|ctx| {
1404 ctx.background_executor()
1405 .spawn(async move {
1406 #[cfg(target_family = "wasm")]
1407 crate::windowing::winit::notifications::request_notification_permissions(
1408 callback, proxy,
1409 )
1410 .await;
1411 
1412 #[cfg(target_os = "linux")]
1413 {
1414 // On Linux, there is no concept of requesting notification permissions. This
1415 // logic is hard-coded to always return an outcome of "Accepted".
1416 let _ = proxy.send_event(CustomEvent::UpdateUIApp(Box::new(|ctx| {
1417 callback(RequestPermissionsOutcome::Accepted, ctx)
1418 })));
1419 }
1420 })
1421 .detach()
1422 });
1423 }
1424 
1425 /// Takes all pending dropped files and creates a single DragAndDropFiles event.
1426 fn handle_debounced_drag_drop(&mut self, window_id: winit::window::WindowId) {
1427 let Some(window_state) = self.state.windows.get_mut(&window_id) else {
1428 return;
1429 };
1430 
1431 window_state.has_pending_drag_drop_timer = false;
1432 
1433 if window_state.pending_drag_drop_files.is_empty() {
1434 return;
1435 }
1436 
1437 // Take ownership of accumulated files
1438 let paths = std::mem::take(&mut window_state.pending_drag_drop_files);
1439 
1440 let location = window_state.last_cursor_position.to_vec2f();
1441 
1442 // Create and dispatch the batched drag-and-drop event
1443 let drag_drop_event = crate::Event::DragAndDropFiles { paths, location };
1444 
1445 self.handle_converted_warpui_event(window_id, drag_drop_event);
1446 }
1447 
1448 /// Handles a request to close the window with the given strato_ui and winit
1449 /// IDs.
1450 fn close_window_requested(
1451 &mut self,
1452 window_id: crate::WindowId,
1453 winit_window_id: winit::window::WindowId,
1454 termination_mode: TerminationMode,
1455 window_target: &ActiveEventLoop,
1456 ) {
1457 if matches!(
1458 termination_mode,
1459 TerminationMode::ForceTerminate | TerminationMode::ContentTransferred
1460 ) {
1461 self.close_window(window_id, winit_window_id, window_target);
1462 } else if let ApproveTerminateResult::Terminate =
1463 self.callbacks.should_close_window(window_id)
1464 {
1465 self.close_window(window_id, winit_window_id, window_target);
1466 }
1467 }
1468 
1469 fn close_window(
1470 &mut self,
1471 window_id: crate::WindowId,
1472 winit_window_id: winit::window::WindowId,
1473 window_target: &ActiveEventLoop,
1474 ) {
1475 let window_state = self.state.windows.remove(&winit_window_id);
1476 
1477 // Drop the renderer before we actually clean up the window, to ensure
1478 // that the window outlives the `wgpu` surface that references it.
1479 if let Some(WindowState { window_id, .. }) = window_state {
1480 if let Some(window) = self
1481 .ui_app
1482 .read(|ctx| ctx.windows().platform_window(window_id))
1483 {
1484 downcast_window(window.as_ref())
1485 .drop_renderer(Box::new(window_target.owned_display_handle()));
1486 }
1487 }
1488 
1489 self.callbacks.window_will_close(window_id)
1490 }
1491 
1492 fn terminate_app_requested(
1493 &mut self,
1494 termination_mode: TerminationMode,
1495 ) -> ApproveTerminateResult {
1496 if matches!(
1497 termination_mode,
1498 TerminationMode::ForceTerminate | TerminationMode::ContentTransferred
1499 ) {
1500 return ApproveTerminateResult::Terminate;
1501 }
1502 
1503 let approve_terminate_result = self.callbacks.should_terminate_app();
1504 if let ApproveTerminateResult::Terminate = approve_terminate_result {}
1505 approve_terminate_result
1506 }
1507 
1508 fn handle_ime_event(&mut self, winit_window_id: WinitWindowId, event: ImeEvent) {
1509 match event {
1510 winit::event::Ime::Enabled => {
1511 self.ime_enabled = true;
1512 self.ui_app
1513 .update(|ctx| ctx.report_active_cursor_position_update());
1514 }
1515 winit::event::Ime::Preedit(preedit_text, cursor_position) => {
1516 if !self.ime_enabled {
1517 return;
1518 }
1519 
1520 let Some(window_state) = self.state.windows.get_mut(&winit_window_id) else {
1521 return;
1522 };
1523 let Some(window) = self
1524 .ui_app
1525 .read(|ctx| ctx.windows().platform_window(window_state.window_id))
1526 else {
1527 return;
1528 };
1529 
1530 let mut window_callbacks = self.callbacks.for_window(window.as_ref());
1531 window_callbacks.dispatch_event(SetMarkedText {
1532 marked_text: preedit_text,
1533 selected_range: cursor_position
1534 .map(|cursor_position| cursor_position.0..cursor_position.1)
1535 .unwrap_or(0..0),
1536 });
1537 }
1538 winit::event::Ime::Commit(chars) => {
1539 let Some(window_state) = self.state.windows.get_mut(&winit_window_id) else {
1540 return;
1541 };
1542 let Some(window) = self
1543 .ui_app
1544 .read(|ctx| ctx.windows().platform_window(window_state.window_id))
1545 else {
1546 return;
1547 };
1548 
1549 let mut window_callbacks = self.callbacks.for_window(window.as_ref());
1550 // We clear the marked text state before inserting typed characters so that the Vim
1551 // FSA knows it can interpret the committed text as a user insertion.
1552 window_callbacks.dispatch_event(ClearMarkedText);
1553 window_callbacks.dispatch_event(TypedCharacters { chars });
1554 }
1555 winit::event::Ime::Disabled => {
1556 self.ime_enabled = false;
1557 }
1558 };
1559 }
1560 
1561 /// Handle events that may be handled by strato_ui, or maybe not in some cases, e.g. window
1562 /// drag-to-resize or drag-to-move.
1563 fn handle_converted_warpui_event(
1564 &mut self,
1565 window_id: winit::window::WindowId,
1566 event: crate::Event,
1567 ) {
1568 let Some(window_state) = self.state.windows.get_mut(&window_id) else {
1569 return;
1570 };
1571 let Some(window) = self
1572 .ui_app
1573 .read(|ctx| ctx.windows().platform_window(window_state.window_id))
1574 else {
1575 return;
1576 };
1577 
1578 let winit_window = downcast_window(window.as_ref());
1579 // There is some state on the [`winit::window::Window`] that needs to be kept
1580 // in sync with the cursor position in order for drag-resizing windows to work.
1581 if let crate::Event::MouseMoved { .. } = event {
1582 if !winit_window.is_decorated() {
1583 winit_window.update_drag_resize_state(window_state.last_cursor_position);
1584 }
1585 }
1586 
1587 // Check if we should start a window drag-resize. If so, do that instead of
1588 // passing the event into strato_ui. Skip for touch events as drag_resize_window
1589 // doesn't work properly with touch input on Windows.
1590 if let crate::event::Event::LeftMouseDown { .. } = event {
1591 if !winit_window.is_decorated()
1592 && winit_window.try_drag_resize()
1593 && window_state.last_touch_purpose.is_none()
1594 {
1595 // If we initiated a drag via the method
1596 // [`winit::window::Window::drag_resize_window`], we will not
1597 // receive a MouseInput event when the button is release, so we
1598 // pre-emptively set this back to None.
1599 window_state.current_mouse_button_pressed = None;
1600 return;
1601 }
1602 }
1603 let dispatch_result = self
1604 .callbacks
1605 .for_window(winit_window)
1606 .dispatch_event(event.clone());
1607 
1608 // If the app didn't handle the event, strato_ui might still want to do something
1609 // with it if it's a click within the "titlebar region" at the top.
1610 if !dispatch_result.handled {
1611 if let crate::event::Event::LeftMouseDown {
1612 click_count,
1613 position,
1614 ..
1615 } = event
1616 {
1617 // The WASM "window" does not support dragging or maximization.
1618 let titlebar_height = winit_window.titlebar_height();
1619 if position.y() < titlebar_height && !cfg!(target_family = "wasm") {
1620 // Double-clicking the titlebar does maximize/restore.
1621 if click_count >= 2 {
1622 window.toggle_maximized();
1623 } else if window_state.last_touch_purpose.is_none() {
1624 // Single-click drag moves the window. Skip for touch events as
1625 // drag_window doesn't work properly with touch input on Windows.
1626 // We won't receive MouseInput::Released after drag_window.
1627 match winit_window.drag_window() {
1628 Ok(_) => window_state.current_mouse_button_pressed = None,
1629 Err(err) => log::error!("error dragging window: {err:?}"),
1630 }
1631 }
1632 }
1633 }
1634 }
1635 
1636 // On mobile WASM, update soft keyboard state based on touch/click events.
1637 // This must happen synchronously within the touch event handler (user gesture context)
1638 // for the browser to allow focusing the hidden input element.
1639 //
1640 // For touch events, we defer keyboard updates until LeftMouseUp to avoid showing
1641 // the keyboard during drags/scrolls (which start with LeftMouseDown but later get
1642 // reclassified as scroll gestures). We only trigger the keyboard if the touch purpose
1643 // is still Tap (meaning it was never reclassified to Scroll, Select, or WindowDrag).
1644 #[cfg(target_family = "wasm")]
1645 {
1646 // First, check what kind of event we have without holding a mutable borrow.
1647 let touch_info = self.state.windows.get(&window_id).and_then(|ws| {
1648 ws.last_touch_purpose.as_ref().map(|purpose| {
1649 // Check if this is still a tap (not a scroll/drag/select)
1650 matches!(purpose, TouchPurpose::Tap(..))
1651 })
1652 });
1653 
1654 match (&event, touch_info) {
1655 // Regular mouse click (not touch) - update keyboard immediately.
1656 (crate::event::Event::LeftMouseDown { .. }, None) => {
1657 self.update_soft_keyboard_state(dispatch_result.soft_keyboard_requested);
1658 }
1659 // Touch LeftMouseDown - store keyboard request for later use on LeftMouseUp.
1660 (crate::event::Event::LeftMouseDown { .. }, Some(_)) => {
1661 if let Some(ws) = self.state.windows.get_mut(&window_id) {
1662 ws.pending_soft_keyboard_request = dispatch_result.soft_keyboard_requested;
1663 }
1664 }
1665 // Touch tap completed and purpose is still Tap - use stored keyboard request.
1666 (crate::event::Event::LeftMouseUp { .. }, Some(true)) => {
1667 let should_show = self
1668 .state
1669 .windows
1670 .get(&window_id)
1671 .map(|ws| ws.pending_soft_keyboard_request)
1672 .unwrap_or(false);
1673 self.update_soft_keyboard_state(should_show);
1674 }
1675 _ => {}
1676 };
1677 }
1678 
1679 // On LeftMouseUp: clear touch state and start momentum scrolling if applicable.
1680 if matches!(event, crate::event::Event::LeftMouseUp { .. }) {
1681 let should_start_momentum = self
1682 .state
1683 .windows
1684 .get_mut(&window_id)
1685 .and_then(|window_state| {
1686 let purpose = window_state.last_touch_purpose.take()?;
1687 
1688 if !matches!(purpose, TouchPurpose::Scroll(_)) {
1689 return None;
1690 }
1691 
1692 window_state.last_mouse_button_pressed = None;
1693 
1694 let scroll_vel = window_state.scroll_velocity.as_mut()?;
1695 // Clamp velocity to prevent excessively fast momentum scrolling
1696 // from quick flick gestures or batched touch events that produce
1697 // artificially large velocity spikes.
1698 scroll_vel.velocity = vec2f(
1699 scroll_vel
1700 .velocity
1701 .x()
1702 .clamp(-MOMENTUM_MAX_VELOCITY, MOMENTUM_MAX_VELOCITY),
1703 scroll_vel
1704 .velocity
1705 .y()
1706 .clamp(-MOMENTUM_MAX_VELOCITY, MOMENTUM_MAX_VELOCITY),
1707 );
1708 (scroll_vel.velocity.length() >= MOMENTUM_THRESHOLD).then_some(())
1709 })
1710 .is_some();
1711 
1712 if should_start_momentum {
1713 let abort_handle = self.start_momentum_scroll(window_id);
1714 
1715 if let Some(ws) = self.state.windows.get_mut(&window_id) {
1716 ws.momentum_scroll_abort = Some(abort_handle);
1717 }
1718 }
1719 }
1720 }
1721 
1722 fn handle_clipboard_event(&mut self, clipboard_event: ClipboardEvent) {
1723 let Some(active_window_id) = self.ui_app.read(|ctx| ctx.windows().active_window()) else {
1724 return;
1725 };
1726 
1727 match clipboard_event {
1728 #[allow(unused_variables)]
1729 ClipboardEvent::Paste(content) => {
1730 cfg_if::cfg_if! {
1731 if #[cfg(target_family = "wasm")] {
1732 self.ui_app.update(|ctx| {
1733 ctx.clipboard().save(content);
1734 })
1735 }
1736 }
1737 self.ui_app
1738 .dispatch_standard_action(active_window_id, StandardAction::Paste);
1739 }
1740 }
1741 }
1742 
1743 /// Starts a timer that triggers MomentumScroll events at a fixed interval.
1744 fn start_momentum_scroll(&self, window_id: winit::window::WindowId) -> AbortHandle {
1745 let proxy = self.proxy.clone();
1746 // Use abortable future so we can cancel momentum scrolling when the user touches the screen.
1747 // Aborting is expected since we cancelled the animation, so we can safely discard it.
1748 let (future, abort_handle) = futures::future::abortable(async move {
1749 loop {
1750 Timer::after(MOMENTUM_FRAME_INTERVAL).await;
1751 let _ = proxy.send_event(CustomEvent::MomentumScroll { window_id });
1752 }
1753 });
1754 
1755 self.ui_app.read(|ctx| {
1756 ctx.foreground_executor()
1757 .spawn(async move {
1758 let _ = future.await;
1759 })
1760 .detach();
1761 });
1762 
1763 abort_handle
1764 }
1765 
1766 fn update_ime_position(&mut self) {
1767 let Some(active_window_id) = self.ui_app.read(|ctx| ctx.windows().active_window()) else {
1768 return;
1769 };
1770 let Some(window) = self
1771 .ui_app
1772 .update(|ctx| ctx.windows().platform_window(active_window_id))
1773 else {
1774 return;
1775 };
1776 
1777 let mut window_callbacks = self.callbacks.for_window(window.as_ref());
1778 let active_cursor_position = window_callbacks.get_active_cursor_position();
1779 if let Some(active_cursor_position) = active_cursor_position {
1780 let winit_window = downcast_window(window.as_ref());
1781 let position = LogicalPosition::new(
1782 active_cursor_position.position.origin_x(),
1783 active_cursor_position.position.origin_y()
1784 + (1.2 * active_cursor_position.font_size),
1785 );
1786 // Currently the size argument is not supported on X11. We calculate it here anyway.
1787 let size = LogicalSize::new(
1788 active_cursor_position.font_size,
1789 active_cursor_position.font_size,
1790 );
1791 // TODO(abhishek): We make sure that the position is different than last time to prevent winit from
1792 // caching the old position and not properly updating on `WindowMoved` or `WindowResized` events.
1793 winit_window.set_ime_position(LogicalPosition::new(position.x, position.y + 1.), size);
1794 winit_window.set_ime_position(position, size);
1795 }
1796 }
1797 
1798 /// Prepares for an impending system suspend/sleep on Linux.
1799 ///
1800 /// When using a dedicated GPU, the kernel sometimes disables the device
1801 /// during system suspend. When this happens, wgpu treats the device as
1802 /// "lost", which currently produces an unavoidable panic the next time
1803 /// it is accessed.
1804 ///
1805 /// To work around this, we drop all rendering resources pre-suspend, and
1806 /// re-create them post-resume.
1807 #[cfg(target_os = "linux")]
1808 fn prepare_for_sleep_on_linux(&mut self, window_target: &ActiveEventLoop) {
1809 self.ui_app.update(|ctx| {
1810 for window_id in ctx.window_ids() {
1811 let Some(window) = ctx.windows().platform_window(window_id) else {
1812 return;
1813 };
1814 
1815 let winit_window = downcast_window(window.as_ref());
1816 winit_window.drop_renderer(Box::new(window_target.owned_display_handle()));
1817 }
1818 });
1819 }
1820 
1821 /// Resumes from system suspend/sleep on Linux.
1822 ///
1823 /// See the [`Self::prepare_for_sleep_on_linux`] documentation for more
1824 /// details.
1825 #[cfg(target_os = "linux")]
1826 fn resume_from_sleep_on_linux(&mut self) {
1827 self.ui_app.update(|ctx| {
1828 for window_id in ctx.window_ids() {
1829 let Some(window) = ctx.windows().platform_window(window_id) else {
1830 return;
1831 };
1832 
1833 let winit_window = downcast_window(window.as_ref());
1834 winit_window.recreate_renderer(self.downrank_non_nvidia_vulkan_adapters);
1835 }
1836 });
1837 }
1838 
1839 /// Initializes the soft keyboard manager on mobile WASM devices.
1840 ///
1841 /// This creates the hidden input element that triggers the soft keyboard
1842 /// when focused. The manager is only created on mobile devices.
1843 #[cfg(target_family = "wasm")]
1844 fn initialize_soft_keyboard(&mut self) {
1845 use crate::platform::wasm::{is_mobile_device, SoftKeyboardInput, SoftKeyboardManager};
1846 
1847 if !is_mobile_device() {
1848 log::info!("Not a mobile device, skipping soft keyboard initialization");
1849 return;
1850 }
1851 
1852 log::info!("Initializing soft keyboard for mobile WASM");
1853 
1854 // Create a callback that handles soft keyboard input events.
1855 // These are forwarded to the event loop via CustomEvent, where they
1856 // will be dispatched to the active window as TypedCharacters/IME events.
1857 let proxy = self.proxy.clone();
1858 let on_input = Box::new(move |input: SoftKeyboardInput| {
1859 log::debug!("Soft keyboard callback received input: {:?}", input);
1860 if let Err(e) = proxy.send_event(CustomEvent::SoftKeyboardInput(input)) {
1861 log::error!("Failed to send SoftKeyboardInput event: {:?}", e);
1862 }
1863 });
1864 
1865 match SoftKeyboardManager::new(on_input) {
1866 Ok(manager) => {
1867 log::info!("Soft keyboard manager initialized successfully");
1868 self.soft_keyboard_manager = Some(manager);
1869 }
1870 Err(err) => {
1871 log::error!("Failed to initialize soft keyboard manager: {:?}", err);
1872 }
1873 }
1874 }
1875 
1876 /// Updates the soft keyboard visibility based on the dispatch result.
1877 ///
1878 /// This is called after processing touch events, while still in user gesture context.
1879 /// Since everything renders to a canvas, the browser can't detect taps "outside" the
1880 /// keyboard input, so we must explicitly show/hide based on what the app requested.
1881 #[cfg(target_family = "wasm")]
1882 fn update_soft_keyboard_state(&mut self, requested: bool) {
1883 let Some(manager) = &self.soft_keyboard_manager else {
1884 return;
1885 };
1886 
1887 if requested {
1888 log::debug!("App requested soft keyboard, showing it");
1889 manager.show_keyboard();
1890 } else {
1891 log::debug!("App did not request soft keyboard, hiding it");
1892 manager.hide_keyboard();
1893 }
1894 }
1895 
1896 /// Attempts to refocus the main canvas element.
1897 ///
1898 /// This is needed to restore app interactivity after the soft keyboard is dismissed,
1899 /// particularly on iOS Safari which can leave the app in a "blurred" state.
1900 ///
1901 /// We defer the focus to the next frame using setTimeout(0) because calling focus()
1902 /// synchronously during event processing may not work reliably on iOS Safari.
1903 #[cfg(target_family = "wasm")]
1904 fn refocus_canvas() {
1905 use wasm_bindgen::{prelude::Closure, JsCast};
1906 
1907 // Defer focus to next frame to ensure we're outside the current event processing.
1908 let callback = Closure::once(Box::new(|| {
1909 if let Some(canvas) = gloo::utils::document()
1910 .query_selector("canvas")
1911 .ok()
1912 .flatten()
1913 {
1914 if let Ok(html_element) = canvas.dyn_into::<web_sys::HtmlElement>() {
1915 let _ = html_element.focus();
1916 }
1917 }
1918 }) as Box<dyn FnOnce()>);
1919 
1920 let _ = gloo::utils::window().set_timeout_with_callback(callback.as_ref().unchecked_ref());
1921 // Prevent the closure from being dropped immediately
1922 callback.forget();
1923 }
1924 
1925 /// Handles input events from the soft keyboard on mobile WASM.
1926 ///
1927 /// Converts `SoftKeyboardInput` events into strato_ui `Event`s and dispatches
1928 /// them to the active window, similar to how `handle_ime_event` works.
1929 #[cfg(target_family = "wasm")]
1930 fn handle_soft_keyboard_input(&mut self, input: crate::platform::wasm::SoftKeyboardInput) {
1931 use crate::platform::wasm::SoftKeyboardInput;
1932 
1933 // On WASM, get the first (and typically only) window if there's no "active" window
1934 let window_id = self.ui_app.read(|ctx| {
1935 ctx.windows()
1936 .active_window()
1937 .or_else(|| ctx.window_ids().next())
1938 });
1939 
1940 let Some(window_id) = window_id else {
1941 log::debug!("No window for soft keyboard input");
1942 return;
1943 };
1944 let Some(window) = self
1945 .ui_app
1946 .read(|ctx| ctx.windows().platform_window(window_id))
1947 else {
1948 log::debug!("Could not get platform window for soft keyboard input");
1949 return;
1950 };
1951 
1952 let mut window_callbacks = self.callbacks.for_window(window.as_ref());
1953 
1954 match input {
1955 SoftKeyboardInput::TextInserted(text) => {
1956 window_callbacks.dispatch_event(TypedCharacters { chars: text });
1957 }
1958 SoftKeyboardInput::Backspace => {
1959 window_callbacks.dispatch_event(crate::Event::KeyDown {
1960 keystroke: crate::keymap::Keystroke {
1961 ctrl: false,
1962 alt: false,
1963 shift: false,
1964 cmd: false,
1965 meta: false,
1966 key: "backspace".to_string(),
1967 },
1968 chars: String::new(),
1969 details: crate::event::KeyEventDetails::default(),
1970 is_composing: false,
1971 });
1972 }
1973 SoftKeyboardInput::KeyboardDismissed => {
1974 // The keyboard was dismissed (user tapped elsewhere or pressed "Done").
1975 // Refocus the canvas to restore interactivity.
1976 log::debug!("Soft keyboard was dismissed, refocusing canvas");
1977 Self::refocus_canvas();
1978 }
1979 SoftKeyboardInput::KeyDown(key) => {
1980 // Map special key names to their control characters (e.g., Enter → "\r")
1981 // so the terminal's key_down handler can process them.
1982 let chars = match key.to_lowercase().as_str() {
1983 "enter" => "\r".to_string(),
1984 _ => String::new(),
1985 };
1986 window_callbacks.dispatch_event(crate::Event::KeyDown {
1987 keystroke: crate::keymap::Keystroke {
1988 ctrl: false,
1989 alt: false,
1990 shift: false,
1991 cmd: false,
1992 meta: false,
1993 key: key.to_lowercase(),
1994 },
1995 chars,
1996 details: crate::event::KeyEventDetails::default(),
1997 is_composing: false,
1998 });
1999 }
2000 }
2001 }
2002 
2003 /// Resizes container when visual viewport changes (e.g., soft keyboard appears).
2004 #[cfg(target_family = "wasm")]
2005 fn handle_visual_viewport_resize(&mut self, _width: f32, height: f32) {
2006 log::debug!("Visual viewport resized, height = {}px", height);
2007 
2008 if let Some(container) = gloo::utils::document().get_element_by_id("wasm-container") {
2009 if let Some(html_element) = container.dyn_ref::<web_sys::HtmlElement>() {
2010 let _ = html_element
2011 .style()
2012 .set_property("height", &format!("{}px", height));
2013 }
2014 }
2015 }
2016}
2017 
2018/// Set of possible UI-framework events that have been converted from a [`winit::event::Event`].
2019#[derive(Debug)]
2020enum ConvertedEvent {
2021 Event(crate::Event),
2022 /// A keydown event with the actual text of keydown event. We separate the characters from the
2023 /// underlying event so that we can produce a `TypedCharacters` event if the initial `event` is
2024 /// not handled by the UI framework.
2025 KeyDownWithTypedCharacters {
2026 chars: Option<String>,
2027 event: crate::Event,
2028 },
2029 Resize,
2030 WindowMoved {
2031 new_position: PhysicalPosition<i32>,
2032 },
2033 ModifierKeyChanged {
2034 key_code: crate::platform::keyboard::KeyCode,
2035 state: ElementState,
2036 },
2037 /// Move the window for touch-based window dragging.
2038 MoveWindowBy {
2039 /// Current touch position (window-relative).
2040 current_touch: PhysicalPosition<f64>,
2041 /// Touch position when drag started (window-relative).
2042 start_touch: PhysicalPosition<f64>,
2043 },
2044}
2045 
2046/// Convert the platform-independent trait object Window into a concrete, platform-specific Window.
2047fn downcast_window(window: &dyn platform::Window) -> &super::Window {
2048 window
2049 .as_any()
2050 .downcast_ref::<super::Window>()
2051 .expect("Should not fail to downcast the platform window to its concrete type")
2052}
2053