mod key_events; #[cfg(test)] mod drag_drop_tests; use std::collections::HashMap; use std::mem::ManuallyDrop; #[cfg(target_os = "linux")] use crate::notification::RequestPermissionsOutcome; use futures_util::future::LocalBoxFuture; use futures_util::stream::AbortHandle; use instant::{Duration, Instant}; use pathfinder_geometry::rect::RectF; use pathfinder_geometry::vector::{vec2f, Vector2F}; use winit::dpi::{LogicalPosition, LogicalSize, PhysicalPosition}; use winit::event::Ime as ImeEvent; use winit::event_loop::EventLoopProxy; use winit::keyboard::{self, KeyCode}; use winit::window::WindowId as WinitWindowId; use winit::{ event::{ElementState, Event, MouseButton, StartCause, Touch, TouchPhase, WindowEvent}, event_loop::{ActiveEventLoop, ControlFlow}, }; use crate::actions::StandardAction; use crate::event::ModifiersState; use crate::platform::NotificationInfo; use crate::platform::OperatingSystem; use crate::platform::{ self, app::{AppCallbackDispatcher, ApproveTerminateResult}, TerminationMode, WindowContext, }; use crate::r#async::Timer; use crate::rendering::wgpu::renderer; use crate::windowing::winit::app::RequestPermissionsCallback; use crate::windowing::winit::window::MIN_WINDOW_SIZE; use crate::Event::{ClearMarkedText, SetMarkedText, TypedCharacters}; use crate::{AppContext, WindowId}; #[cfg(target_family = "wasm")] use wasm_bindgen::JsCast; use super::app::ClipboardEvent; use super::window::DEFAULT_TITLEBAR_HEIGHT; use super::CustomEvent; #[cfg(windows)] use super::windows::{add_network_connection_listener, WindowsNetworkConnectionPoint}; use self::key_events::convert_keyboard_input_event; /// This is the time duration beyond which clicks get treated as separate single clicks instead of /// double-click, triple-click, etc. const MULTI_CLICK_INTERVAL: Duration = Duration::from_millis(400); /// The debounce timeout for drag-and-drop files. Multiple DroppedFile events /// are received within this time window and then combined into a single DragAndDropFiles event. /// This timeout ensures all files in a multi-file drag operation are batched together efficiently. const DRAG_DROP_DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(50); /// Distance (in logical pixels) before a touch input is considered a drag. Flutter uses 18. const MAX_TAP_DISTANCE: f64 = 18.; /// Duration to hold before a touch becomes a right-click (context menu). /// Matches the iOS and Android platform default of 500ms. const LONG_PRESS_DURATION: Duration = Duration::from_millis(500); /// Momentum scrolling configuration. Math is as follows: /// Each tick (every MOMENTUM_FRAME_INTERVAL): /// /// 1. Decay velocity: v = v * MOMENTUM_DECAY^(elapsed / MOMENTUM_DECAY_INTERVAL) /// 2. Check stop condition: if |v| < MOMENTUM_MIN_VELOCITY, cancel the animation. /// 3. Compute scroll delta: delta = v * elapsed /// /// Decay is between iOS normal (0.984/8ms) and fast (0.923/8ms). const MOMENTUM_DECAY: f32 = 0.968; // Every interval, velocity is multiplied by this factor. const MOMENTUM_DECAY_INTERVAL: f32 = 0.008; // Time period (seconds) over which MOMENTUM_DECAY is applied const MOMENTUM_FRAME_INTERVAL: Duration = Duration::from_millis(8); //Controls how often the momentum scroll tick fires. // Higher values means it fires less often (choppier) const MOMENTUM_THRESHOLD: f32 = 50.0; // Min-velocity to start momentum scroll, Android standards const MOMENTUM_MIN_VELOCITY: f32 = 1.0; // When velocity falls below this, scrolling stops. 1.0 is subpixel const MOMENTUM_MAX_VELOCITY: f32 = 2000.0; // Hard cap on momentum initial velocity (px/s) const MIN_VELOCITY_TIME_DELTA: f32 = 0.004; // Floor for time deltas to prevent spikes from batched events /// TryFrom implementation for converting winit's `KeyCode` to /// `crate::platform::keyboard::KeyCode`. /// Only converts modifier keys and fails for all other keys. fn try_from_winit_keycode(keycode: &KeyCode) -> Result { match keycode { KeyCode::AltLeft => Ok(crate::platform::keyboard::KeyCode::AltLeft), KeyCode::AltRight => Ok(crate::platform::keyboard::KeyCode::AltRight), KeyCode::ShiftLeft => Ok(crate::platform::keyboard::KeyCode::ShiftLeft), KeyCode::ShiftRight => Ok(crate::platform::keyboard::KeyCode::ShiftRight), KeyCode::ControlLeft => Ok(crate::platform::keyboard::KeyCode::ControlLeft), KeyCode::ControlRight => Ok(crate::platform::keyboard::KeyCode::ControlRight), KeyCode::SuperLeft => Ok(crate::platform::keyboard::KeyCode::SuperLeft), KeyCode::SuperRight => Ok(crate::platform::keyboard::KeyCode::SuperRight), // Note that the Fn key is not well identified on Windows laptops (e.g. // winit wasn't able to identify it correctly on ThinkPad). But, if it's // identified, we still pass it on to the UI framework. KeyCode::Fn => Ok(crate::platform::keyboard::KeyCode::Fn), _ => Err(()), } } /// Data needed to detect double/triple-click. struct MouseButtonPressState { pressed_at: Instant, button_pressed: MouseButton, click_count: u32, } #[derive(Debug)] /// Purpose of the touch event. enum TouchPurpose { Select, Scroll(Touch), /// A tap that hasn't yet been classified. /// Stores (initial touch, click count, start time for long-press detection). Tap(Touch, u32, Instant), /// Dragging the window via touch in the titlebar region. /// Stores the starting touch position (window-relative). WindowDrag { start_touch: PhysicalPosition, }, } /// Tracks scroll velocity during active touch scrolling and momentum scrolling. #[derive(Debug, Clone, Copy)] struct ScrollVelocity { velocity: Vector2F, last_update: Instant, } /// The set of state we need to track per-window across frames. struct WindowState { /// The UI framework's identifier for the window in question (not to be /// confused with winit's identifer for the window). window_id: crate::WindowId, /// The last known modifier key (ctr/alt/etc) state. modifiers: keyboard::ModifiersState, /// Whether the left Alt key is currently pressed. `ModifiersState` does not distinguish /// between left and right Alt, so we track per-side state by watching `KeyboardInput` /// events for `KeyCode::AltLeft`/`KeyCode::AltRight`. Used by the extra-meta-keys setting /// to apply the left/right-alt-as-meta preferences correctly. left_alt_pressed: bool, /// Whether the right Alt key is currently pressed. See [`Self::left_alt_pressed`]. right_alt_pressed: bool, /// The currently-pressed mouse button, if any. Set back to None when the button is released. /// /// This ultimately should be a HashSet of mouse buttons, as more than one /// can be held down at a time. current_mouse_button_pressed: Option, /// The last mouse button pressed. This persists after the button is released because it needs /// to keep that state to detect double/triple-click. last_mouse_button_pressed: Option, /// The last known cursor position, measured in logical pixels. last_cursor_position: winit::dpi::LogicalPosition, /// Drag-and-drop files are received as separate DroppedFile events per file. /// We collect them and debounce them to create a single consistent DragAndDropFiles event. pending_drag_drop_files: Vec, /// Track if we have a debounce timer already running for this window. has_pending_drag_drop_timer: bool, /// The purpose of the last touch event. last_touch_purpose: Option, /// Tracks scroll velocity during active touch scrolling and momentum scrolling. /// Active phase determined through `momentum_scroll_abort.is_some()`. scroll_velocity: Option, /// Abort handle for momentum scrolling timer. Present only during the momentum phase. momentum_scroll_abort: Option, /// For touch events, stores whether soft keyboard was requested during LeftMouseDown. /// This is needed because touch keyboard updates are deferred to LeftMouseUp, but the /// UI element only requests the keyboard during LeftMouseDown. #[cfg(target_family = "wasm")] pending_soft_keyboard_request: bool, } impl WindowState { fn new(window_id: crate::WindowId) -> Self { Self { window_id, modifiers: Default::default(), left_alt_pressed: false, right_alt_pressed: false, current_mouse_button_pressed: None, last_mouse_button_pressed: None, last_cursor_position: Default::default(), pending_drag_drop_files: Vec::new(), has_pending_drag_drop_timer: false, last_touch_purpose: None, scroll_velocity: None, momentum_scroll_abort: None, #[cfg(target_family = "wasm")] pending_soft_keyboard_request: false, } } /// Cancels ongoing momentum scroll, clearing both the animation timer and velocity state. fn cancel_momentum_scroll(&mut self) { if let Some(abort_handle) = self.momentum_scroll_abort.take() { abort_handle.abort(); } self.scroll_velocity = None; } /// When a mouse button is pressed, save it to [`Self::current_mouse_button_pressed`] so that /// we can detect dragging. Also save it to [`Self::last_mouse_button_pressed`] for /// double/triple-click. Returns the calculated click_count. fn determine_click_count_and_update_button_state(&mut self, button: MouseButton) -> u32 { self.current_mouse_button_pressed = Some(button); let now = Instant::now(); // Increment the click_count if the button type is the same and the duration is faster than // MULTI_CLICK_INTERVAL. let click_count = self .last_mouse_button_pressed .take() .filter(|old_state| { old_state.button_pressed == button && now.duration_since(old_state.pressed_at) <= MULTI_CLICK_INTERVAL }) .map(|old_state| old_state.click_count + 1) .unwrap_or(1); let new_state = MouseButtonPressState { pressed_at: now, button_pressed: button, click_count, }; self.last_mouse_button_pressed = Some(new_state); click_count } } /// An extension trait to add helpful methods to [`winit::dpi::LogicalPosition`]. trait LogicalPositionExt { /// Converts the [`LogicalPosition`] into a [`Vector2F`]. fn to_vec2f(&self) -> Vector2F; } impl LogicalPositionExt for winit::dpi::LogicalPosition { fn to_vec2f(&self) -> Vector2F { Vector2F::new(self.x, self.y) } } /// The state that we need to track across frames in order to properly convert /// winit events into galaxyui events. #[derive(Default)] struct State { windows: HashMap, pending_active_window_change: Option, #[cfg(windows)] network_connection_listener: Option, } /// This enum holds the state needed to convert multiple emitted /// [`winit::event::WindowEvent::Focused`] events into a single /// [`CustomEvent::ActiveWindowChanged`]. #[derive(Copy, Clone, Debug)] enum ActiveWindowChange { /// When we see `WindowEvent::Focused(false)` from winit, we store this variant. FocusOut, /// When we see `WindowEvent::Focused(true)` from winit, we store this variant and save the ID /// of the newly focused window. FocusIn(winit::window::WindowId), } fn from_winit_modifiers_state(state: keyboard::ModifiersState) -> ModifiersState { ModifiersState { alt: state.alt_key(), cmd: state.super_key(), shift: state.shift_key(), ctrl: state.control_key(), // TODO(advait): Implement the function key for winit. // Note there is no function_key() function to use here. func: false, } } /// Handles the `TouchPhase::Started` phase of a touch event. /// /// Emits a `LeftMouseDown` event to begin a potential tap, selection, scroll, or window drag. fn convert_touch_started( touch: Touch, window_state: &mut WindowState, scale_factor: f32, ) -> Option { if window_state.last_touch_purpose.is_some() { return None; } // Cancel any ongoing momentum scroll when user touches screen (iOS behavior). window_state.cancel_momentum_scroll(); window_state.last_cursor_position = touch.location.to_logical(scale_factor as f64); let click_count = window_state.determine_click_count_and_update_button_state(MouseButton::Left); window_state.current_mouse_button_pressed = None; // Store click_count and start time for double-tap and long-press detection. window_state.last_touch_purpose = Some(TouchPurpose::Tap(touch, click_count, Instant::now())); Some(ConvertedEvent::Event(crate::event::Event::LeftMouseDown { position: window_state.last_cursor_position.to_vec2f(), click_count, is_first_mouse: false, modifiers: from_winit_modifiers_state(window_state.modifiers), })) } /// Handles the `TouchPhase::Moved` phase of a touch event. fn convert_touch_moved( touch: Touch, window_state: &mut WindowState, scale_factor: f32, titlebar_height: f32, ) -> Option { match window_state.last_touch_purpose { Some(TouchPurpose::Tap(last_touch, click_count, start_time)) => { // Compute deltas in logical pixels for consistent behavior across DPI settings. let current_logical: LogicalPosition = touch.location.to_logical(scale_factor as f64); let last_logical: LogicalPosition = last_touch.location.to_logical(scale_factor as f64); let delta_x = current_logical.x - last_logical.x; let delta_y = current_logical.y - last_logical.y; // Not moved enough to classify gesture yet if delta_x.abs() <= MAX_TAP_DISTANCE && delta_y.abs() <= MAX_TAP_DISTANCE { return None; } window_state.last_cursor_position = touch.location.to_logical(scale_factor as f64); // Double-tap + drag = text selection if click_count >= 2 { window_state.last_touch_purpose = Some(TouchPurpose::Select); return Some(ConvertedEvent::Event( crate::event::Event::LeftMouseDragged { position: window_state.last_cursor_position.to_vec2f(), modifiers: from_winit_modifiers_state(window_state.modifiers), }, )); } // Touch in titlebar = window drag let initial_pos: winit::dpi::LogicalPosition = last_touch.location.to_logical(scale_factor as f64); if initial_pos.y < titlebar_height && !cfg!(target_family = "wasm") { let start_touch = last_touch.location; window_state.last_touch_purpose = Some(TouchPurpose::WindowDrag { start_touch }); return Some(ConvertedEvent::MoveWindowBy { current_touch: touch.location, start_touch, }); } // Single tap + swipe = scroll (default) window_state.last_touch_purpose = Some(TouchPurpose::Scroll(last_touch)); let elapsed = start_time .elapsed() .as_secs_f32() .max(MIN_VELOCITY_TIME_DELTA); window_state.scroll_velocity = Some(ScrollVelocity { velocity: Vector2F::new(delta_x as f32, delta_y as f32) / elapsed, last_update: Instant::now(), }); Some(ConvertedEvent::Event(crate::event::Event::ScrollWheel { position: window_state.last_cursor_position.to_vec2f(), delta: Vector2F::new(delta_x as f32, delta_y as f32), precise: true, modifiers: from_winit_modifiers_state(window_state.modifiers), })) } Some(TouchPurpose::Scroll(last_touch)) => { // Continue scrolling. Use logical pixels for consistent scroll speed across DPI. window_state.last_touch_purpose = Some(TouchPurpose::Scroll(touch)); let current_logical: LogicalPosition = touch.location.to_logical(scale_factor as f64); let last_logical: LogicalPosition = last_touch.location.to_logical(scale_factor as f64); let delta_x = current_logical.x - last_logical.x; let delta_y = current_logical.y - last_logical.y; window_state.last_cursor_position = current_logical.cast(); // Update velocity for momentum scrolling. let now = Instant::now(); let delta = Vector2F::new(delta_x as f32, delta_y as f32); let time_delta = window_state .scroll_velocity .map(|v| now.duration_since(v.last_update).as_secs_f32()) .unwrap_or(MOMENTUM_DECAY_INTERVAL) .max(MIN_VELOCITY_TIME_DELTA); window_state.scroll_velocity = Some(ScrollVelocity { velocity: delta / time_delta, last_update: now, }); Some(ConvertedEvent::Event(crate::event::Event::ScrollWheel { position: window_state.last_cursor_position.to_vec2f(), delta, precise: true, modifiers: from_winit_modifiers_state(window_state.modifiers), })) } Some(TouchPurpose::Select) => { // Continue selecting. window_state.last_cursor_position = touch.location.to_logical(scale_factor as f64); Some(ConvertedEvent::Event( crate::event::Event::LeftMouseDragged { position: window_state.last_cursor_position.to_vec2f(), modifiers: from_winit_modifiers_state(window_state.modifiers), }, )) } Some(TouchPurpose::WindowDrag { start_touch }) => Some(ConvertedEvent::MoveWindowBy { current_touch: touch.location, start_touch, }), None => None, } } /// Handles the `TouchPhase::Ended` phase of a touch event. /// /// Note: This function intentionally does NOT clear `last_touch_purpose` for normal taps. /// The purpose is cleared later in `handle_converted_galaxyui_event` after soft keyboard /// logic runs, which needs to check if the touch was still a Tap (vs Scroll/Select/WindowDrag). fn convert_touch_ended( touch: Touch, window_state: &mut WindowState, scale_factor: f32, ) -> Option { // Check the purpose but don't clear it yet - we'll clear it later // in handle_converted_galaxyui_event after checking if we need to // update the soft keyboard. let is_long_press = if let Some(TouchPurpose::Tap(_, _, start_time)) = &window_state.last_touch_purpose { start_time.elapsed() >= LONG_PRESS_DURATION } else { false }; let is_window_drag = matches!( &window_state.last_touch_purpose, Some(TouchPurpose::WindowDrag { .. }) ); window_state.last_cursor_position = touch.location.to_logical(scale_factor as f64); window_state.current_mouse_button_pressed = None; // Long press: still in Tap state and held longer than LONG_PRESS_DURATION if is_long_press { // Clear the purpose here since we're returning early window_state.last_touch_purpose = None; return Some(ConvertedEvent::Event(crate::event::Event::RightMouseDown { position: window_state.last_cursor_position.to_vec2f(), cmd: window_state.modifiers.super_key(), shift: window_state.modifiers.shift_key(), click_count: 1, })); } // WindowDrag doesn't need a mouse up event if is_window_drag { // Clear the purpose here since we're returning early window_state.last_touch_purpose = None; return None; } // Don't clear last_touch_purpose yet - it will be cleared in // handle_converted_galaxyui_event after soft keyboard logic runs. Some(ConvertedEvent::Event(crate::event::Event::LeftMouseUp { position: window_state.last_cursor_position.to_vec2f(), modifiers: from_winit_modifiers_state(window_state.modifiers), })) } /// Handles the `TouchPhase::Cancelled` phase of a touch event. /// /// Cancelled touches only clean up state without triggering any action events. fn convert_touch_cancelled( touch: Touch, window_state: &mut WindowState, scale_factor: f32, ) -> Option { window_state.last_touch_purpose.take(); window_state.last_cursor_position = touch.location.to_logical(scale_factor as f64); window_state.current_mouse_button_pressed = None; None } /// A structure to manage state /// [`winit::event_loop::EventLoop`] and generate the appropriate callbacks into /// the UI framework. pub(super) struct EventLoop { ui_app: crate::App, callbacks: AppCallbackDispatcher, init_fn: Option, window_class: Option, state: State, proxy: EventLoopProxy, ime_enabled: bool, /// Whether to downrank non-NVIDIA vulkan adapters. This is set to true when we detect a DRI3 /// error that occurs when trying to present against a non-NVIDIA Vulkan adapter when the /// PRIME Profile is set to "Performance" mode. It's not fully clear why this error occurs. Our /// theory is that when the PRIME performance profile is enabled (which indicates to NVIDIA /// Optimus that the machine should _only_ render to the NVIDIA GPU), Optimus determines that /// the Integrated GPU won't be rendered to and puts it in an idle / partially loaded state that /// will eventually trigger these DRI3 `BadMatch` errors when we attempt to render to it. downrank_non_nvidia_vulkan_adapters: bool, /// Soft keyboard manager for mobile WASM. #[cfg(target_family = "wasm")] soft_keyboard_manager: Option>, } impl EventLoop { pub fn new( ui_app: crate::App, callbacks: platform::AppCallbacks, init_fn: impl FnOnce(&mut AppContext, LocalBoxFuture<'static, crate::App>) + 'static, window_class: Option, proxy: EventLoopProxy, ) -> Self { Self { ui_app: ui_app.clone(), callbacks: AppCallbackDispatcher::new(callbacks, ui_app), init_fn: Some(Box::new(init_fn)), window_class, state: Default::default(), proxy, ime_enabled: false, downrank_non_nvidia_vulkan_adapters: false, #[cfg(target_family = "wasm")] soft_keyboard_manager: None, } } /// Handles a single [`winit::event::Event`]. pub fn handle_event(&mut self, evt: Event, window_target: &ActiveEventLoop) { window_target.set_control_flow(ControlFlow::Wait); match evt { Event::NewEvents(StartCause::Init) => { #[cfg(target_os = "linux")] { let windowing_system = if winit::platform::x11::ActiveEventLoopExtX11::is_x11(window_target) { crate::windowing::WindowingSystem::X11 } else { crate::windowing::WindowingSystem::Wayland }; log::info!("Running app with windowing system: {windowing_system:?}"); if let Err(err) = super::app::WINDOWING_SYSTEM.set(windowing_system) { log::warn!("Could not set global static for windowing system: {err:?}"); } } if let Some(init_fn) = self.init_fn.take() { self.callbacks.initialize_app(init_fn); } // Start listening for various platform events. #[cfg(target_os = "linux")] { super::linux::watch_suspend_resume_changes( self.proxy.clone(), &self.ui_app.background_executor(), ); super::linux::watch_desktop_settings_changes( self.proxy.clone(), &self.ui_app.background_executor(), ); if self.callbacks.has_internet_reachability_changed_callback() { super::linux::watch_network_status_changed( self.proxy.clone(), &self.ui_app.background_executor(), ); } } #[cfg(windows)] match add_network_connection_listener(self.proxy.clone()) { Ok(listener) => { self.state.network_connection_listener = Some(listener); } Err(e) => { log::warn!("Creating a network connection listener failed: {e:?}"); } } // Initialize soft keyboard support on mobile WASM devices. #[cfg(target_family = "wasm")] { self.initialize_soft_keyboard(); } } Event::UserEvent(CustomEvent::OpenWindow { window_id, window_options, }) => { let Some((window, is_tiling_window_manager)) = self.ui_app.update(|ctx| { let window = ctx.windows().platform_window(window_id)?; let is_tiling_window_manager = ctx.windows().is_tiling_window_manager(); Some((window, is_tiling_window_manager)) }) else { return; }; let window = downcast_window(window.as_ref()); match window.open_window( window_target, window_options, &self.window_class, is_tiling_window_manager, self.downrank_non_nvidia_vulkan_adapters, ) { Ok(winit_window_id) => { let window_state = WindowState::new(window_id); self.state.windows.insert(winit_window_id, window_state); // Now that the window has opened and we know its // actual size, notify the framework that the window // size may have (almost certainly) changed. self.callbacks.for_window(window).window_resized(window); } Err(err) => { log::error!("Failed to open window: {err:#}"); // Tell the app that the window is "closing". self.callbacks.window_will_close(window_id); } } } Event::UserEvent(CustomEvent::RunTask(task)) => { let task = ManuallyDrop::into_inner(task); task.run(); } Event::UserEvent(CustomEvent::Terminate(termination_mode)) => { if let ApproveTerminateResult::Terminate = self.terminate_app_requested(termination_mode) { window_target.exit(); } } Event::UserEvent(CustomEvent::UpdateUIApp(callback)) => { self.ui_app.update(callback); } Event::UserEvent(CustomEvent::GlobalShortcutTriggered(shortcut)) => { self.callbacks.global_shortcut_triggered(shortcut) } Event::UserEvent(CustomEvent::CloseWindow { window_id, termination_mode, }) => { if let Some(winit_window_id) = self .state .windows .iter() .find(|(_, v)| v.window_id == window_id) .map(|(k, _)| k) .cloned() { self.close_window_requested( window_id, winit_window_id, termination_mode, window_target, ) } } Event::UserEvent(CustomEvent::ActiveWindowChanged) => { let app_was_active = self .ui_app .read(|ctx| ctx.windows().active_window()) .is_some(); let active_window_id = match self.state.pending_active_window_change.take() { None => return, Some(ActiveWindowChange::FocusOut) => None, Some(ActiveWindowChange::FocusIn(window_id)) => Some(window_id), }; let active_window_id = active_window_id .and_then(|window_id| self.state.windows.get(&window_id)) .map(|state| state.window_id); self.callbacks.active_window_changed(active_window_id); // If the application became active or inactive, invoke the appropriate callback. let app_is_active = active_window_id.is_some(); match (app_was_active, app_is_active) { (false, true) => self.callbacks.app_became_active(), (true, false) => self.callbacks.app_resigned_active(), _ => {} }; } Event::UserEvent(CustomEvent::RequestUserAttention { window_id }) => { self.ui_app.update(|ctx| { if ctx.windows().active_window() == Some(window_id) { // The current window is already active, early return since requesting user attention would be // a noop. return; } let Some(window) = ctx.windows().platform_window(window_id) else { return; }; let window = downcast_window(window.as_ref()); window.request_user_attention(); // To mimic the behavior on Mac, we only request user attention for 1 second before then stopping // the request. This is especially needed on x11 since the app icon will bounce in perpetuity until // is explicitly told to stop. let event_loop_proxy = self.proxy.clone(); ctx.foreground_executor() .spawn(async move { Timer::after(Duration::from_secs(1)).await; let _ = event_loop_proxy .send_event(CustomEvent::StopRequestingUserAttention { window_id }); }) .detach(); }); } Event::UserEvent(CustomEvent::StopRequestingUserAttention { window_id }) => { self.ui_app.update(|ctx| { let Some(window) = ctx.windows().platform_window(window_id) else { return; }; let window = downcast_window(window.as_ref()); window.stop_requesting_user_attention(); }); } Event::UserEvent(CustomEvent::Clipboard(clipboard_event)) => { self.handle_clipboard_event(clipboard_event); } Event::UserEvent(CustomEvent::SetCursorShape(cursor)) => { self.ui_app.update(|ctx| { let Some(window_id) = ctx.windows().active_window() else { return; }; let Some(window) = ctx.windows().platform_window(window_id) else { return; }; let winit_window = downcast_window(window.as_ref()); winit_window.set_cursor_icon(cursor); }); } Event::UserEvent(CustomEvent::ActiveCursorPositionUpdated) => { if self.ime_enabled { self.update_ime_position(); } } Event::UserEvent(CustomEvent::AboutToSleep) => { #[cfg(target_os = "linux")] self.prepare_for_sleep_on_linux(window_target); self.callbacks.cpu_will_sleep(); } Event::UserEvent(CustomEvent::ResumedFromSleep) => { #[cfg(target_os = "linux")] self.resume_from_sleep_on_linux(); self.callbacks.cpu_awakened(); } Event::UserEvent(CustomEvent::InternetConnected) => { self.callbacks.internet_reachability_changed(true); } Event::UserEvent(CustomEvent::InternetDisconnected) => { self.callbacks.internet_reachability_changed(false) } Event::UserEvent(CustomEvent::SystemThemeChanged) => { self.callbacks.os_appearance_changed(); } Event::WindowEvent { window_id: _, event: WindowEvent::ThemeChanged(_new_theme), } => { self.callbacks.os_appearance_changed(); } Event::UserEvent(CustomEvent::SendNotification { notification_info, window_id, }) => { self.send_notification(notification_info, window_id); } Event::UserEvent(CustomEvent::FocusWindow { window_id }) => { self.focus_window(window_id); } Event::UserEvent(CustomEvent::RequestNotificationPermissions(callback)) => { self.request_notification_permissions(callback); } Event::UserEvent(CustomEvent::DragAndDropFilesDebounced { window_id }) => { self.handle_debounced_drag_drop(window_id); } #[cfg(target_family = "wasm")] Event::UserEvent(CustomEvent::SoftKeyboardInput(input)) => { self.handle_soft_keyboard_input(input); } #[cfg(target_family = "wasm")] Event::UserEvent(CustomEvent::VisualViewportResized { width, height }) => { self.handle_visual_viewport_resize(width, height); } Event::UserEvent(CustomEvent::MomentumScroll { window_id }) => { let Some(window_state) = self.state.windows.get_mut(&window_id) else { return; }; let Some(mut velocity) = window_state.scroll_velocity else { return; }; let now = Instant::now(); let elapsed = now.duration_since(velocity.last_update).as_secs_f32(); velocity.last_update = now; // Apply time-based decay: v_new = v_old * decay^(Δt/interval) let decay_factor = MOMENTUM_DECAY.powf(elapsed / MOMENTUM_DECAY_INTERVAL); velocity.velocity *= decay_factor; if velocity.velocity.length() < MOMENTUM_MIN_VELOCITY { window_state.cancel_momentum_scroll(); return; } window_state.scroll_velocity = Some(velocity); // Convert velocity (px/sec) to scroll delta using elapsed time. let delta = velocity.velocity * elapsed; let position = window_state.last_cursor_position.to_vec2f(); self.handle_converted_galaxyui_event( window_id, crate::event::Event::ScrollWheel { position, delta, precise: true, modifiers: ModifiersState::default(), }, ); } Event::WindowEvent { window_id, event: WindowEvent::RedrawRequested, } => self.redraw_window(window_id, window_target), Event::WindowEvent { window_id: winit_window_id, event: WindowEvent::CloseRequested, } => { if let Some(state) = self.state.windows.get(&winit_window_id) { self.close_window_requested( state.window_id, winit_window_id, TerminationMode::Cancellable, window_target, ); } } Event::WindowEvent { event: WindowEvent::Destroyed, .. } => { // TODO(vorporeal): Should we be calling approve_termination() here? // i.e.: should we invoke a helper shared with CustomEvent::Terminate? if cfg!(not(target_os = "macos")) && self.state.windows.is_empty() { window_target.exit(); } // When a window loses focus, winit will emit [`WindowEvent::Focused(false)`]. // However, that doesn't fire when a window is closed. So, we trigger that code path // from here to make sure the app knows to update its active window. self.state.pending_active_window_change = Some(ActiveWindowChange::FocusOut); let _ = self.proxy.send_event(CustomEvent::ActiveWindowChanged); } Event::WindowEvent { window_id, event: WindowEvent::Ime(evt), } => { self.handle_ime_event(window_id, evt); } Event::WindowEvent { window_id, event: WindowEvent::ScaleFactorChanged { scale_factor, mut inner_size_writer, }, } => { // The following correction is only needed on Windows. if !cfg!(windows) { return; } let Some(window_state) = self.state.windows.get(&window_id) else { return; }; let Some(window) = self .ui_app .read(|ctx| ctx.windows().platform_window(window_state.window_id)) else { return; }; // There is a winit bug such that events which cause a window to switch displays to // one with a different scale factor resize the Warp window to an absurdly small // size, <157, 25> on my system when I repro it. Events include unplugging a // display, changing a display from extended to mirrored, and the like. We work // around that by listening for [`WindowEvent::ScaleFactorChanged`] and changing // the size back up to the minimum dimensions. let size = window.as_ctx().size(); let mut size = LogicalSize::new(size.x() as f64, size.y() as f64); let mut request_new_size = false; if size.width < MIN_WINDOW_SIZE.width { size.width = MIN_WINDOW_SIZE.width; request_new_size = true; } if size.height < MIN_WINDOW_SIZE.height { size.height = MIN_WINDOW_SIZE.height; request_new_size = true; } if request_new_size { if let Err(err) = inner_size_writer.request_inner_size(size.to_physical(scale_factor)) { log::warn!("unable to correct window size: {err:#}"); } } } Event::WindowEvent { window_id, event } => self.handle_window_event(window_id, event), Event::LoopExiting => { // Hide all open windows such that, if the application takes a // second or two to clean up before exiting, this isn't visible // to the end user. self.ui_app.update(|ctx| { use crate::SingletonEntity as _; crate::windowing::WindowManager::handle(ctx).update( ctx, |window_manager, _| { window_manager.hide_app(); }, ); }); #[cfg(windows)] if let Some(network_listener) = self.state.network_connection_listener.take() { network_listener.clean_up(); } self.callbacks.app_will_terminate(); // On non-web platforms, immediately terminate the process instead of returning // from the event loop. This matches the behavior of // `[NSApp terminate]` on macOS, and may avoid some at-exit // crashes that produce noise in our crash reporting data. // On web, it's not possible to exit cleanly, so just return from the event loop // instead. #[cfg(not(target_family = "wasm"))] std::process::exit(0); } _ => {} } } fn redraw_window( &mut self, window_id: winit::window::WindowId, window_target: &ActiveEventLoop, ) { let Some(window_id) = self .state .windows .get(&window_id) .map(|state| state.window_id) else { log::warn!("Redraw requested for a window for which we have no state"); return; }; let Some(window) = self .ui_app .read(|ctx| ctx.windows().platform_window(window_id)) else { log::warn!("Unable to retrieve platform window from app"); return; }; let window = downcast_window(window.as_ref()); #[cfg(target_os = "linux")] if crate::windowing::winit::linux::take_encountered_bad_match_from_dri3_fence_from_fd() { log::warn!("Encountered a DRI3FenceFromFd error, forcing use of the NVIDIA GPU and recreating resources..."); self.downrank_non_nvidia_vulkan_adapters = true; self.ui_app.update(|ctx| { for window_id in ctx.window_ids() { let Some(window) = ctx.windows().platform_window(window_id) else { return; }; let winit_window = downcast_window(window.as_ref()); winit_window.recreate_renderer(self.downrank_non_nvidia_vulkan_adapters); } }) } let render_result = (|| { // Before building the scene, make sure the window size is up-to-date, to ensure // that the scene is built at a size that matches the size we're about to render at. window.update_size_if_needed()?; let new_scene = if !window.has_scene() { Some(self.callbacks.for_window(window).build_scene(window)) } else { None }; self.callbacks .with_mutable_app_context(|ctx| window.render(new_scene, ctx.font_cache())) })(); match render_result { Ok(_) => self.callbacks.for_window(window).frame_drawn(), Err(err) => { log::warn!("Failed to render frame: {err:#}"); self.callbacks.for_window(window).frame_failed_to_draw(); match err { // If we failed to configure the surface, or... renderer::Error::SurfaceConfigureError { .. } // If the device was lost, or... | renderer::Error::SurfaceError(renderer::GetSurfaceTextureError::Lost) | renderer::Error::DeviceLost // If we ran into any other wgpu error - | renderer::Error::Unknown(_)=> { log::warn!("Recreating the renderer in an attempt to recover..."); window.drop_renderer(Box::new(window_target.owned_display_handle())); window.recreate_renderer(self.downrank_non_nvidia_vulkan_adapters); } _ => {} } } } } /// Handles a [`winit::event::WindowEvent`]. fn handle_window_event(&mut self, window_id: winit::window::WindowId, evt: WindowEvent) { let Some(event) = self.convert_window_event(window_id, evt) else { return; }; let Some(window_state) = self.state.windows.get_mut(&window_id) else { return; }; let Some(window) = self .ui_app .read(|ctx| ctx.windows().platform_window(window_state.window_id)) else { return; }; match event { ConvertedEvent::Event(event) => { self.handle_converted_galaxyui_event(window_id, event); } ConvertedEvent::Resize => { let window = downcast_window(window.as_ref()); window.handle_resize(); self.callbacks.for_window(window).window_resized(window); self.callbacks.window_resized(); } ConvertedEvent::ModifierKeyChanged { key_code, state } => { let mut window_callbacks = self.callbacks.for_window(window.as_ref()); window_callbacks.dispatch_event(crate::event::Event::ModifierKeyChanged { key_code, state: match state { ElementState::Pressed => crate::event::KeyState::Pressed, ElementState::Released => crate::event::KeyState::Released, }, }); } ConvertedEvent::KeyDownWithTypedCharacters { chars, event } => { // To match the behavior of macOS: first try to dispatch the underlying keydown // event. If it was not handled (and doesn't include the cmd modifier), send a // `TypedCharacters` event. (We don't send `TypedCharacters` events for keypresess // that include the cmd key because they are assumed to be // intended as OS-level or application-level shortcuts. This matches the behavior // on macOS.) let cmd_pressed = match &event { crate::event::Event::KeyDown { keystroke, .. } => keystroke.cmd, _ => false, }; let mut window_callbacks = self.callbacks.for_window(window.as_ref()); let result = window_callbacks.dispatch_event(event); if !result.handled && !cmd_pressed { if let Some(chars) = chars { window_callbacks.dispatch_event(TypedCharacters { chars }); } } } ConvertedEvent::WindowMoved { new_position } => { let window = downcast_window(window.as_ref()); let scale_factor = self.ui_app.update(|ctx| { ctx.windows() .platform_window(window_state.window_id) .expect("window should exist") .backing_scale_factor() }); let position = new_position.to_logical(scale_factor.into()); let size = window.size(); self.callbacks .for_window(window) .window_moved(RectF::new(vec2f(position.x, position.y), size)); self.callbacks.window_moved(); } ConvertedEvent::MoveWindowBy { current_touch, start_touch, } => { let winit_window = downcast_window(window.as_ref()); if let Some(current_window) = winit_window.outer_position() { // target = current_window + (current_touch - start_touch) let target_x = (current_window.x as f64 + current_touch.x - start_touch.x) as i32; let target_y = (current_window.y as f64 + current_touch.y - start_touch.y) as i32; winit_window.set_outer_position(PhysicalPosition::new(target_x, target_y)); } } } } /// Converts a [`winit::event::WindowEvent`] into a [`ConvertedEvent`], returning [`None`] if /// there is no equivalent/the event should be ignored. fn convert_window_event( &mut self, window_id: winit::window::WindowId, evt: winit::event::WindowEvent, ) -> Option { let window_state = self.state.windows.get_mut(&window_id)?; let scale_factor = self.ui_app.update(|ctx| { ctx.windows() .platform_window(window_state.window_id) .expect("window should exist") .backing_scale_factor() }); match evt { WindowEvent::ModifiersChanged(modifiers) => { let state = modifiers.state(); window_state.modifiers = state; // If Alt is no longer held at all, clear both per-side flags as a safety net // in case a key-release event was dropped (e.g. released while the window was // unfocused). if !state.alt_key() { window_state.left_alt_pressed = false; window_state.right_alt_pressed = false; } Some(ConvertedEvent::Event( crate::event::Event::ModifierStateChanged { mouse_position: window_state.last_cursor_position.to_vec2f(), modifiers: from_winit_modifiers_state(state), // TODO: when we need key codes for voice input on Linux/Windows, we'll need to populate this! key_code: None, }, )) } WindowEvent::CursorMoved { position, .. } => { window_state.last_cursor_position = position.to_logical(scale_factor as f64); match window_state.current_mouse_button_pressed { Some(MouseButton::Left) => Some(ConvertedEvent::Event( crate::event::Event::LeftMouseDragged { position: window_state.last_cursor_position.to_vec2f(), modifiers: from_winit_modifiers_state(window_state.modifiers), }, )), _ => Some(ConvertedEvent::Event(crate::event::Event::MouseMoved { position: window_state.last_cursor_position.to_vec2f(), cmd: window_state.modifiers.super_key(), shift: window_state.modifiers.shift_key(), is_synthetic: false, })), } } WindowEvent::MouseInput { state, button, .. } => match state { ElementState::Pressed => { let click_count = window_state.determine_click_count_and_update_button_state(button); match button { MouseButton::Left => { // ctrl-click should actually be registered as a right-click on mac let ctrl_click = window_state.modifiers.control_key(); if ctrl_click && OperatingSystem::get().is_mac() { Some(ConvertedEvent::Event(crate::event::Event::RightMouseDown { position: window_state.last_cursor_position.to_vec2f(), cmd: window_state.modifiers.super_key(), shift: window_state.modifiers.shift_key(), click_count, })) } else { Some(ConvertedEvent::Event(crate::event::Event::LeftMouseDown { position: window_state.last_cursor_position.to_vec2f(), click_count, is_first_mouse: false, modifiers: from_winit_modifiers_state(window_state.modifiers), })) } } MouseButton::Right => { Some(ConvertedEvent::Event(crate::event::Event::RightMouseDown { position: window_state.last_cursor_position.to_vec2f(), cmd: window_state.modifiers.super_key(), shift: window_state.modifiers.shift_key(), click_count, })) } MouseButton::Middle => Some(ConvertedEvent::Event( crate::event::Event::MiddleMouseDown { position: window_state.last_cursor_position.to_vec2f(), cmd: window_state.modifiers.super_key(), shift: window_state.modifiers.shift_key(), click_count, }, )), _ => None, } } ElementState::Released => { window_state.current_mouse_button_pressed = None; match button { MouseButton::Left => { Some(ConvertedEvent::Event(crate::event::Event::LeftMouseUp { position: window_state.last_cursor_position.to_vec2f(), modifiers: from_winit_modifiers_state(window_state.modifiers), })) } _ => None, } } }, // Handle and convert touch events into mouse events. WindowEvent::Touch(touch) => match touch.phase { TouchPhase::Started => convert_touch_started(touch, window_state, scale_factor), TouchPhase::Moved => { let titlebar_height = self .ui_app .read(|ctx| ctx.windows().platform_window(window_state.window_id)) .map(|w| downcast_window(w.as_ref()).titlebar_height()) .unwrap_or(DEFAULT_TITLEBAR_HEIGHT); convert_touch_moved(touch, window_state, scale_factor, titlebar_height) } TouchPhase::Ended => convert_touch_ended(touch, window_state, scale_factor), TouchPhase::Cancelled => convert_touch_cancelled(touch, window_state, scale_factor), }, WindowEvent::MouseWheel { delta, .. } => { let (precise, delta) = match delta { winit::event::MouseScrollDelta::LineDelta(horiz, vert) => { (false, Vector2F::new(horiz, vert)) } winit::event::MouseScrollDelta::PixelDelta(px) => { (true, px.to_logical(scale_factor as f64).to_vec2f()) } }; Some(ConvertedEvent::Event(crate::event::Event::ScrollWheel { position: window_state.last_cursor_position.to_vec2f(), delta, precise, modifiers: from_winit_modifiers_state(window_state.modifiers), })) } WindowEvent::KeyboardInput { event, is_synthetic, .. } => { // Track per-side Alt press state so that the extra-meta-keys setting can // distinguish between left Alt and right Alt. `ModifiersState` alone does // not expose which side of a modifier was pressed. if let keyboard::PhysicalKey::Code(keycode) = &event.physical_key { let is_pressed = event.state == ElementState::Pressed; match keycode { KeyCode::AltLeft => window_state.left_alt_pressed = is_pressed, KeyCode::AltRight => window_state.right_alt_pressed = is_pressed, _ => {} } } // If the event is a modifier key, just by itself, we handle it specially, issuing // the appropriate Warp-side event (ModifierKeyChanged). if let (None, keyboard::PhysicalKey::Code(keycode)) = (&event.text, &event.physical_key) { if let Ok(mapped_keycode) = try_from_winit_keycode(keycode) { return Some(ConvertedEvent::ModifierKeyChanged { key_code: mapped_keycode, state: event.state, }); } } let event_text = event.text.as_ref().map(|text| text.to_string()); let warp_ui_event = convert_keyboard_input_event(event, window_state, is_synthetic)?; Some(ConvertedEvent::KeyDownWithTypedCharacters { chars: event_text, event: warp_ui_event, }) } WindowEvent::Resized(_) => Some(ConvertedEvent::Resize), WindowEvent::Focused(is_focused) => { // On mobile WASM, ignore focus-out events. The soft keyboard's hidden input // causes spurious focus events, and mobile doesn't have the concept of // "unfocused windows" anyway - you're either in the app or switched away entirely. #[cfg(target_family = "wasm")] if !is_focused && crate::platform::wasm::is_mobile_device() { return None; } // Clear tracked per-side Alt state when we lose focus so that a release // event dropped while another window had focus can't leave us believing a // side is still held. if !is_focused { window_state.left_alt_pressed = false; window_state.right_alt_pressed = false; } // On the next tick of the event loop, notify the ui_app that focus has // transferred, but only if there isn't already one of these // [`CustomEvent::ActiveWindowChanged`] pending. This coalesces multiple // `WindowEvent::Focused` events into a single CustomEvent. if self.state.pending_active_window_change.is_none() { let _ = self.proxy.send_event(CustomEvent::ActiveWindowChanged); } if is_focused { self.state.pending_active_window_change = Some(ActiveWindowChange::FocusIn(window_id)); } else { self.state.pending_active_window_change = Some(ActiveWindowChange::FocusOut); } None } WindowEvent::DroppedFile(path_buf) => { let Some(path) = path_buf.as_os_str().to_str() else { log::warn!("Failed to convert dropped file path to UTF-8: {path_buf:?}"); return None; }; // Add this file to the pending list window_state.pending_drag_drop_files.push(path.to_string()); // Only schedule a debounced event if we don't already have one running if !window_state.has_pending_drag_drop_timer { window_state.has_pending_drag_drop_timer = true; let proxy = self.proxy.clone(); // Wait to collect all files before sending one event for all of them self.ui_app.update(|ctx| { ctx.foreground_executor() .spawn(async move { Timer::after(DRAG_DROP_DEBOUNCE_TIMEOUT).await; let _ = proxy.send_event(CustomEvent::DragAndDropFilesDebounced { window_id, }); }) .detach(); }); } None // Use debounced event instead of immediate processing } WindowEvent::Moved(new_position) => Some(ConvertedEvent::WindowMoved { new_position }), _ => None, } } #[allow(unused_variables)] fn send_notification(&mut self, notification_info: NotificationInfo, window_id: WindowId) { let proxy = self.proxy.clone(); self.ui_app.update(|ctx| { ctx.background_executor() .spawn(async move { crate::windowing::winit::notifications::send_notification( notification_info, window_id, proxy, ) .await; }) .detach() }); } fn focus_window(&mut self, window_id: WindowId) { self.ui_app.update(|ctx| { let Some(window) = ctx.windows().platform_window(window_id) else { return; }; let window = downcast_window(window.as_ref()); window.focus(); }); } #[allow(unused_variables)] fn request_notification_permissions(&mut self, callback: RequestPermissionsCallback) { let proxy = self.proxy.clone(); self.ui_app.update(|ctx| { ctx.background_executor() .spawn(async move { #[cfg(target_family = "wasm")] crate::windowing::winit::notifications::request_notification_permissions( callback, proxy, ) .await; #[cfg(target_os = "linux")] { // On Linux, there is no concept of requesting notification permissions. This // logic is hard-coded to always return an outcome of "Accepted". let _ = proxy.send_event(CustomEvent::UpdateUIApp(Box::new(|ctx| { callback(RequestPermissionsOutcome::Accepted, ctx) }))); } }) .detach() }); } /// Takes all pending dropped files and creates a single DragAndDropFiles event. fn handle_debounced_drag_drop(&mut self, window_id: winit::window::WindowId) { let Some(window_state) = self.state.windows.get_mut(&window_id) else { return; }; window_state.has_pending_drag_drop_timer = false; if window_state.pending_drag_drop_files.is_empty() { return; } // Take ownership of accumulated files let paths = std::mem::take(&mut window_state.pending_drag_drop_files); let location = window_state.last_cursor_position.to_vec2f(); // Create and dispatch the batched drag-and-drop event let drag_drop_event = crate::Event::DragAndDropFiles { paths, location }; self.handle_converted_galaxyui_event(window_id, drag_drop_event); } /// Handles a request to close the window with the given galaxyui and winit /// IDs. fn close_window_requested( &mut self, window_id: crate::WindowId, winit_window_id: winit::window::WindowId, termination_mode: TerminationMode, window_target: &ActiveEventLoop, ) { if matches!( termination_mode, TerminationMode::ForceTerminate | TerminationMode::ContentTransferred ) { self.close_window(window_id, winit_window_id, window_target); } else if let ApproveTerminateResult::Terminate = self.callbacks.should_close_window(window_id) { self.close_window(window_id, winit_window_id, window_target); } } fn close_window( &mut self, window_id: crate::WindowId, winit_window_id: winit::window::WindowId, window_target: &ActiveEventLoop, ) { let window_state = self.state.windows.remove(&winit_window_id); // Drop the renderer before we actually clean up the window, to ensure // that the window outlives the `wgpu` surface that references it. if let Some(WindowState { window_id, .. }) = window_state { if let Some(window) = self .ui_app .read(|ctx| ctx.windows().platform_window(window_id)) { downcast_window(window.as_ref()) .drop_renderer(Box::new(window_target.owned_display_handle())); } } self.callbacks.window_will_close(window_id) } fn terminate_app_requested( &mut self, termination_mode: TerminationMode, ) -> ApproveTerminateResult { if matches!( termination_mode, TerminationMode::ForceTerminate | TerminationMode::ContentTransferred ) { return ApproveTerminateResult::Terminate; } let approve_terminate_result = self.callbacks.should_terminate_app(); if let ApproveTerminateResult::Terminate = approve_terminate_result {} approve_terminate_result } fn handle_ime_event(&mut self, winit_window_id: WinitWindowId, event: ImeEvent) { match event { winit::event::Ime::Enabled => { self.ime_enabled = true; self.ui_app .update(|ctx| ctx.report_active_cursor_position_update()); } winit::event::Ime::Preedit(preedit_text, cursor_position) => { if !self.ime_enabled { return; } let Some(window_state) = self.state.windows.get_mut(&winit_window_id) else { return; }; let Some(window) = self .ui_app .read(|ctx| ctx.windows().platform_window(window_state.window_id)) else { return; }; let mut window_callbacks = self.callbacks.for_window(window.as_ref()); window_callbacks.dispatch_event(SetMarkedText { marked_text: preedit_text, selected_range: cursor_position .map(|cursor_position| cursor_position.0..cursor_position.1) .unwrap_or(0..0), }); } winit::event::Ime::Commit(chars) => { let Some(window_state) = self.state.windows.get_mut(&winit_window_id) else { return; }; let Some(window) = self .ui_app .read(|ctx| ctx.windows().platform_window(window_state.window_id)) else { return; }; let mut window_callbacks = self.callbacks.for_window(window.as_ref()); // We clear the marked text state before inserting typed characters so that the Vim // FSA knows it can interpret the committed text as a user insertion. window_callbacks.dispatch_event(ClearMarkedText); window_callbacks.dispatch_event(TypedCharacters { chars }); } winit::event::Ime::Disabled => { self.ime_enabled = false; } }; } /// Handle events that may be handled by galaxyui, or maybe not in some cases, e.g. window /// drag-to-resize or drag-to-move. fn handle_converted_galaxyui_event( &mut self, window_id: winit::window::WindowId, event: crate::Event, ) { let Some(window_state) = self.state.windows.get_mut(&window_id) else { return; }; let Some(window) = self .ui_app .read(|ctx| ctx.windows().platform_window(window_state.window_id)) else { return; }; let winit_window = downcast_window(window.as_ref()); // There is some state on the [`winit::window::Window`] that needs to be kept // in sync with the cursor position in order for drag-resizing windows to work. if let crate::Event::MouseMoved { .. } = event { if !winit_window.is_decorated() { winit_window.update_drag_resize_state(window_state.last_cursor_position); } } // Check if we should start a window drag-resize. If so, do that instead of // passing the event into galaxyui. Skip for touch events as drag_resize_window // doesn't work properly with touch input on Windows. if let crate::event::Event::LeftMouseDown { .. } = event { if !winit_window.is_decorated() && winit_window.try_drag_resize() && window_state.last_touch_purpose.is_none() { // If we initiated a drag via the method // [`winit::window::Window::drag_resize_window`], we will not // receive a MouseInput event when the button is release, so we // pre-emptively set this back to None. window_state.current_mouse_button_pressed = None; return; } } let dispatch_result = self .callbacks .for_window(winit_window) .dispatch_event(event.clone()); // If the app didn't handle the event, galaxyui might still want to do something // with it if it's a click within the "titlebar region" at the top. if !dispatch_result.handled { if let crate::event::Event::LeftMouseDown { click_count, position, .. } = event { // The WASM "window" does not support dragging or maximization. let titlebar_height = winit_window.titlebar_height(); if position.y() < titlebar_height && !cfg!(target_family = "wasm") { // Double-clicking the titlebar does maximize/restore. if click_count >= 2 { window.toggle_maximized(); } else if window_state.last_touch_purpose.is_none() { // Single-click drag moves the window. Skip for touch events as // drag_window doesn't work properly with touch input on Windows. // We won't receive MouseInput::Released after drag_window. match winit_window.drag_window() { Ok(_) => window_state.current_mouse_button_pressed = None, Err(err) => log::error!("error dragging window: {err:?}"), } } } } } // On mobile WASM, update soft keyboard state based on touch/click events. // This must happen synchronously within the touch event handler (user gesture context) // for the browser to allow focusing the hidden input element. // // For touch events, we defer keyboard updates until LeftMouseUp to avoid showing // the keyboard during drags/scrolls (which start with LeftMouseDown but later get // reclassified as scroll gestures). We only trigger the keyboard if the touch purpose // is still Tap (meaning it was never reclassified to Scroll, Select, or WindowDrag). #[cfg(target_family = "wasm")] { // First, check what kind of event we have without holding a mutable borrow. let touch_info = self.state.windows.get(&window_id).and_then(|ws| { ws.last_touch_purpose.as_ref().map(|purpose| { // Check if this is still a tap (not a scroll/drag/select) matches!(purpose, TouchPurpose::Tap(..)) }) }); match (&event, touch_info) { // Regular mouse click (not touch) - update keyboard immediately. (crate::event::Event::LeftMouseDown { .. }, None) => { self.update_soft_keyboard_state(dispatch_result.soft_keyboard_requested); } // Touch LeftMouseDown - store keyboard request for later use on LeftMouseUp. (crate::event::Event::LeftMouseDown { .. }, Some(_)) => { if let Some(ws) = self.state.windows.get_mut(&window_id) { ws.pending_soft_keyboard_request = dispatch_result.soft_keyboard_requested; } } // Touch tap completed and purpose is still Tap - use stored keyboard request. (crate::event::Event::LeftMouseUp { .. }, Some(true)) => { let should_show = self .state .windows .get(&window_id) .map(|ws| ws.pending_soft_keyboard_request) .unwrap_or(false); self.update_soft_keyboard_state(should_show); } _ => {} }; } // On LeftMouseUp: clear touch state and start momentum scrolling if applicable. if matches!(event, crate::event::Event::LeftMouseUp { .. }) { let should_start_momentum = self .state .windows .get_mut(&window_id) .and_then(|window_state| { let purpose = window_state.last_touch_purpose.take()?; if !matches!(purpose, TouchPurpose::Scroll(_)) { return None; } window_state.last_mouse_button_pressed = None; let scroll_vel = window_state.scroll_velocity.as_mut()?; // Clamp velocity to prevent excessively fast momentum scrolling // from quick flick gestures or batched touch events that produce // artificially large velocity spikes. scroll_vel.velocity = vec2f( scroll_vel .velocity .x() .clamp(-MOMENTUM_MAX_VELOCITY, MOMENTUM_MAX_VELOCITY), scroll_vel .velocity .y() .clamp(-MOMENTUM_MAX_VELOCITY, MOMENTUM_MAX_VELOCITY), ); (scroll_vel.velocity.length() >= MOMENTUM_THRESHOLD).then_some(()) }) .is_some(); if should_start_momentum { let abort_handle = self.start_momentum_scroll(window_id); if let Some(ws) = self.state.windows.get_mut(&window_id) { ws.momentum_scroll_abort = Some(abort_handle); } } } } fn handle_clipboard_event(&mut self, clipboard_event: ClipboardEvent) { let Some(active_window_id) = self.ui_app.read(|ctx| ctx.windows().active_window()) else { return; }; match clipboard_event { #[allow(unused_variables)] ClipboardEvent::Paste(content) => { cfg_if::cfg_if! { if #[cfg(target_family = "wasm")] { self.ui_app.update(|ctx| { ctx.clipboard().save(content); }) } } self.ui_app .dispatch_standard_action(active_window_id, StandardAction::Paste); } } } /// Starts a timer that triggers MomentumScroll events at a fixed interval. fn start_momentum_scroll(&self, window_id: winit::window::WindowId) -> AbortHandle { let proxy = self.proxy.clone(); // Use abortable future so we can cancel momentum scrolling when the user touches the screen. // Aborting is expected since we cancelled the animation, so we can safely discard it. let (future, abort_handle) = futures::future::abortable(async move { loop { Timer::after(MOMENTUM_FRAME_INTERVAL).await; let _ = proxy.send_event(CustomEvent::MomentumScroll { window_id }); } }); self.ui_app.read(|ctx| { ctx.foreground_executor() .spawn(async move { let _ = future.await; }) .detach(); }); abort_handle } fn update_ime_position(&mut self) { let Some(active_window_id) = self.ui_app.read(|ctx| ctx.windows().active_window()) else { return; }; let Some(window) = self .ui_app .update(|ctx| ctx.windows().platform_window(active_window_id)) else { return; }; let mut window_callbacks = self.callbacks.for_window(window.as_ref()); let active_cursor_position = window_callbacks.get_active_cursor_position(); if let Some(active_cursor_position) = active_cursor_position { let winit_window = downcast_window(window.as_ref()); let position = LogicalPosition::new( active_cursor_position.position.origin_x(), active_cursor_position.position.origin_y() + (1.2 * active_cursor_position.font_size), ); // Currently the size argument is not supported on X11. We calculate it here anyway. let size = LogicalSize::new( active_cursor_position.font_size, active_cursor_position.font_size, ); // TODO(abhishek): We make sure that the position is different than last time to prevent winit from // caching the old position and not properly updating on `WindowMoved` or `WindowResized` events. winit_window.set_ime_position(LogicalPosition::new(position.x, position.y + 1.), size); winit_window.set_ime_position(position, size); } } /// Prepares for an impending system suspend/sleep on Linux. /// /// When using a dedicated GPU, the kernel sometimes disables the device /// during system suspend. When this happens, wgpu treats the device as /// "lost", which currently produces an unavoidable panic the next time /// it is accessed. /// /// To work around this, we drop all rendering resources pre-suspend, and /// re-create them post-resume. #[cfg(target_os = "linux")] fn prepare_for_sleep_on_linux(&mut self, window_target: &ActiveEventLoop) { self.ui_app.update(|ctx| { for window_id in ctx.window_ids() { let Some(window) = ctx.windows().platform_window(window_id) else { return; }; let winit_window = downcast_window(window.as_ref()); winit_window.drop_renderer(Box::new(window_target.owned_display_handle())); } }); } /// Resumes from system suspend/sleep on Linux. /// /// See the [`Self::prepare_for_sleep_on_linux`] documentation for more /// details. #[cfg(target_os = "linux")] fn resume_from_sleep_on_linux(&mut self) { self.ui_app.update(|ctx| { for window_id in ctx.window_ids() { let Some(window) = ctx.windows().platform_window(window_id) else { return; }; let winit_window = downcast_window(window.as_ref()); winit_window.recreate_renderer(self.downrank_non_nvidia_vulkan_adapters); } }); } /// Initializes the soft keyboard manager on mobile WASM devices. /// /// This creates the hidden input element that triggers the soft keyboard /// when focused. The manager is only created on mobile devices. #[cfg(target_family = "wasm")] fn initialize_soft_keyboard(&mut self) { use crate::platform::wasm::{is_mobile_device, SoftKeyboardInput, SoftKeyboardManager}; if !is_mobile_device() { log::info!("Not a mobile device, skipping soft keyboard initialization"); return; } log::info!("Initializing soft keyboard for mobile WASM"); // Create a callback that handles soft keyboard input events. // These are forwarded to the event loop via CustomEvent, where they // will be dispatched to the active window as TypedCharacters/IME events. let proxy = self.proxy.clone(); let on_input = Box::new(move |input: SoftKeyboardInput| { log::debug!("Soft keyboard callback received input: {:?}", input); if let Err(e) = proxy.send_event(CustomEvent::SoftKeyboardInput(input)) { log::error!("Failed to send SoftKeyboardInput event: {:?}", e); } }); match SoftKeyboardManager::new(on_input) { Ok(manager) => { log::info!("Soft keyboard manager initialized successfully"); self.soft_keyboard_manager = Some(manager); } Err(err) => { log::error!("Failed to initialize soft keyboard manager: {:?}", err); } } } /// Updates the soft keyboard visibility based on the dispatch result. /// /// This is called after processing touch events, while still in user gesture context. /// Since everything renders to a canvas, the browser can't detect taps "outside" the /// keyboard input, so we must explicitly show/hide based on what the app requested. #[cfg(target_family = "wasm")] fn update_soft_keyboard_state(&mut self, requested: bool) { let Some(manager) = &self.soft_keyboard_manager else { return; }; if requested { log::debug!("App requested soft keyboard, showing it"); manager.show_keyboard(); } else { log::debug!("App did not request soft keyboard, hiding it"); manager.hide_keyboard(); } } /// Attempts to refocus the main canvas element. /// /// This is needed to restore app interactivity after the soft keyboard is dismissed, /// particularly on iOS Safari which can leave the app in a "blurred" state. /// /// We defer the focus to the next frame using setTimeout(0) because calling focus() /// synchronously during event processing may not work reliably on iOS Safari. #[cfg(target_family = "wasm")] fn refocus_canvas() { use wasm_bindgen::{prelude::Closure, JsCast}; // Defer focus to next frame to ensure we're outside the current event processing. let callback = Closure::once(Box::new(|| { if let Some(canvas) = gloo::utils::document() .query_selector("canvas") .ok() .flatten() { if let Ok(html_element) = canvas.dyn_into::() { let _ = html_element.focus(); } } }) as Box); let _ = gloo::utils::window().set_timeout_with_callback(callback.as_ref().unchecked_ref()); // Prevent the closure from being dropped immediately callback.forget(); } /// Handles input events from the soft keyboard on mobile WASM. /// /// Converts `SoftKeyboardInput` events into galaxyui `Event`s and dispatches /// them to the active window, similar to how `handle_ime_event` works. #[cfg(target_family = "wasm")] fn handle_soft_keyboard_input(&mut self, input: crate::platform::wasm::SoftKeyboardInput) { use crate::platform::wasm::SoftKeyboardInput; // On WASM, get the first (and typically only) window if there's no "active" window let window_id = self.ui_app.read(|ctx| { ctx.windows() .active_window() .or_else(|| ctx.window_ids().next()) }); let Some(window_id) = window_id else { log::debug!("No window for soft keyboard input"); return; }; let Some(window) = self .ui_app .read(|ctx| ctx.windows().platform_window(window_id)) else { log::debug!("Could not get platform window for soft keyboard input"); return; }; let mut window_callbacks = self.callbacks.for_window(window.as_ref()); match input { SoftKeyboardInput::TextInserted(text) => { window_callbacks.dispatch_event(TypedCharacters { chars: text }); } SoftKeyboardInput::Backspace => { window_callbacks.dispatch_event(crate::Event::KeyDown { keystroke: crate::keymap::Keystroke { ctrl: false, alt: false, shift: false, cmd: false, meta: false, key: "backspace".to_string(), }, chars: String::new(), details: crate::event::KeyEventDetails::default(), is_composing: false, }); } SoftKeyboardInput::KeyboardDismissed => { // The keyboard was dismissed (user tapped elsewhere or pressed "Done"). // Refocus the canvas to restore interactivity. log::debug!("Soft keyboard was dismissed, refocusing canvas"); Self::refocus_canvas(); } SoftKeyboardInput::KeyDown(key) => { // Map special key names to their control characters (e.g., Enter → "\r") // so the terminal's key_down handler can process them. let chars = match key.to_lowercase().as_str() { "enter" => "\r".to_string(), _ => String::new(), }; window_callbacks.dispatch_event(crate::Event::KeyDown { keystroke: crate::keymap::Keystroke { ctrl: false, alt: false, shift: false, cmd: false, meta: false, key: key.to_lowercase(), }, chars, details: crate::event::KeyEventDetails::default(), is_composing: false, }); } } } /// Resizes container when visual viewport changes (e.g., soft keyboard appears). #[cfg(target_family = "wasm")] fn handle_visual_viewport_resize(&mut self, _width: f32, height: f32) { log::debug!("Visual viewport resized, height = {}px", height); if let Some(container) = gloo::utils::document().get_element_by_id("wasm-container") { if let Some(html_element) = container.dyn_ref::() { let _ = html_element .style() .set_property("height", &format!("{}px", height)); } } } } /// Set of possible UI-framework events that have been converted from a [`winit::event::Event`]. #[derive(Debug)] enum ConvertedEvent { Event(crate::Event), /// A keydown event with the actual text of keydown event. We separate the characters from the /// underlying event so that we can produce a `TypedCharacters` event if the initial `event` is /// not handled by the UI framework. KeyDownWithTypedCharacters { chars: Option, event: crate::Event, }, Resize, WindowMoved { new_position: PhysicalPosition, }, ModifierKeyChanged { key_code: crate::platform::keyboard::KeyCode, state: ElementState, }, /// Move the window for touch-based window dragging. MoveWindowBy { /// Current touch position (window-relative). current_touch: PhysicalPosition, /// Touch position when drag started (window-relative). start_touch: PhysicalPosition, }, } /// Convert the platform-independent trait object Window into a concrete, platform-specific Window. fn downcast_window(window: &dyn platform::Window) -> &super::Window { window .as_any() .downcast_ref::() .expect("Should not fail to downcast the platform window to its concrete type") }