fix keyboard input and app shutdown freeze

Signed-off-by: Sienna Meridian Satterwhite <sienna@r3t.io>
This commit is contained in:
2025-12-24 18:18:27 +00:00
parent 3e840908f6
commit 28909e8b76
7 changed files with 296 additions and 72 deletions

View File

@@ -18,6 +18,8 @@ impl Plugin for EngineBridgePlugin {
app.add_systems(Update, poll_engine_events); app.add_systems(Update, poll_engine_events);
// Detect changes and send clock tick commands to engine // Detect changes and send clock tick commands to engine
app.add_systems(PostUpdate, detect_changes_and_tick); app.add_systems(PostUpdate, detect_changes_and_tick);
// Handle app exit to stop networking gracefully
app.add_systems(Update, handle_app_exit);
} }
} }
@@ -46,16 +48,35 @@ fn poll_engine_events(
bridge: Res<EngineBridge>, bridge: Res<EngineBridge>,
mut current_session: ResMut<CurrentSession>, mut current_session: ResMut<CurrentSession>,
mut node_clock: ResMut<NodeVectorClock>, mut node_clock: ResMut<NodeVectorClock>,
mut networking_status: Option<ResMut<crate::session_ui::NetworkingStatus>>,
) { ) {
let events = (*bridge).poll_events(); let events = (*bridge).poll_events();
if !events.is_empty() { if !events.is_empty() {
for event in events { for event in events {
match event { match event {
EngineEvent::NetworkingInitializing { session_id, status } => {
info!("Networking initializing for session {}: {:?}", session_id.to_code(), status);
// Update NetworkingStatus resource
if let Some(ref mut net_status) = networking_status {
net_status.latest_status = Some(status);
}
// Update session state to Joining if not already
if matches!(current_session.session.state, SessionState::Created) {
current_session.session.state = SessionState::Joining;
}
}
EngineEvent::NetworkingStarted { session_id, node_id, bridge: gossip_bridge } => { EngineEvent::NetworkingStarted { session_id, node_id, bridge: gossip_bridge } => {
info!("Networking started: session={}, node={}", info!("Networking started: session={}, node={}",
session_id.to_code(), node_id); session_id.to_code(), node_id);
// Clear networking status
if let Some(ref mut net_status) = networking_status {
net_status.latest_status = None;
}
// Insert GossipBridge for Bevy systems to use // Insert GossipBridge for Bevy systems to use
commands.insert_resource(gossip_bridge); commands.insert_resource(gossip_bridge);
info!("Inserted GossipBridge resource"); info!("Inserted GossipBridge resource");
@@ -71,12 +92,22 @@ fn poll_engine_events(
EngineEvent::NetworkingFailed { error } => { EngineEvent::NetworkingFailed { error } => {
error!("Networking failed: {}", error); error!("Networking failed: {}", error);
// Clear networking status
if let Some(ref mut net_status) = networking_status {
net_status.latest_status = None;
}
// Keep session state as Created // Keep session state as Created
current_session.session.state = SessionState::Created; current_session.session.state = SessionState::Created;
} }
EngineEvent::NetworkingStopped => { EngineEvent::NetworkingStopped => {
info!("Networking stopped"); info!("Networking stopped");
// Clear networking status
if let Some(ref mut net_status) = networking_status {
net_status.latest_status = None;
}
// Update session state to Disconnected // Update session state to Disconnected
current_session.session.state = SessionState::Disconnected; current_session.session.state = SessionState::Disconnected;
} }
@@ -133,3 +164,21 @@ fn poll_engine_events(
} }
} }
} }
/// Handle app exit to stop networking immediately
fn handle_app_exit(
mut exit_events: MessageReader<bevy::app::AppExit>,
bridge: Res<EngineBridge>,
current_session: Res<CurrentSession>,
) {
for _ in exit_events.read() {
// If networking is active, send stop command
// Don't wait - the task will be aborted when the runtime shuts down
if current_session.session.state == SessionState::Active
|| current_session.session.state == SessionState::Joining {
info!("App exiting, aborting networking immediately");
bridge.send_command(EngineCommand::StopNetworking);
// Don't sleep - just let the app exit. The tokio runtime will clean up.
}
}
}

View File

@@ -1509,6 +1509,17 @@ pub fn custom_input_system(
} }
} }
InputEvent::Text { text } => {
// Send text input to egui
for (entity, _settings, _pointer_pos) in egui_contexts.iter() {
egui_input_message_writer.write(EguiInputEvent {
context: entity,
event: egui::Event::Text(text.clone()),
});
messages_written += 1;
}
}
_ => { _ => {
// Ignore stylus and touch events for now // Ignore stylus and touch events for now
} }

View File

@@ -1,6 +1,7 @@
//! Core Engine event loop - runs on tokio outside Bevy //! Core Engine event loop - runs on tokio outside Bevy
use tokio::task::JoinHandle; use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
use uuid::Uuid; use uuid::Uuid;
use super::{EngineCommand, EngineEvent, EngineHandle, NetworkingManager, PersistenceManager}; use super::{EngineCommand, EngineEvent, EngineHandle, NetworkingManager, PersistenceManager};
@@ -9,6 +10,7 @@ use crate::networking::{SessionId, VectorClock};
pub struct EngineCore { pub struct EngineCore {
handle: EngineHandle, handle: EngineHandle,
networking_task: Option<JoinHandle<()>>, networking_task: Option<JoinHandle<()>>,
networking_cancel_token: Option<CancellationToken>,
#[allow(dead_code)] #[allow(dead_code)]
persistence: PersistenceManager, persistence: PersistenceManager,
@@ -28,6 +30,7 @@ impl EngineCore {
Self { Self {
handle, handle,
networking_task: None, // Start offline networking_task: None, // Start offline
networking_cancel_token: None,
persistence, persistence,
node_id, node_id,
clock, clock,
@@ -93,39 +96,73 @@ impl EngineCore {
return; return;
} }
match NetworkingManager::new(session_id.clone()).await { tracing::info!("Starting networking initialization for session {}", session_id.to_code());
Ok((net_manager, bridge)) => {
let node_id = net_manager.node_id();
// Spawn NetworkingManager in background task // Create cancellation token for graceful shutdown
let event_tx = self.handle.event_tx.clone(); let cancel_token = CancellationToken::new();
let task = tokio::spawn(async move { let cancel_token_clone = cancel_token.clone();
net_manager.run(event_tx).await;
// Spawn NetworkingManager initialization in background to avoid blocking
// DHT peer discovery can take 15+ seconds with retries
let event_tx = self.handle.event_tx.clone();
// Create channel for progress updates
let (progress_tx, mut progress_rx) = tokio::sync::mpsc::unbounded_channel();
// Spawn task to forward progress updates to Bevy
let event_tx_clone = event_tx.clone();
let session_id_clone = session_id.clone();
tokio::spawn(async move {
while let Some(status) = progress_rx.recv().await {
let _ = event_tx_clone.send(EngineEvent::NetworkingInitializing {
session_id: session_id_clone.clone(),
status,
}); });
self.networking_task = Some(task);
let _ = self.handle.event_tx.send(EngineEvent::NetworkingStarted {
session_id: session_id.clone(),
node_id,
bridge,
});
tracing::info!("Networking started for session {}", session_id.to_code());
} }
Err(e) => { });
let _ = self.handle.event_tx.send(EngineEvent::NetworkingFailed {
error: e.to_string(), let task = tokio::spawn(async move {
}); match NetworkingManager::new(session_id.clone(), Some(progress_tx), cancel_token_clone.clone()).await {
tracing::error!("Failed to start networking: {}", e); Ok((net_manager, bridge)) => {
let node_id = net_manager.node_id();
// Notify Bevy that networking started
let _ = event_tx.send(EngineEvent::NetworkingStarted {
session_id: session_id.clone(),
node_id,
bridge,
});
tracing::info!("Networking started for session {}", session_id.to_code());
// Run the networking manager loop with cancellation support
net_manager.run(event_tx.clone(), cancel_token_clone).await;
}
Err(e) => {
let _ = event_tx.send(EngineEvent::NetworkingFailed {
error: e.to_string(),
});
tracing::error!("Failed to start networking: {}", e);
}
} }
} });
self.networking_task = Some(task);
self.networking_cancel_token = Some(cancel_token);
} }
async fn stop_networking(&mut self) { async fn stop_networking(&mut self) {
// Cancel the task gracefully
if let Some(cancel_token) = self.networking_cancel_token.take() {
cancel_token.cancel();
tracing::info!("Networking cancellation requested");
}
// Abort the task immediately - don't wait for graceful shutdown
// This is fine because NetworkingManager doesn't hold critical resources
if let Some(task) = self.networking_task.take() { if let Some(task) = self.networking_task.take() {
task.abort(); // Cancel the networking task task.abort();
tracing::info!("Networking task aborted");
let _ = self.handle.event_tx.send(EngineEvent::NetworkingStopped); let _ = self.handle.event_tx.send(EngineEvent::NetworkingStopped);
tracing::info!("Networking stopped");
} }
} }

View File

@@ -12,6 +12,7 @@ use crate::networking::{
}; };
use super::EngineEvent; use super::EngineEvent;
use super::events::NetworkingInitStatus;
pub struct NetworkingManager { pub struct NetworkingManager {
session_id: SessionId, session_id: SessionId,
@@ -40,9 +41,19 @@ pub struct NetworkingManager {
} }
impl NetworkingManager { impl NetworkingManager {
pub async fn new(session_id: SessionId) -> anyhow::Result<(Self, crate::networking::GossipBridge)> { pub async fn new(
session_id: SessionId,
progress_tx: Option<tokio::sync::mpsc::UnboundedSender<NetworkingInitStatus>>,
cancel_token: tokio_util::sync::CancellationToken,
) -> anyhow::Result<(Self, crate::networking::GossipBridge)> {
let send_progress = |status: NetworkingInitStatus| {
if let Some(ref tx) = progress_tx {
let _ = tx.send(status.clone());
}
tracing::info!("Networking init: {:?}", status);
};
use iroh::{ use iroh::{
discovery::mdns::MdnsDiscovery, discovery::pkarr::dht::DhtDiscovery,
protocol::Router, protocol::Router,
Endpoint, Endpoint,
}; };
@@ -51,12 +62,24 @@ impl NetworkingManager {
proto::TopicId, proto::TopicId,
}; };
// Create iroh endpoint with mDNS discovery // Check for cancellation at start
if cancel_token.is_cancelled() {
return Err(anyhow::anyhow!("Initialization cancelled before start"));
}
send_progress(NetworkingInitStatus::CreatingEndpoint);
// Create iroh endpoint with DHT discovery
// This allows peers to discover each other over the internet via Mainline DHT
// Security comes from the secret session-derived ALPN, not network isolation
let dht_discovery = DhtDiscovery::builder().build()?;
let endpoint = Endpoint::builder() let endpoint = Endpoint::builder()
.discovery(MdnsDiscovery::builder()) .discovery(dht_discovery)
.bind() .bind()
.await?; .await?;
send_progress(NetworkingInitStatus::EndpointReady);
let endpoint_id = endpoint.addr().id; let endpoint_id = endpoint.addr().id;
// Convert endpoint ID to NodeId (using first 16 bytes) // Convert endpoint ID to NodeId (using first 16 bytes)
@@ -65,20 +88,89 @@ impl NetworkingManager {
node_id_bytes.copy_from_slice(&id_bytes[..16]); node_id_bytes.copy_from_slice(&id_bytes[..16]);
let node_id = NodeId::from_bytes(node_id_bytes); let node_id = NodeId::from_bytes(node_id_bytes);
// Create gossip protocol // Create pkarr client for DHT peer discovery
let gossip = Gossip::builder().spawn(endpoint.clone()); let pkarr_client = pkarr::Client::builder()
.no_default_network()
.dht(|x| x)
.build()?;
// Discover existing peers from DHT with retries
// Retry immediately without delays - if peers aren't in DHT yet, they'll appear soon
let mut peer_endpoint_ids = vec![];
for attempt in 1..=3 {
// Check for cancellation before each attempt
if cancel_token.is_cancelled() {
tracing::info!("Networking initialization cancelled during DHT discovery");
return Err(anyhow::anyhow!("Initialization cancelled"));
}
send_progress(NetworkingInitStatus::DiscoveringPeers {
session_code: session_id.to_code().to_string(),
attempt,
});
match crate::engine::peer_discovery::discover_peers_from_dht(&session_id, &pkarr_client).await {
Ok(peers) if !peers.is_empty() => {
let count = peers.len();
peer_endpoint_ids = peers;
send_progress(NetworkingInitStatus::PeersFound {
count,
});
break;
}
Ok(_) if attempt == 3 => {
// Last attempt and no peers found
send_progress(NetworkingInitStatus::NoPeersFound);
}
Ok(_) => {
// No peers found, but will retry immediately
}
Err(e) => {
tracing::warn!("DHT query attempt {} failed: {}", attempt, e);
}
}
}
// Check for cancellation before publishing
if cancel_token.is_cancelled() {
tracing::info!("Networking initialization cancelled before DHT publish");
return Err(anyhow::anyhow!("Initialization cancelled"));
}
// Publish our presence to DHT
send_progress(NetworkingInitStatus::PublishingToDHT);
if let Err(e) = crate::engine::peer_discovery::publish_peer_to_dht(
&session_id,
endpoint_id,
&pkarr_client,
)
.await
{
tracing::warn!("Failed to publish to DHT: {}", e);
}
// Check for cancellation before gossip initialization
if cancel_token.is_cancelled() {
tracing::info!("Networking initialization cancelled before gossip init");
return Err(anyhow::anyhow!("Initialization cancelled"));
}
// Derive session-specific ALPN for network isolation // Derive session-specific ALPN for network isolation
let session_alpn = session_id.to_alpn(); let session_alpn = session_id.to_alpn();
// Create gossip protocol with custom session ALPN
send_progress(NetworkingInitStatus::InitializingGossip);
let gossip = Gossip::builder()
.alpn(&session_alpn)
.spawn(endpoint.clone());
// Set up router to accept session ALPN // Set up router to accept session ALPN
let router = Router::builder(endpoint.clone()) let router = Router::builder(endpoint.clone())
.accept(session_alpn.as_slice(), gossip.clone()) .accept(session_alpn.as_slice(), gossip.clone())
.spawn(); .spawn();
// Subscribe to topic derived from session ALPN // Subscribe to topic with discovered peers as bootstrap
let topic_id = TopicId::from_bytes(session_alpn); let topic_id = TopicId::from_bytes(session_alpn);
let subscribe_handle = gossip.subscribe(topic_id, vec![]).await?; let subscribe_handle = gossip.subscribe(topic_id, peer_endpoint_ids).await?;
let (sender, receiver) = subscribe_handle.split(); let (sender, receiver) = subscribe_handle.split();
@@ -91,6 +183,14 @@ impl NetworkingManager {
// Create GossipBridge for Bevy integration // Create GossipBridge for Bevy integration
let bridge = crate::networking::GossipBridge::new(node_id); let bridge = crate::networking::GossipBridge::new(node_id);
// Spawn background task to maintain DHT presence
let session_id_clone = session_id.clone();
tokio::spawn(crate::engine::peer_discovery::maintain_dht_presence(
session_id_clone,
endpoint_id,
pkarr_client,
));
let manager = Self { let manager = Self {
session_id, session_id,
node_id, node_id,
@@ -120,12 +220,17 @@ impl NetworkingManager {
/// Process gossip events (unbounded) and periodic tasks (heartbeats, lock cleanup) /// Process gossip events (unbounded) and periodic tasks (heartbeats, lock cleanup)
/// Also bridges messages between iroh-gossip and Bevy's GossipBridge /// Also bridges messages between iroh-gossip and Bevy's GossipBridge
pub async fn run(mut self, event_tx: mpsc::UnboundedSender<EngineEvent>) { pub async fn run(mut self, event_tx: mpsc::UnboundedSender<EngineEvent>, cancel_token: tokio_util::sync::CancellationToken) {
let mut heartbeat_interval = time::interval(Duration::from_secs(1)); let mut heartbeat_interval = time::interval(Duration::from_secs(1));
let mut bridge_poll_interval = time::interval(Duration::from_millis(10)); let mut bridge_poll_interval = time::interval(Duration::from_millis(10));
loop { loop {
tokio::select! { tokio::select! {
// Listen for shutdown signal
_ = cancel_token.cancelled() => {
tracing::info!("NetworkingManager received shutdown signal");
break;
}
// Process incoming gossip messages and forward to GossipBridge // Process incoming gossip messages and forward to GossipBridge
Some(result) = self.receiver.next() => { Some(result) = self.receiver.next() => {
match result { match result {

View File

@@ -437,25 +437,18 @@ pub fn push_device_event(event: &winit::event::DeviceEvent) {
} }
} }
/// 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> { pub fn drain_as_input_events() -> Vec<InputEvent> {
let (_, receiver) = get_event_channel(); let (_, receiver) = get_event_channel();
// Drain all events from the channel // Drain all events from the channel and convert to InputEvents
// Each raw event may generate multiple InputEvents (e.g., Keyboard + Text)
receiver receiver
.try_iter() .try_iter()
.filter_map(raw_to_input_event) .flat_map(raw_to_input_event)
.collect() .collect()
} }
/// Convert a raw winit event to an engine InputEvent fn raw_to_input_event(event: RawWinitEvent) -> Vec<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 { match event {
// === MOUSE INPUT === // === MOUSE INPUT ===
RawWinitEvent::MouseButton { button, state, position } => { RawWinitEvent::MouseButton { button, state, position } => {
@@ -464,55 +457,70 @@ fn raw_to_input_event(event: RawWinitEvent) -> Option<InputEvent> {
ElementState::Released => TouchPhase::Ended, ElementState::Released => TouchPhase::Ended,
}; };
Some(InputEvent::Mouse { vec![InputEvent::Mouse {
pos: position, pos: position,
button, button,
phase, phase,
}) }]
} }
RawWinitEvent::CursorMoved { position } => { RawWinitEvent::CursorMoved { position } => {
// Check if any button is pressed // Check if any button is pressed
let input_state = INPUT_STATE.lock().ok()?; let Some(input_state) = INPUT_STATE.lock().ok() else {
return vec![];
};
if input_state.left_pressed { if input_state.left_pressed {
Some(InputEvent::Mouse { vec![InputEvent::Mouse {
pos: position, pos: position,
button: MouseButton::Left, button: MouseButton::Left,
phase: TouchPhase::Moved, phase: TouchPhase::Moved,
}) }]
} else if input_state.right_pressed { } else if input_state.right_pressed {
Some(InputEvent::Mouse { vec![InputEvent::Mouse {
pos: position, pos: position,
button: MouseButton::Right, button: MouseButton::Right,
phase: TouchPhase::Moved, phase: TouchPhase::Moved,
}) }]
} else if input_state.middle_pressed { } else if input_state.middle_pressed {
Some(InputEvent::Mouse { vec![InputEvent::Mouse {
pos: position, pos: position,
button: MouseButton::Middle, button: MouseButton::Middle,
phase: TouchPhase::Moved, phase: TouchPhase::Moved,
}) }]
} else { } else {
// No button pressed - hover tracking // No button pressed - hover tracking
Some(InputEvent::MouseMove { pos: position }) vec![InputEvent::MouseMove { pos: position }]
} }
} }
RawWinitEvent::MouseWheel { delta, position } => { RawWinitEvent::MouseWheel { delta, position } => {
Some(InputEvent::MouseWheel { vec![InputEvent::MouseWheel {
delta, delta,
pos: position, pos: position,
}) }]
} }
// === KEYBOARD INPUT === // === KEYBOARD INPUT ===
RawWinitEvent::Keyboard { key, state, modifiers, .. } => { RawWinitEvent::Keyboard { key, state, modifiers, text, .. } => {
Some(InputEvent::Keyboard { let mut events = vec![InputEvent::Keyboard {
key, key,
pressed: state == ElementState::Pressed, pressed: state == ElementState::Pressed,
modifiers, modifiers,
}) }];
// If there's text input and the key was pressed, send a Text event too
// But only for printable characters, not control characters (backspace, etc.)
if state == ElementState::Pressed {
if let Some(text) = text {
// Filter out control characters - only send printable text
if !text.is_empty() && text.chars().all(|c| !c.is_control()) {
events.push(InputEvent::Text { text });
}
}
}
events
} }
// === TOUCH INPUT (APPLE PENCIL!) === // === TOUCH INPUT (APPLE PENCIL!) ===
@@ -543,55 +551,55 @@ fn raw_to_input_event(event: RawWinitEvent) -> Option<InputEvent> {
0.0, // Azimuth not provided by winit Force::Calibrated 0.0, // Azimuth not provided by winit Force::Calibrated
); );
Some(InputEvent::Stylus { vec![InputEvent::Stylus {
pos: position, pos: position,
pressure, pressure,
tilt, tilt,
phase: touch_phase, phase: touch_phase,
timestamp: 0.0, // TODO: Get actual timestamp from winit when available timestamp: 0.0, // TODO: Get actual timestamp from winit when available
}) }]
} }
Some(WinitForce::Normalized(pressure)) => { Some(WinitForce::Normalized(pressure)) => {
// Normalized pressure (0.0-1.0), likely a stylus // Normalized pressure (0.0-1.0), likely a stylus
Some(InputEvent::Stylus { vec![InputEvent::Stylus {
pos: position, pos: position,
pressure: pressure as f32, pressure: pressure as f32,
tilt: Vec2::ZERO, // No tilt data in normalized mode tilt: Vec2::ZERO, // No tilt data in normalized mode
phase: touch_phase, phase: touch_phase,
timestamp: 0.0, timestamp: 0.0,
}) }]
} }
None => { None => {
// No force data - regular touch (finger) // No force data - regular touch (finger)
Some(InputEvent::Touch { vec![InputEvent::Touch {
pos: position, pos: position,
phase: touch_phase, phase: touch_phase,
id, id,
}) }]
} }
} }
} }
// === GESTURE INPUT === // === GESTURE INPUT ===
RawWinitEvent::PinchGesture { delta } => { RawWinitEvent::PinchGesture { delta } => {
Some(InputEvent::PinchGesture { delta }) vec![InputEvent::PinchGesture { delta }]
} }
RawWinitEvent::RotationGesture { delta } => { RawWinitEvent::RotationGesture { delta } => {
Some(InputEvent::RotationGesture { delta }) vec![InputEvent::RotationGesture { delta }]
} }
RawWinitEvent::PanGesture { delta } => { RawWinitEvent::PanGesture { delta } => {
Some(InputEvent::PanGesture { delta }) vec![InputEvent::PanGesture { delta }]
} }
RawWinitEvent::DoubleTapGesture => { RawWinitEvent::DoubleTapGesture => {
Some(InputEvent::DoubleTapGesture) vec![InputEvent::DoubleTapGesture]
} }
// === MOUSE MOTION (RAW DELTA) === // === MOUSE MOTION (RAW DELTA) ===
RawWinitEvent::MouseMotion { delta } => { RawWinitEvent::MouseMotion { delta } => {
Some(InputEvent::MouseMotion { delta }) vec![InputEvent::MouseMotion { delta }]
} }
// === NON-INPUT EVENTS === // === NON-INPUT EVENTS ===
@@ -611,7 +619,7 @@ fn raw_to_input_event(event: RawWinitEvent) -> Option<InputEvent> {
RawWinitEvent::Moved { .. } => { RawWinitEvent::Moved { .. } => {
// These are window/UI events, should be sent to Bevy messages // These are window/UI events, should be sent to Bevy messages
// (to be implemented when we add Bevy window event forwarding) // (to be implemented when we add Bevy window event forwarding)
None vec![]
} }
} }
} }

View File

@@ -245,6 +245,11 @@ impl InputController {
} }
// In other contexts, ignore MouseMotion to avoid conflicts with cursor-based input // In other contexts, ignore MouseMotion to avoid conflicts with cursor-based input
} }
InputEvent::Text { text: _ } => {
// Text input is handled by egui, not by game actions
// This is for typing in text fields, not game controls
}
} }
actions actions

View File

@@ -52,7 +52,7 @@ pub struct InputEventBuffer {
/// ///
/// Platform-specific code converts native input (UITouch, winit events) /// Platform-specific code converts native input (UITouch, winit events)
/// into these engine-agnostic events. /// into these engine-agnostic events.
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone)]
pub enum InputEvent { pub enum InputEvent {
/// Stylus input (Apple Pencil, Surface Pen, etc.) /// Stylus input (Apple Pencil, Surface Pen, etc.)
Stylus { Stylus {
@@ -108,6 +108,13 @@ pub enum InputEvent {
modifiers: Modifiers, modifiers: Modifiers,
}, },
/// Text input from keyboard
/// This is the actual character that was typed, after applying keyboard layout
Text {
/// The text/character that was entered
text: String,
},
/// Mouse wheel scroll /// Mouse wheel scroll
MouseWheel { MouseWheel {
/// Scroll delta (pixels or lines depending on device) /// Scroll delta (pixels or lines depending on device)
@@ -155,6 +162,7 @@ impl InputEvent {
InputEvent::Touch { pos, .. } => Some(*pos), InputEvent::Touch { pos, .. } => Some(*pos),
InputEvent::MouseWheel { pos, .. } => Some(*pos), InputEvent::MouseWheel { pos, .. } => Some(*pos),
InputEvent::Keyboard { .. } | InputEvent::Keyboard { .. } |
InputEvent::Text { .. } |
InputEvent::MouseMotion { .. } | InputEvent::MouseMotion { .. } |
InputEvent::PinchGesture { .. } | InputEvent::PinchGesture { .. } |
InputEvent::RotationGesture { .. } | InputEvent::RotationGesture { .. } |
@@ -170,6 +178,7 @@ impl InputEvent {
InputEvent::Mouse { phase, .. } => Some(*phase), InputEvent::Mouse { phase, .. } => Some(*phase),
InputEvent::Touch { phase, .. } => Some(*phase), InputEvent::Touch { phase, .. } => Some(*phase),
InputEvent::Keyboard { .. } | InputEvent::Keyboard { .. } |
InputEvent::Text { .. } |
InputEvent::MouseWheel { .. } | InputEvent::MouseWheel { .. } |
InputEvent::MouseMove { .. } | InputEvent::MouseMove { .. } |
InputEvent::MouseMotion { .. } | InputEvent::MouseMotion { .. } |