Files
marathon/crates/libmarathon/src/platform/desktop/input.rs
Sienna Meridian Satterwhite 0bbc2c094a chore: cleaned up code
Signed-off-by: Sienna Meridian Satterwhite <sienna@r3t.io>
2026-02-07 18:19:29 +00:00

618 lines
21 KiB
Rust

//! Desktop winit event loop integration
//!
//! This module owns the winit event loop and window, converting winit events
//! to engine-agnostic InputEvents and Bevy window events.
//!
//! # Feature Parity with Bevy's WinitPlugin
//!
//! This implementation achieves comprehensive feature parity with bevy_winit,
//! handling all major input and window event types:
//!
//! ## Input Events (WindowEvent)
//! - **Mouse**: Buttons, cursor movement, wheel scrolling, enter/exit
//! - **Keyboard**: Full key event details with modifiers, logical keys, repeat detection
//! - **Touch**: Multi-touch with phase tracking (Started, Moved, Ended, Cancelled)
//! - **Apple Pencil**: Force/pressure detection via calibrated touch force, altitude angle for tilt
//! - **Gestures**: Pinch (zoom), rotation, pan, double-tap (trackpad/touch)
//! - **File Drag & Drop**: Dropped files, hovered files, cancelled hovers
//! - **IME**: International text input with preedit, commit, enabled/disabled states
//!
//! ## Window State Events
//! - **Focus**: Window focused/unfocused tracking
//! - **Occlusion**: Window visibility state (occluded/visible)
//! - **Theme**: System theme changes (light/dark mode)
//! - **Position**: Window moved events
//! - **Resize**: Window resized with physical dimensions
//! - **Scale Factor**: DPI/HiDPI scale factor changes
//!
//! ## Device Events
//! - **Mouse Motion**: Raw mouse delta for FPS camera controls (unbounded movement)
//!
//! ## Application Lifecycle
//! - **Suspended**: iOS/Android backgrounding support
//! - **Resumed**: App foreground/initialization
//! - **Exiting**: Clean shutdown
//!
//! ## Architecture
//!
//! Events flow through three layers:
//! 1. **Winit WindowEvent/DeviceEvent** → Raw OS events
//! 2. **RawWinitEvent** → Buffered events in lock-free channel
//! 3. **InputEvent** → Engine-agnostic input abstraction
//!
//! The executor (`super::executor`) drives the event loop and calls:
//! - `push_window_event()` for window events
//! - `push_device_event()` for device events
//! - `drain_as_input_events()` to consume buffered events each frame
use crate::platform::input::{InputEvent, KeyCode, Modifiers, MouseButton, TouchPhase};
use crossbeam_channel::{Receiver, Sender, unbounded};
use glam::Vec2;
use std::sync::{Mutex, OnceLock};
use std::path::PathBuf;
use winit::event::{
ElementState, MouseButton as WinitMouseButton, MouseScrollDelta, WindowEvent,
Force as WinitForce, TouchPhase as WinitTouchPhase,
Ime as WinitIme,
};
use winit::keyboard::{PhysicalKey, Key as LogicalKey};
use winit::window::Theme as WinitTheme;
/// Raw winit input events before conversion
///
/// Comprehensive event types matching bevy_winit functionality
#[derive(Clone, Debug)]
pub enum RawWinitEvent {
// Mouse events
MouseButton {
button: MouseButton,
state: ElementState,
position: Vec2,
},
CursorMoved {
position: Vec2,
},
CursorEntered,
CursorLeft,
MouseWheel {
delta: Vec2,
position: Vec2,
},
/// Raw mouse motion delta (for FPS camera, etc.)
/// This is separate from CursorMoved and gives unbounded mouse delta
MouseMotion {
delta: Vec2,
},
// Keyboard events
Keyboard {
key: KeyCode,
state: ElementState,
modifiers: Modifiers,
logical_key: String, // Text representation for display
text: Option<String>, // Actual character input
repeat: bool,
},
// Touch events (CRITICAL for Apple Pencil!)
Touch {
phase: WinitTouchPhase,
position: Vec2,
force: Option<WinitForce>,
id: u64,
},
// Gesture events (trackpad/touch)
PinchGesture {
delta: f32,
},
RotationGesture {
delta: f32,
},
DoubleTapGesture,
PanGesture {
delta: Vec2,
},
// File drag & drop
DroppedFile {
path: PathBuf,
},
HoveredFile {
path: PathBuf,
},
HoveredFileCancelled,
// IME (international text input)
ImePreedit {
value: String,
cursor: Option<(usize, usize)>,
},
ImeCommit {
value: String,
},
ImeEnabled,
ImeDisabled,
// Window state events
Focused {
focused: bool,
},
Occluded {
occluded: bool,
},
ThemeChanged {
theme: WinitTheme,
},
Moved {
x: i32,
y: i32,
},
}
/// Lock-free channel for winit events
///
/// The winit event loop sends events here.
/// The engine receives them each frame.
static EVENT_CHANNEL: OnceLock<(Sender<RawWinitEvent>, Receiver<RawWinitEvent>)> = OnceLock::new();
fn get_event_channel() -> &'static (Sender<RawWinitEvent>, Receiver<RawWinitEvent>) {
EVENT_CHANNEL.get_or_init(|| unbounded())
}
/// Current scale factor (needed to convert physical to logical pixels)
static SCALE_FACTOR: Mutex<f64> = Mutex::new(1.0);
/// Set the window scale factor (call when window is created or scale changes)
pub fn set_scale_factor(scale_factor: f64) {
if let Ok(mut sf) = SCALE_FACTOR.lock() {
*sf = scale_factor;
}
}
/// Current input state for tracking drags and modifiers
static INPUT_STATE: Mutex<InputState> = Mutex::new(InputState {
left_pressed: false,
right_pressed: false,
middle_pressed: false,
last_position: Vec2::ZERO,
modifiers: Modifiers {
shift: false,
ctrl: false,
alt: false,
meta: false,
},
});
#[derive(Clone, Copy, Debug)]
struct InputState {
left_pressed: bool,
right_pressed: bool,
middle_pressed: bool,
last_position: Vec2,
modifiers: Modifiers,
}
/// Push a winit window event to the buffer
///
/// Call this from the winit event loop. Handles ALL WindowEvent variants
/// for complete feature parity with bevy_winit.
pub fn push_window_event(event: &WindowEvent) {
let (sender, _) = get_event_channel();
match event {
// === MOUSE EVENTS ===
WindowEvent::MouseInput { state, button, .. } => {
let mouse_button = match button {
WinitMouseButton::Left => MouseButton::Left,
WinitMouseButton::Right => MouseButton::Right,
WinitMouseButton::Middle => MouseButton::Middle,
_ => return, // Ignore other buttons
};
if let Ok(mut input_state) = INPUT_STATE.lock() {
let position = input_state.last_position;
// Update button state
match mouse_button {
MouseButton::Left => input_state.left_pressed = *state == ElementState::Pressed,
MouseButton::Right => input_state.right_pressed = *state == ElementState::Pressed,
MouseButton::Middle => input_state.middle_pressed = *state == ElementState::Pressed,
}
let _ = sender.send(RawWinitEvent::MouseButton {
button: mouse_button,
state: *state,
position,
});
}
}
WindowEvent::CursorMoved { position, .. } => {
// Convert from physical pixels to logical pixels
let scale_factor = SCALE_FACTOR.lock().map(|sf| *sf).unwrap_or(1.0);
let pos = Vec2::new(
(position.x / scale_factor) as f32,
(position.y / scale_factor) as f32,
);
if let Ok(mut input_state) = INPUT_STATE.lock() {
input_state.last_position = pos;
let _ = sender.send(RawWinitEvent::CursorMoved { position: pos });
}
}
WindowEvent::CursorEntered { .. } => {
let _ = sender.send(RawWinitEvent::CursorEntered);
}
WindowEvent::CursorLeft { .. } => {
let _ = sender.send(RawWinitEvent::CursorLeft);
}
WindowEvent::MouseWheel { delta, .. } => {
let scroll_delta = match delta {
MouseScrollDelta::LineDelta(x, y) => Vec2::new(*x, *y) * 20.0,
MouseScrollDelta::PixelDelta(pos) => Vec2::new(pos.x as f32, pos.y as f32),
};
if let Ok(input_state) = INPUT_STATE.lock() {
let _ = sender.send(RawWinitEvent::MouseWheel {
delta: scroll_delta,
position: input_state.last_position,
});
}
}
// === KEYBOARD EVENTS ===
WindowEvent::KeyboardInput { event: key_event, is_synthetic: false, .. } => {
// Skip synthetic key events (we handle focus ourselves)
if let PhysicalKey::Code(key_code) = key_event.physical_key {
if let Ok(input_state) = INPUT_STATE.lock() {
// Convert logical key to string for display
let logical_key_str = match &key_event.logical_key {
LogicalKey::Character(s) => s.to_string(),
LogicalKey::Named(named) => format!("{:?}", named),
_ => String::new(),
};
let _ = sender.send(RawWinitEvent::Keyboard {
key: key_code,
state: key_event.state,
modifiers: input_state.modifiers,
logical_key: logical_key_str,
text: key_event.text.as_ref().map(|s| s.to_string()),
repeat: key_event.repeat,
});
}
}
}
WindowEvent::ModifiersChanged(new_modifiers) => {
if let Ok(mut input_state) = INPUT_STATE.lock() {
input_state.modifiers = Modifiers {
shift: new_modifiers.state().shift_key(),
ctrl: new_modifiers.state().control_key(),
alt: new_modifiers.state().alt_key(),
meta: new_modifiers.state().super_key(),
};
}
}
// === TOUCH EVENTS (APPLE PENCIL!) ===
WindowEvent::Touch(touch) => {
let scale_factor = SCALE_FACTOR.lock().map(|sf| *sf).unwrap_or(1.0);
let position = Vec2::new(
(touch.location.x / scale_factor) as f32,
(touch.location.y / scale_factor) as f32,
);
let _ = sender.send(RawWinitEvent::Touch {
phase: touch.phase,
position,
force: touch.force,
id: touch.id,
});
}
// === GESTURE EVENTS ===
WindowEvent::PinchGesture { delta, .. } => {
let _ = sender.send(RawWinitEvent::PinchGesture {
delta: *delta as f32,
});
}
WindowEvent::RotationGesture { delta, .. } => {
let _ = sender.send(RawWinitEvent::RotationGesture {
delta: *delta,
});
}
WindowEvent::DoubleTapGesture { .. } => {
let _ = sender.send(RawWinitEvent::DoubleTapGesture);
}
WindowEvent::PanGesture { delta, .. } => {
let _ = sender.send(RawWinitEvent::PanGesture {
delta: Vec2::new(delta.x, delta.y),
});
}
// === FILE DRAG & DROP ===
WindowEvent::DroppedFile(path) => {
let _ = sender.send(RawWinitEvent::DroppedFile {
path: path.clone(),
});
}
WindowEvent::HoveredFile(path) => {
let _ = sender.send(RawWinitEvent::HoveredFile {
path: path.clone(),
});
}
WindowEvent::HoveredFileCancelled => {
let _ = sender.send(RawWinitEvent::HoveredFileCancelled);
}
// === IME (INTERNATIONAL TEXT INPUT) ===
WindowEvent::Ime(ime_event) => {
match ime_event {
WinitIme::Enabled => {
let _ = sender.send(RawWinitEvent::ImeEnabled);
}
WinitIme::Preedit(value, cursor) => {
let _ = sender.send(RawWinitEvent::ImePreedit {
value: value.clone(),
cursor: *cursor,
});
}
WinitIme::Commit(value) => {
let _ = sender.send(RawWinitEvent::ImeCommit {
value: value.clone(),
});
}
WinitIme::Disabled => {
let _ = sender.send(RawWinitEvent::ImeDisabled);
}
}
}
// === WINDOW STATE EVENTS ===
WindowEvent::Focused(focused) => {
let _ = sender.send(RawWinitEvent::Focused {
focused: *focused,
});
}
WindowEvent::Occluded(occluded) => {
let _ = sender.send(RawWinitEvent::Occluded {
occluded: *occluded,
});
}
WindowEvent::ThemeChanged(theme) => {
let _ = sender.send(RawWinitEvent::ThemeChanged {
theme: *theme,
});
}
WindowEvent::Moved(position) => {
let _ = sender.send(RawWinitEvent::Moved {
x: position.x,
y: position.y,
});
}
// === EVENTS WE DON'T PROPAGATE (handled at executor level) ===
WindowEvent::Resized(_) |
WindowEvent::ScaleFactorChanged { .. } |
WindowEvent::CloseRequested |
WindowEvent::Destroyed |
WindowEvent::RedrawRequested => {
// These are handled directly by the executor, not converted to InputEvents
}
// Catch-all for any future events
_ => {}
}
}
/// Push a device event into the event buffer
///
/// Device events are low-level input events that don't correspond to a specific window.
/// The most important one is MouseMotion, which gives raw mouse delta for FPS cameras.
pub fn push_device_event(event: &winit::event::DeviceEvent) {
let (sender, _) = get_event_channel();
match event {
winit::event::DeviceEvent::MouseMotion { delta: (x, y) } => {
let delta = Vec2::new(*x as f32, *y as f32);
let _ = sender.send(RawWinitEvent::MouseMotion { delta });
}
// Other device events (Added/Removed, Button, Key, etc.) are not needed
// for our use case. Mouse delta is the main one.
_ => {}
}
}
/// Drain all buffered winit events and convert to InputEvents
///
/// Call this from your engine's input processing to consume events.
/// This uses a lock-free channel so it never blocks and can't silently drop events.
pub fn drain_as_input_events() -> Vec<InputEvent> {
let (_, receiver) = get_event_channel();
// Drain all events from the channel
receiver
.try_iter()
.filter_map(raw_to_input_event)
.collect()
}
/// Convert a raw winit event to an engine InputEvent
///
/// Only input-related events are converted. Other events (gestures, file drop, IME, etc.)
/// return None and should be handled by the Bevy event system directly.
fn raw_to_input_event(event: RawWinitEvent) -> Option<InputEvent> {
match event {
// === MOUSE INPUT ===
RawWinitEvent::MouseButton { button, state, position } => {
let phase = match state {
ElementState::Pressed => TouchPhase::Started,
ElementState::Released => TouchPhase::Ended,
};
Some(InputEvent::Mouse {
pos: position,
button,
phase,
})
}
RawWinitEvent::CursorMoved { position } => {
// Check if any button is pressed
let input_state = INPUT_STATE.lock().ok()?;
if input_state.left_pressed {
Some(InputEvent::Mouse {
pos: position,
button: MouseButton::Left,
phase: TouchPhase::Moved,
})
} else if input_state.right_pressed {
Some(InputEvent::Mouse {
pos: position,
button: MouseButton::Right,
phase: TouchPhase::Moved,
})
} else if input_state.middle_pressed {
Some(InputEvent::Mouse {
pos: position,
button: MouseButton::Middle,
phase: TouchPhase::Moved,
})
} else {
// No button pressed - hover tracking
Some(InputEvent::MouseMove { pos: position })
}
}
RawWinitEvent::MouseWheel { delta, position } => {
Some(InputEvent::MouseWheel {
delta,
pos: position,
})
}
// === KEYBOARD INPUT ===
RawWinitEvent::Keyboard { key, state, modifiers, .. } => {
Some(InputEvent::Keyboard {
key,
pressed: state == ElementState::Pressed,
modifiers,
})
}
// === TOUCH INPUT (APPLE PENCIL!) ===
RawWinitEvent::Touch { phase, position, force, id } => {
// Convert winit TouchPhase to engine TouchPhase
let touch_phase = match phase {
WinitTouchPhase::Started => TouchPhase::Started,
WinitTouchPhase::Moved => TouchPhase::Moved,
WinitTouchPhase::Ended => TouchPhase::Ended,
WinitTouchPhase::Cancelled => TouchPhase::Cancelled,
};
// Check if this is a stylus (has force/pressure data)
match force {
Some(WinitForce::Calibrated { force, max_possible_force, altitude_angle }) => {
// This is Apple Pencil or similar stylus!
// Normalize pressure to 0-1 range (though Apple Pencil can exceed 1.0)
let pressure = if max_possible_force > 0.0 {
(force / max_possible_force) as f32
} else {
force as f32 // Fallback if max is not provided
};
// Tilt: altitude_angle is provided, azimuth would need device_id tracking
// For now, use altitude and assume azimuth=0
let tilt = Vec2::new(
altitude_angle.unwrap_or(0.0) as f32,
0.0, // Azimuth not provided by winit Force::Calibrated
);
Some(InputEvent::Stylus {
pos: position,
pressure,
tilt,
phase: touch_phase,
timestamp: 0.0, // TODO: Get actual timestamp from winit when available
})
}
Some(WinitForce::Normalized(pressure)) => {
// Normalized pressure (0.0-1.0), likely a stylus
Some(InputEvent::Stylus {
pos: position,
pressure: pressure as f32,
tilt: Vec2::ZERO, // No tilt data in normalized mode
phase: touch_phase,
timestamp: 0.0,
})
}
None => {
// No force data - regular touch (finger)
Some(InputEvent::Touch {
pos: position,
phase: touch_phase,
id,
})
}
}
}
// === GESTURE INPUT ===
RawWinitEvent::PinchGesture { delta } => {
Some(InputEvent::PinchGesture { delta })
}
RawWinitEvent::RotationGesture { delta } => {
Some(InputEvent::RotationGesture { delta })
}
RawWinitEvent::PanGesture { delta } => {
Some(InputEvent::PanGesture { delta })
}
RawWinitEvent::DoubleTapGesture => {
Some(InputEvent::DoubleTapGesture)
}
// === MOUSE MOTION (RAW DELTA) ===
RawWinitEvent::MouseMotion { delta } => {
Some(InputEvent::MouseMotion { delta })
}
// === NON-INPUT EVENTS ===
// These are window/system events, not user input
RawWinitEvent::CursorEntered |
RawWinitEvent::CursorLeft |
RawWinitEvent::DroppedFile { .. } |
RawWinitEvent::HoveredFile { .. } |
RawWinitEvent::HoveredFileCancelled |
RawWinitEvent::ImePreedit { .. } |
RawWinitEvent::ImeCommit { .. } |
RawWinitEvent::ImeEnabled |
RawWinitEvent::ImeDisabled |
RawWinitEvent::Focused { .. } |
RawWinitEvent::Occluded { .. } |
RawWinitEvent::ThemeChanged { .. } |
RawWinitEvent::Moved { .. } => {
// These are window/UI events, should be sent to Bevy messages
// (to be implemented when we add Bevy window event forwarding)
None
}
}
}