Files
galaxy/crates/galaxyui/src/windowing/winit/event_loop/mod.rs
T

2053 lines
89 KiB
Rust

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<crate::platform::keyboard::KeyCode, ()> {
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<f64>,
},
}
/// 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<MouseButton>,
/// 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<MouseButtonPressState>,
/// The last known cursor position, measured in logical pixels.
last_cursor_position: winit::dpi::LogicalPosition<f32>,
/// 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<String>,
/// 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<TouchPurpose>,
/// Tracks scroll velocity during active touch scrolling and momentum scrolling.
/// Active phase determined through `momentum_scroll_abort.is_some()`.
scroll_velocity: Option<ScrollVelocity>,
/// Abort handle for momentum scrolling timer. Present only during the momentum phase.
momentum_scroll_abort: Option<AbortHandle>,
/// 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<f32> {
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<winit::window::WindowId, WindowState>,
pending_active_window_change: Option<ActiveWindowChange>,
#[cfg(windows)]
network_connection_listener: Option<WindowsNetworkConnectionPoint>,
}
/// 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<ConvertedEvent> {
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<ConvertedEvent> {
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<f64> =
touch.location.to_logical(scale_factor as f64);
let last_logical: LogicalPosition<f64> =
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<f32> =
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<f64> =
touch.location.to_logical(scale_factor as f64);
let last_logical: LogicalPosition<f64> =
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<ConvertedEvent> {
// 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<ConvertedEvent> {
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<platform::app::AppInitCallbackFn>,
window_class: Option<String>,
state: State,
proxy: EventLoopProxy<CustomEvent>,
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<std::rc::Rc<crate::platform::wasm::SoftKeyboardManager>>,
}
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<String>,
proxy: EventLoopProxy<CustomEvent>,
) -> 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<CustomEvent>, 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<ConvertedEvent> {
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::<web_sys::HtmlElement>() {
let _ = html_element.focus();
}
}
}) as Box<dyn FnOnce()>);
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::<web_sys::HtmlElement>() {
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<String>,
event: crate::Event,
},
Resize,
WindowMoved {
new_position: PhysicalPosition<i32>,
},
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<f64>,
/// Touch position when drag started (window-relative).
start_touch: PhysicalPosition<f64>,
},
}
/// 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::<super::Window>()
.expect("Should not fail to downcast the platform window to its concrete type")
}