diff --git a/desktop/src/app.rs b/desktop/src/app.rs index 96cfbb1b565..63f4f81b4ce 100644 --- a/desktop/src/app.rs +++ b/desktop/src/app.rs @@ -340,11 +340,14 @@ impl App { self.app_event_scheduler.schedule(AppEvent::ClipboardWrite { content }); } DesktopFrontendMessage::PointerLock => { - self.input_state.lock_pointer(); - if let Some(window) = &self.window { - window.start_pointer_lock(); + let locked = self.window.as_ref().is_some_and(|window| window.start_pointer_lock()); + if locked { + self.input_state.lock_pointer(); } } + DesktopFrontendMessage::PointerWrap { enabled } => { + self.input_state.set_pointer_wrap(enabled); + } DesktopFrontendMessage::WindowClose => { self.app_event_scheduler.schedule(AppEvent::Exit); } @@ -578,12 +581,24 @@ impl ApplicationHandler for App { )); } + if let WindowEvent::Focused(focused) = &event { + self.input_state.set_window_focused(*focused); + } + self.input_state.process( &event, |message| self.app_event_scheduler.schedule(AppEvent::DesktopWrapperMessage(DesktopWrapperMessage::Input(message))), |input| self.ui.send(UiCommand::Input(input)), ); + if let Some(position) = self.input_state.take_pending_warp() { + let moved = self.window.as_ref().is_some_and(|window| window.set_cursor_position(position)); + // Wayland refuses to move the pointer, so the wrap turns itself off + if !moved { + self.input_state.set_pointer_wrap(false); + } + } + match event { WindowEvent::CloseRequested => { self.app_event_scheduler.schedule(AppEvent::Exit); diff --git a/desktop/src/input.rs b/desktop/src/input.rs index dcc438f35b5..63b23a45f80 100644 --- a/desktop/src/input.rs +++ b/desktop/src/input.rs @@ -26,6 +26,9 @@ pub(crate) struct InputState { modifiers: ModifiersState, pointer_position: PhysicalPosition, pointer_state: PointerState, + pointer_wrap: Option, + window_focused: bool, + pending_warp: Option>, click_tracker: ClickTracker, shake_tracker: ShakeTracker, } @@ -39,6 +42,9 @@ impl InputState { modifiers: ModifiersState::default(), pointer_position: PhysicalPosition::default(), pointer_state: PointerState::Hover { route: Route::Ui }, + pointer_wrap: None, + window_focused: true, + pending_warp: None, click_tracker: ClickTracker::default(), shake_tracker: ShakeTracker::default(), } @@ -52,6 +58,29 @@ impl InputState { self.direct_input = enabled; } + pub(crate) fn set_pointer_wrap(&mut self, enabled: bool) { + self.pointer_wrap = enabled.then_some(PointerWrap { + position: self.pointer_position, + last_reported: Some(self.pointer_position), + }); + self.pending_warp = None; + } + + /// Pauses the wrap while the window is unfocused. + pub(crate) fn set_window_focused(&mut self, focused: bool) { + self.window_focused = focused; + // The cursor can be anywhere now, so the next report only sets a baseline + if let Some(wrap) = &mut self.pointer_wrap { + wrap.last_reported = None; + } + self.pending_warp = None; + } + + /// Where to move the OS cursor after the last event wrapped it. + pub(crate) fn take_pending_warp(&mut self) -> Option> { + self.pending_warp.take() + } + pub(crate) fn lock_pointer(&mut self) { self.pointer_state = match self.pointer_state { PointerState::Hover { route } => PointerState::Locked { @@ -88,6 +117,39 @@ impl InputState { matches!(self.pointer_state, PointerState::Locked { .. }) } + /// Accumulates the reported position into the continuous one the editor sees during a G/R/S wrap. + /// `reported` is the OS's physical window position, and the return value is in that same space. + fn wrapped_position(&mut self, reported: PhysicalPosition) -> PhysicalPosition { + let viewport = self.viewport_info; + let Some(wrap) = self.pointer_wrap.as_mut() else { return reported }; + + // Hold the drag still while the window is unfocused + if !self.window_focused { + return wrap.position; + } + + // A report can arrive from before the OS moved the cursor, so measure the short way around + let viewport_size = viewport.map_or(glam::DVec2::ZERO, |viewport| glam::DVec2::new(viewport.width, viewport.height)); + let delta = match wrap.last_reported { + Some(last) => wrap_delta(to_dvec(reported) - to_dvec(last), viewport_size), + None => glam::DVec2::ZERO, // Nothing to measure from, so this sets the baseline + }; + wrap.position.x += delta.x; + wrap.position.y += delta.y; + wrap.last_reported = Some(reported); + + let warp_to = viewport.and_then(|viewport| wrap_into_viewport(reported, viewport)); + + let position = wrap.position; + self.pending_warp = warp_to; + position + } + + /// The continuous position the editor sees while a wrap runs. + fn tracked_position(&self, reported: PhysicalPosition) -> PhysicalPosition { + self.pointer_wrap.as_ref().map_or(reported, |wrap| wrap.position) + } + pub(crate) fn modifiers(&self) -> ModifiersState { self.modifiers } @@ -95,11 +157,12 @@ impl InputState { pub(crate) fn process(&mut self, event: &WindowEvent, mut editor_callback: impl FnMut(InputMessage), mut ui_callback: impl FnMut(InputEvent)) { match event { WindowEvent::PointerMoved { position, source, .. } => { - self.pointer_position = *position; + let position = self.wrapped_position(*position); + self.pointer_position = position; let route = match self.pointer_state { PointerState::Hover { .. } => { - let next = self.route(*position); + let next = self.route(position); self.pointer_state = PointerState::Hover { route: next }; next } @@ -110,17 +173,17 @@ impl InputState { }, }; match route { - Route::Ui => ui_callback(InputEvent::pointer().position(*position).moved().modifiers(self.modifiers).build()), + Route::Ui => ui_callback(InputEvent::pointer().position(position).moved().modifiers(self.modifiers).build()), Route::Editor => { if !self.pointer_locked() { - ui_callback(InputEvent::pointer().position(*position).moved().modifiers(self.modifiers).observe_only().build()); + ui_callback(InputEvent::pointer().position(position).moved().modifiers(self.modifiers).observe_only().build()); } let editor_mouse_state = match source { PointerSource::TabletTool { kind, data } => self.tablet_pointer_state(kind, data), _ => self.pointer_state(), }; let modifier_keys = self.modifier_keys(); - if self.shake_tracker.detect(*position, self.start.elapsed()) { + if self.shake_tracker.detect(position, self.start.elapsed()) { editor_callback(InputMessage::PointerShake { editor_mouse_state, modifier_keys }); } editor_callback(InputMessage::PointerMove { editor_mouse_state, modifier_keys }); @@ -128,16 +191,20 @@ impl InputState { } } WindowEvent::PointerEntered { position, .. } => { - self.pointer_position = *position; - ui_callback(InputEvent::pointer().position(*position).entered().modifiers(self.modifiers).build()) + let position = self.tracked_position(*position); + self.pointer_position = position; + ui_callback(InputEvent::pointer().position(position).entered().modifiers(self.modifiers).build()) } WindowEvent::PointerLeft { position: Some(position), .. } => { - self.pointer_position = *position; - ui_callback(InputEvent::pointer().position(*position).exited().modifiers(self.modifiers).build()) + let position = self.tracked_position(*position); + self.pointer_position = position; + ui_callback(InputEvent::pointer().position(position).exited().modifiers(self.modifiers).build()) } WindowEvent::PointerLeft { position: None, .. } => ui_callback(InputEvent::pointer().exited().modifiers(self.modifiers).build()), WindowEvent::PointerButton { state, button, position, .. } => { - self.pointer_position = *position; + // The OS cursor has been warped away, so a click belongs at the tracked position + let position = self.tracked_position(*position); + self.pointer_position = position; let mouse_button = button.clone().mouse_button(); let keys = match mouse_button { @@ -152,10 +219,10 @@ impl InputState { let (pointer, route) = match self.pointer_state { PointerState::Hover { route } => match (state.is_pressed(), keys.is_empty()) { (true, false) => { - let route = self.route(*position); + let route = self.route(position); (PointerState::Stroke { route, keys }, route) } - (true, true) => (PointerState::Hover { route }, self.route(*position)), + (true, true) => (PointerState::Hover { route }, self.route(position)), (false, _) => (PointerState::Hover { route }, route), }, PointerState::Stroke { route, keys: mut held } => { @@ -179,10 +246,10 @@ impl InputState { }; self.pointer_state = pointer; - let count = mouse_button.map_or(1, |button| self.click_tracker.input(*position, button, *state)); + let count = mouse_button.map_or(1, |button| self.click_tracker.input(position, button, *state)); let back_or_forward = matches!(mouse_button, Some(MouseButton::Back | MouseButton::Forward)); - let pointer = InputEvent::pointer().position(*position); + let pointer = InputEvent::pointer().position(position); let input = match state { ElementState::Pressed => pointer.pressed(button.clone(), count), ElementState::Released => pointer.released(button.clone(), count), @@ -270,6 +337,11 @@ impl InputState { } fn route(&self, position: PhysicalPosition) -> Route { + // The tracked position can sit outside the viewport during a wrap, but routing still belongs to the editor + if self.direct_input && self.pointer_wrap.is_some() { + return Route::Editor; + } + if self.ui_captures(position) { Route::Ui } else { Route::Editor } } @@ -323,6 +395,15 @@ enum Route { Editor, } +/// Tracks the pointer in physical window coordinates so the editor sees no jump when the OS cursor wraps. +struct PointerWrap { + position: PhysicalPosition, + /// The last position the OS reported, or `None` after the wrap pauses, until a report rebases it + last_reported: Option>, +} + +/// Where the viewport sits in the window, in physical coordinates. +#[derive(Clone, Copy)] struct ViewportInfo { x: f64, y: f64, @@ -337,6 +418,36 @@ impl ViewportInfo { } } +/// Wraps a window position into the viewport, or `None` when it is already inside. +fn wrap_into_viewport(position: PhysicalPosition, viewport: ViewportInfo) -> Option> { + assert!(viewport.width >= 0. && viewport.height >= 0., "a viewport never has a negative size"); + // Nothing to wrap around until the viewport has an area + if viewport.width == 0. || viewport.height == 0. { + return None; + } + + let relative = glam::DVec2::new(position.x - viewport.x, position.y - viewport.y); + let wrapped = glam::DVec2::new(viewport.x, viewport.y) + relative.rem_euclid(glam::DVec2::new(viewport.width, viewport.height)); + let wrapped = PhysicalPosition::new(wrapped.x, wrapped.y); + (wrapped != position).then_some(wrapped) +} + +/// Takes a movement the short way around the viewport, so a late report adds the real movement instead of the wrap distance. +/// shortcut: a report moving more than half the viewport reads as backwards. Upgrade if reports ever get that far apart. +fn wrap_delta(delta: glam::DVec2, size: glam::DVec2) -> glam::DVec2 { + if size.min_element() <= 0. { + return delta; + } + + let half = size / 2.; + (delta + half).rem_euclid(size) - half +} + +/// The physical window position as a vector. +fn to_dvec(position: PhysicalPosition) -> glam::DVec2 { + glam::DVec2::new(position.x, position.y) +} + #[derive(Default)] struct ClickTracker { left: ClickChains, @@ -453,3 +564,100 @@ impl ShakeTracker { false } } + +#[cfg(test)] +mod test { + use super::*; + + fn viewport() -> ViewportInfo { + ViewportInfo { + x: 100., + y: 50., + width: 200., + height: 100., + scale: 1., + } + } + + fn wrapping_input(position: PhysicalPosition) -> InputState { + let mut input = InputState::new(); + let viewport = viewport(); + input.set_viewport_info(viewport.x, viewport.y, viewport.width, viewport.height, viewport.scale); + input.set_direct_input(true); + input.pointer_position = position; + input.set_pointer_wrap(true); + input + } + + #[test] + fn wrap_into_viewport_wraps_past_the_edges() { + assert_eq!(wrap_into_viewport(PhysicalPosition::new(150., 80.), viewport()), None, "a position inside the viewport stays put"); + assert_eq!( + wrap_into_viewport(PhysicalPosition::new(310., 80.), viewport()), + Some(PhysicalPosition::new(110., 80.)), + "past the right edge wraps to the left" + ); + assert_eq!( + wrap_into_viewport(PhysicalPosition::new(90., 80.), viewport()), + Some(PhysicalPosition::new(290., 80.)), + "past the left edge wraps to the right" + ); + assert_eq!( + wrap_into_viewport(PhysicalPosition::new(150., 160.), viewport()), + Some(PhysicalPosition::new(150., 60.)), + "past the bottom edge wraps to the top" + ); + assert_eq!( + wrap_into_viewport(PhysicalPosition::new(150., 80.), ViewportInfo { width: 0., ..viewport() }), + None, + "a zero-sized viewport never wraps" + ); + } + + #[test] + fn wrap_crosses_the_edges_using_the_os_position() { + let mut input = wrapping_input(PhysicalPosition::new(299., 80.)); + + let position = input.wrapped_position(PhysicalPosition::new(301., 80.)); + assert_eq!(position, PhysicalPosition::new(301., 80.), "the tracked position stays continuous so a transform doesn't jump"); + assert_eq!( + input.take_pending_warp(), + Some(PhysicalPosition::new(101., 80.)), + "crossing the right edge moves the OS cursor to the left edge" + ); + assert!(matches!(input.route(position), Route::Editor), "the editor keeps receiving moves once the pointer wraps"); + + assert_eq!(input.wrapped_position(PhysicalPosition::new(99., 80.)), PhysicalPosition::new(299., 80.)); + assert_eq!(input.take_pending_warp(), Some(PhysicalPosition::new(299., 80.))); + + input.set_pointer_wrap(false); + assert!(matches!(input.route(position), Route::Ui), "without a wrap the UI captures an outside position again"); + } + + #[test] + fn pointer_wrap_pauses_while_the_window_is_unfocused() { + let mut input = wrapping_input(PhysicalPosition::new(299., 80.)); + let tracked = input.wrapped_position(PhysicalPosition::new(301., 80.)); + + input.set_window_focused(false); + assert_eq!(input.wrapped_position(PhysicalPosition::new(150., 80.)), tracked, "an unfocused window ignores the cursor"); + assert_eq!( + input.wrapped_position(PhysicalPosition::new(400., 80.)), + tracked, + "moves past the edge while unfocused don't wrap either" + ); + assert_eq!(input.take_pending_warp(), None, "the cursor stays put while the window is unfocused"); + + input.set_window_focused(true); + assert_eq!( + input.wrapped_position(PhysicalPosition::new(170., 80.)), + tracked, + "the drag doesn't jump a viewport when the window comes back" + ); + assert_eq!( + input.wrapped_position(PhysicalPosition::new(171., 80.)), + PhysicalPosition::new(302., 80.), + "movement after that counts as normal" + ); + } +} diff --git a/desktop/src/window.rs b/desktop/src/window.rs index 6b984e0d2af..48c90b8c8dc 100644 --- a/desktop/src/window.rs +++ b/desktop/src/window.rs @@ -186,9 +186,13 @@ impl Window { self.winit_window.set_cursor(cursor); } - pub(crate) fn start_pointer_lock(&self) { - let _ = self.winit_window.set_cursor_grab(winit::window::CursorGrabMode::Locked); - self.winit_window.set_cursor_visible(false); + pub(crate) fn start_pointer_lock(&self) -> bool { + let locked = self.winit_window.set_cursor_grab(winit::window::CursorGrabMode::Locked).is_ok(); + // Hide the cursor only when the grab took, so a refused lock still leaves the user a cursor + if locked { + self.winit_window.set_cursor_visible(false); + } + locked } pub(crate) fn end_pointer_lock(&self) { @@ -196,6 +200,17 @@ impl Window { self.winit_window.set_cursor_visible(true); } + /// Moves the OS cursor, returning false when the platform refuses. + pub(crate) fn set_cursor_position(&self, position: winit::dpi::PhysicalPosition) -> bool { + match self.winit_window.set_cursor_position(position.into()) { + Ok(()) => true, + Err(e) => { + tracing::warn!("Failed to place the cursor: {e}"); + false + } + } + } + pub(crate) fn update_menu(&self, entries: Vec) { self.native_handle.update_menu(entries); } diff --git a/desktop/wrapper/src/intercept_frontend_message.rs b/desktop/wrapper/src/intercept_frontend_message.rs index 27e0a6892ba..988816fd89e 100644 --- a/desktop/wrapper/src/intercept_frontend_message.rs +++ b/desktop/wrapper/src/intercept_frontend_message.rs @@ -106,6 +106,9 @@ pub(super) fn intercept_frontend_message(dispatcher: &mut DesktopWrapperMessageD FrontendMessage::WindowPointerLock => { dispatcher.respond(DesktopFrontendMessage::PointerLock); } + FrontendMessage::WindowPointerWrap { enabled } => { + dispatcher.respond(DesktopFrontendMessage::PointerWrap { enabled }); + } FrontendMessage::WindowUpdateDirectInput { enabled } => { dispatcher.respond(DesktopFrontendMessage::WindowUpdateDirectInput { enabled }); } diff --git a/desktop/wrapper/src/messages.rs b/desktop/wrapper/src/messages.rs index b3663ea87eb..6b49b3ca723 100644 --- a/desktop/wrapper/src/messages.rs +++ b/desktop/wrapper/src/messages.rs @@ -60,6 +60,9 @@ pub enum DesktopFrontendMessage { content: String, }, PointerLock, + PointerWrap { + enabled: bool, + }, WindowClose, WindowMinimize, WindowMaximize, diff --git a/editor/src/messages/app_window/app_window_message.rs b/editor/src/messages/app_window/app_window_message.rs index b6e9641a160..bc6dee88f51 100644 --- a/editor/src/messages/app_window/app_window_message.rs +++ b/editor/src/messages/app_window/app_window_message.rs @@ -5,6 +5,7 @@ use crate::messages::prelude::*; pub enum AppWindowMessage { PointerLock, PointerLockMove { x: f64, y: f64 }, + PointerWrap { enabled: bool }, DirectInput { enabled: bool }, Restart, Close, diff --git a/editor/src/messages/app_window/app_window_message_handler.rs b/editor/src/messages/app_window/app_window_message_handler.rs index e09ad3c19c1..a93fa663847 100644 --- a/editor/src/messages/app_window/app_window_message_handler.rs +++ b/editor/src/messages/app_window/app_window_message_handler.rs @@ -17,6 +17,12 @@ impl MessageHandler for AppWindowMessageHandler { AppWindowMessage::PointerLockMove { x, y } => { responses.add(FrontendMessage::WindowPointerLockMove { position: (x, y) }); } + AppWindowMessage::PointerWrap { enabled } => { + #[cfg(not(target_family = "wasm"))] + responses.add(FrontendMessage::WindowPointerWrap { enabled }); + #[cfg(target_family = "wasm")] + let _ = enabled; + } AppWindowMessage::DirectInput { enabled } => { #[cfg(not(target_family = "wasm"))] responses.add(FrontendMessage::WindowUpdateDirectInput { enabled }); diff --git a/editor/src/messages/frontend/frontend_message.rs b/editor/src/messages/frontend/frontend_message.rs index 08af84e9cd4..f2961cf2a1c 100644 --- a/editor/src/messages/frontend/frontend_message.rs +++ b/editor/src/messages/frontend/frontend_message.rs @@ -355,6 +355,10 @@ pub enum FrontendMessage { position: (f64, f64), }, #[cfg(not(target_family = "wasm"))] + WindowPointerWrap { + enabled: bool, + }, + #[cfg(not(target_family = "wasm"))] WindowUpdateDirectInput { enabled: bool, }, diff --git a/editor/src/messages/tool/tool_message_handler.rs b/editor/src/messages/tool/tool_message_handler.rs index 2d37d474704..960aed6049a 100644 --- a/editor/src/messages/tool/tool_message_handler.rs +++ b/editor/src/messages/tool/tool_message_handler.rs @@ -188,6 +188,11 @@ impl MessageHandler> for ToolMessageHandler send: Box::new(TransformLayerMessage::SelectionChanged.into()), }); + // A transform can outlive the tools that started it, so end it here + if self.transform_layer_handler.is_transforming() { + responses.add(TransformLayerMessage::CancelTransformOperation); + } + responses.add(OverlaysMessage::RemoveProvider { provider: ARTBOARD_OVERLAY_PROVIDER }); HintData::clear_layout(responses); diff --git a/editor/src/messages/tool/transform_layer/transform_layer_message_handler.rs b/editor/src/messages/tool/transform_layer/transform_layer_message_handler.rs index bdc875ad738..0498dd9d5a0 100644 --- a/editor/src/messages/tool/transform_layer/transform_layer_message_handler.rs +++ b/editor/src/messages/tool/transform_layer/transform_layer_message_handler.rs @@ -340,6 +340,7 @@ impl MessageHandler> for responses.add(OverlaysMessage::RemoveProvider { provider: TRANSFORM_GRS_OVERLAY_PROVIDER, }); + responses.add(AppWindowMessage::PointerWrap { enabled: false }); } } TransformLayerMessage::BeginTransformOperation { operation } => { @@ -385,6 +386,7 @@ impl MessageHandler> for responses.add(OverlaysMessage::AddProvider { provider: TRANSFORM_GRS_OVERLAY_PROVIDER, }); + responses.add(AppWindowMessage::PointerWrap { enabled: true }); // Find a way better than this hack responses.add(TransformLayerMessage::PointerMove { slow_key: SLOW_KEY, @@ -472,6 +474,7 @@ impl MessageHandler> for responses.add(OverlaysMessage::AddProvider { provider: TRANSFORM_GRS_OVERLAY_PROVIDER, }); + responses.add(AppWindowMessage::PointerWrap { enabled: true }); } responses.add(TransformLayerMessage::BeginTransformOperation { operation: transform_type }); responses.add(TransformLayerMessage::PointerMove { @@ -511,6 +514,7 @@ impl MessageHandler> for responses.add(OverlaysMessage::RemoveProvider { provider: TRANSFORM_GRS_OVERLAY_PROVIDER, }); + responses.add(AppWindowMessage::PointerWrap { enabled: false }); } TransformLayerMessage::ConstrainX => { self.state.is_transforming_in_local_space = self.transform_operation.constrain_axis(Axis::X, &mut selected, &self.state, document);