From c39b3b4f1f3433eb54431a0025d6e046d8c473a8 Mon Sep 17 00:00:00 2001 From: TTyChud <100087133+TTyChud@users.noreply.github.com> Date: Sat, 26 Sep 2026 12:52:08 +0530 Subject: [PATCH 01/26] Wrap cursor around viewport during G/R/S Hide the OS cursor and draw a Graphite cursor that wraps inside the viewport bounds while grabbing, rotating or scaling. Relative pointer-lock deltas drive the transform so the drag is unbounded on desktop and web, and the grab still works on Wayland where the OS pointer cannot be warped. --- desktop/src/app.rs | 44 ++- desktop/src/input.rs | 51 ++- desktop/src/window.rs | 15 +- .../wrapper/src/intercept_frontend_message.rs | 7 + desktop/wrapper/src/messages.rs | 9 + .../messages/app_window/app_window_message.rs | 1 + .../app_window/app_window_message_handler.rs | 8 + .../src/messages/frontend/frontend_message.rs | 10 + .../transform_layer_message.rs | 1 + .../transform_layer_message_handler.rs | 364 +++++++++++++++++- .../src/components/panels/Document.svelte | 58 ++- .../components/panels/SoftwareCursor.svelte | 22 ++ frontend/src/utility-functions/input.ts | 14 +- frontend/src/utility-functions/viewports.ts | 15 + frontend/wrapper/src/editor_commands.rs | 5 + 15 files changed, 589 insertions(+), 35 deletions(-) create mode 100644 frontend/src/components/panels/SoftwareCursor.svelte diff --git a/desktop/src/app.rs b/desktop/src/app.rs index a69b9cacf90..f4faca9c19e 100644 --- a/desktop/src/app.rs +++ b/desktop/src/app.rs @@ -10,7 +10,7 @@ use std::thread; use std::time::{Duration, Instant}; use winit::application::ApplicationHandler; use winit::data_transfer::{DataTransferSendBuilder, TypeHint}; -use winit::dpi::PhysicalSize; +use winit::dpi::{PhysicalPosition, PhysicalSize}; use winit::event::{ElementState, MouseButton, StartCause, WindowEvent}; use winit::event_loop::run_on_demand::EventLoopExtRunOnDemand; use winit::event_loop::{ActiveEventLoop, AsyncRequestSerial, ControlFlow, DndAction, EventLoop}; @@ -347,11 +347,18 @@ 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::PointerUnlock { x, y } => { + let destination = self.input_state.window_position(x, y); + self.unlock_pointer(destination); + } + DesktopFrontendMessage::UpdateSoftwareCursor { visible, x, y } => { + self.input_state.set_software_cursor(visible.then_some((x, y))); + } DesktopFrontendMessage::WindowClose => { self.app_event_scheduler.schedule(AppEvent::Exit); } @@ -539,6 +546,23 @@ impl App { } } } + + fn unlock_pointer(&mut self, destination: Option>) { + let Some(position) = self.input_state.unlock_pointer(destination) else { + return; + }; + + if let Some(window) = &self.window { + // Wayland only honors this while the pointer is locked, so place the cursor before releasing the grab + if destination.is_some() && window.has_focus() { + window.set_cursor_position(position); + } + window.end_pointer_lock(); + } + + self.ui + .send(UiCommand::Input(InputEvent::pointer().position(position).moved().modifiers(self.input_state.modifiers()).build())); + } } impl ApplicationHandler for App { fn can_create_surfaces(&mut self, event_loop: &dyn ActiveEventLoop) { @@ -575,14 +599,9 @@ impl ApplicationHandler for App { button, .. } = &event && button.clone().mouse_button() == Some(MouseButton::Left) - && let Some(pointer_lock_position) = self.input_state.unlock_pointer() + && self.input_state.pointer_locked() { - if let Some(window) = &self.window { - window.end_pointer_lock(); - } - self.ui.send(UiCommand::Input( - InputEvent::pointer().position(pointer_lock_position).moved().modifiers(self.input_state.modifiers()).build(), - )); + self.unlock_pointer(None); } self.input_state.process( @@ -709,6 +728,9 @@ impl ApplicationHandler for App { if self.input_state.pointer_locked() && let winit::event::DeviceEvent::PointerMotion { delta: (x, y) } = event { + // Device deltas are physical pixels; the transform layer works in logical units + let scale = self.input_state.viewport_scale(); + let (x, y) = if scale != 0. { (x / scale, y / scale) } else { (x, y) }; let message = DesktopWrapperMessage::PointerLockMove { x, y }; self.app_event_scheduler.schedule(AppEvent::DesktopWrapperMessage(message)); } diff --git a/desktop/src/input.rs b/desktop/src/input.rs index dcc438f35b5..d13d249510e 100644 --- a/desktop/src/input.rs +++ b/desktop/src/input.rs @@ -26,6 +26,7 @@ pub(crate) struct InputState { modifiers: ModifiersState, pointer_position: PhysicalPosition, pointer_state: PointerState, + software_cursor: Option<(f64, f64)>, click_tracker: ClickTracker, shake_tracker: ShakeTracker, } @@ -39,6 +40,7 @@ impl InputState { modifiers: ModifiersState::default(), pointer_position: PhysicalPosition::default(), pointer_state: PointerState::Hover { route: Route::Ui }, + software_cursor: None, click_tracker: ClickTracker::default(), shake_tracker: ShakeTracker::default(), } @@ -52,6 +54,10 @@ impl InputState { self.direct_input = enabled; } + pub(crate) fn set_software_cursor(&mut self, cursor: Option<(f64, f64)>) { + self.software_cursor = cursor; + } + pub(crate) fn lock_pointer(&mut self) { self.pointer_state = match self.pointer_state { PointerState::Hover { route } => PointerState::Locked { @@ -67,27 +73,52 @@ impl InputState { }; } - pub(crate) fn unlock_pointer(&mut self) -> Option> { + /// Releases the pointer lock, returning where to place the pointer: `destination` if given, otherwise where the lock began. + pub(crate) fn unlock_pointer(&mut self, destination: Option>) -> Option> { let PointerState::Locked { route: resume, keys, - position: restore, + position: locked_at, } = self.pointer_state else { - return None; + let destination = destination?; + self.pointer_position = destination; + return Some(destination); }; - self.pointer_position = restore; + + let position = destination.unwrap_or(locked_at); + self.pointer_position = position; self.pointer_state = match keys.is_empty() { true => PointerState::Hover { route: Route::Ui }, false => PointerState::Stroke { route: resume, keys }, }; - Some(restore) + Some(position) } pub(crate) fn pointer_locked(&self) -> bool { matches!(self.pointer_state, PointerState::Locked { .. }) } + pub(crate) fn window_position(&self, x: f64, y: f64) -> Option> { + let viewport = self.viewport_info.as_ref()?; + if !x.is_finite() || !y.is_finite() { + return None; + } + + let (x, y) = (viewport.x + x * viewport.scale, viewport.y + y * viewport.scale); + let (left, top, right, bottom) = (viewport.x, viewport.y, viewport.x + viewport.width, viewport.y + viewport.height); + + Some(PhysicalPosition::new( + if right > left { x.clamp(left, right) } else { x }, + if bottom > top { y.clamp(top, bottom) } else { y }, + )) + } + + fn locked_position(&self) -> Option> { + let (x, y) = self.software_cursor?; + self.window_position(x, y) + } + pub(crate) fn modifiers(&self) -> ModifiersState { self.modifiers } @@ -139,6 +170,8 @@ impl InputState { WindowEvent::PointerButton { state, button, position, .. } => { self.pointer_position = *position; + let hit_position = if self.pointer_locked() { self.locked_position().unwrap_or(*position) } else { *position }; + let mouse_button = button.clone().mouse_button(); let keys = match mouse_button { Some(MouseButton::Left) => MouseKeys::LEFT, @@ -179,10 +212,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(hit_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(hit_position); let input = match state { ElementState::Pressed => pointer.pressed(button.clone(), count), ElementState::Released => pointer.released(button.clone(), count), @@ -261,7 +294,7 @@ impl InputState { } } - fn scale(&self) -> f64 { + pub(crate) fn viewport_scale(&self) -> f64 { self.viewport_info.as_ref().map_or(1., |info| info.scale) } @@ -282,7 +315,7 @@ impl InputState { fn pointer_state(&self) -> EditorPointerState { EditorPointerState { - editor_position: (self.pointer_position.x / self.scale(), self.pointer_position.y / self.scale()).into(), + editor_position: (self.pointer_position.x / self.viewport_scale(), self.pointer_position.y / self.viewport_scale()).into(), mouse_keys: self.pointer_keys(), time: Some(self.start.elapsed().as_secs_f64() * 1000.), ..Default::default() diff --git a/desktop/src/window.rs b/desktop/src/window.rs index 6b984e0d2af..0b651e0cb17 100644 --- a/desktop/src/window.rs +++ b/desktop/src/window.rs @@ -186,9 +186,10 @@ 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); + pub(crate) fn start_pointer_lock(&self) -> bool { + let locked = self.winit_window.set_cursor_grab(winit::window::CursorGrabMode::Locked).is_ok(); self.winit_window.set_cursor_visible(false); + locked } pub(crate) fn end_pointer_lock(&self) { @@ -196,6 +197,16 @@ impl Window { self.winit_window.set_cursor_visible(true); } + pub(crate) fn set_cursor_position(&self, position: winit::dpi::PhysicalPosition) { + if let Err(e) = self.winit_window.set_cursor_position(position.into()) { + tracing::warn!("Failed to place the cursor: {e}"); + } + } + + pub(crate) fn has_focus(&self) -> bool { + self.winit_window.has_focus() + } + 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 f5c71f8bc47..9b74983d7d6 100644 --- a/desktop/wrapper/src/intercept_frontend_message.rs +++ b/desktop/wrapper/src/intercept_frontend_message.rs @@ -118,6 +118,13 @@ pub(super) fn intercept_frontend_message(dispatcher: &mut DesktopWrapperMessageD FrontendMessage::WindowPointerLock => { dispatcher.respond(DesktopFrontendMessage::PointerLock); } + FrontendMessage::WindowPointerUnlock { x, y } => { + dispatcher.respond(DesktopFrontendMessage::PointerUnlock { x, y }); + } + FrontendMessage::UpdateSoftwareCursor { visible, x, y } => { + dispatcher.respond(DesktopFrontendMessage::UpdateSoftwareCursor { visible, x, y }); + return Some(FrontendMessage::UpdateSoftwareCursor { visible, x, y }); + } FrontendMessage::WindowUpdateDirectInput { enabled } => { dispatcher.respond(DesktopFrontendMessage::WindowUpdateDirectInput { enabled }); } diff --git a/desktop/wrapper/src/messages.rs b/desktop/wrapper/src/messages.rs index 2bce1100e80..63a59a9cd38 100644 --- a/desktop/wrapper/src/messages.rs +++ b/desktop/wrapper/src/messages.rs @@ -70,6 +70,15 @@ pub enum DesktopFrontendMessage { content: String, }, PointerLock, + PointerUnlock { + x: f64, + y: f64, + }, + UpdateSoftwareCursor { + visible: bool, + x: f64, + y: f64, + }, 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..d173c9f40d2 100644 --- a/editor/src/messages/app_window/app_window_message.rs +++ b/editor/src/messages/app_window/app_window_message.rs @@ -4,6 +4,7 @@ use crate::messages::prelude::*; #[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)] pub enum AppWindowMessage { PointerLock, + PointerUnlock { x: f64, y: f64 }, PointerLockMove { x: f64, y: f64 }, DirectInput { enabled: bool }, Restart, 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..4d179ce7a94 100644 --- a/editor/src/messages/app_window/app_window_message_handler.rs +++ b/editor/src/messages/app_window/app_window_message_handler.rs @@ -14,8 +14,16 @@ impl MessageHandler for AppWindowMessageHandler { #[cfg(not(target_family = "wasm"))] responses.add(FrontendMessage::WindowPointerLock); } + AppWindowMessage::PointerUnlock { x, y } => { + #[cfg(not(target_family = "wasm"))] + responses.add(FrontendMessage::WindowPointerUnlock { x, y }); + #[cfg(target_family = "wasm")] + let _ = (x, y); + } AppWindowMessage::PointerLockMove { x, y } => { responses.add(FrontendMessage::WindowPointerLockMove { position: (x, y) }); + // Routed to the transform layer only, so number-input drags don't move the editor pointer + responses.add(TransformLayerMessage::PointerLockMove { delta: glam::DVec2::new(x, y) }); } AppWindowMessage::DirectInput { enabled } => { #[cfg(not(target_family = "wasm"))] diff --git a/editor/src/messages/frontend/frontend_message.rs b/editor/src/messages/frontend/frontend_message.rs index f309f51d617..ba80d736a2a 100644 --- a/editor/src/messages/frontend/frontend_message.rs +++ b/editor/src/messages/frontend/frontend_message.rs @@ -350,6 +350,16 @@ pub enum FrontendMessage { position: (f64, f64), }, #[cfg(not(target_family = "wasm"))] + WindowPointerUnlock { + x: f64, + y: f64, + }, + UpdateSoftwareCursor { + visible: bool, + x: f64, + y: f64, + }, + #[cfg(not(target_family = "wasm"))] WindowUpdateDirectInput { enabled: bool, }, diff --git a/editor/src/messages/tool/transform_layer/transform_layer_message.rs b/editor/src/messages/tool/transform_layer/transform_layer_message.rs index 76f33718cfe..ef7b5b3ecd9 100644 --- a/editor/src/messages/tool/transform_layer/transform_layer_message.rs +++ b/editor/src/messages/tool/transform_layer/transform_layer_message.rs @@ -23,6 +23,7 @@ pub enum TransformLayerMessage { ConstrainX, ConstrainY, PointerMove { slow_key: Key, increments_key: Key }, + PointerLockMove { delta: DVec2 }, SelectionChanged, TypeBackspace, TypeDecimalPoint, 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 bc9dbd6adb5..ad030150ed1 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 @@ -1,4 +1,5 @@ use crate::consts::{ANGLE_MEASURE_RADIUS_FACTOR, ARC_MEASURE_RADIUS_FACTOR_RANGE, COLOR_OVERLAY_BLUE, COLOR_OVERLAY_GRAY, SLOWING_DIVISOR}; +use crate::messages::frontend::utility_types::MouseCursorIcon; use crate::messages::input_mapper::utility_types::pointer::{DocumentPosition, ViewportPosition}; use crate::messages::portfolio::document::overlays::utility_functions::text_width; use crate::messages::portfolio::document::overlays::utility_types::{OverlayProvider, Pivot}; @@ -95,6 +96,14 @@ pub struct TransformLayerMessageHandler { // Path tool (ghost outlines showing pre-transform geometry) ghost_outline: Vec<(Vec, DAffine2)>, + + // Software cursor shown while G/R/S wraps the pointer around the viewport + software_cursor_active: bool, + software_cursor_pos: ViewportPosition, + // A locked pointer delta waiting to be applied by the next `PointerMove` + pointer_lock_delta: Option, + last_absolute_pointer: ViewportPosition, + tracking_locked_deltas: bool, } #[message_handler_data] @@ -190,11 +199,10 @@ impl MessageHandler> for } } - *mouse_position = input.mouse.position; - *start_mouse = input.mouse.position; + // `mouse_position` already includes any locked deltas accumulated so far + *start_mouse = *mouse_position; *transform = document_to_viewport; - self.local_mouse_start = document.metadata().document_to_viewport.inverse().transform_point2(input.mouse.position); - + self.local_mouse_start = document.metadata().document_to_viewport.inverse().transform_point2(*mouse_position); selected.original_transforms.clear(); selected.responses.add(DocumentMessage::StartTransaction); @@ -339,6 +347,7 @@ impl MessageHandler> for responses.add(OverlaysMessage::RemoveProvider { provider: TRANSFORM_GRS_OVERLAY_PROVIDER, }); + self.disable_software_cursor(responses); } } TransformLayerMessage::BeginTransformOperation { operation } => { @@ -358,8 +367,11 @@ impl MessageHandler> for self.last_point = last_point; self.handle = handle; self.grs_pen_handle = true; - self.mouse_position = input.mouse.position; - self.start_mouse = input.mouse.position; + // Keep the accumulated position while the software cursor is active + if !self.software_cursor_active { + self.mouse_position = input.mouse.position; + } + self.start_mouse = self.mouse_position; let top_left = DVec2::new(last_point.x, handle.y); let bottom_right = DVec2::new(handle.x, last_point.y); @@ -384,6 +396,7 @@ impl MessageHandler> for responses.add(OverlaysMessage::AddProvider { provider: TRANSFORM_GRS_OVERLAY_PROVIDER, }); + self.enable_software_cursor(responses, input.mouse.position); // Find a way better than this hack responses.add(TransformLayerMessage::PointerMove { slow_key: SLOW_KEY, @@ -471,6 +484,7 @@ impl MessageHandler> for responses.add(OverlaysMessage::AddProvider { provider: TRANSFORM_GRS_OVERLAY_PROVIDER, }); + self.enable_software_cursor(responses, input.mouse.position); } responses.add(TransformLayerMessage::BeginTransformOperation { operation: transform_type }); responses.add(TransformLayerMessage::PointerMove { @@ -510,6 +524,7 @@ impl MessageHandler> for responses.add(OverlaysMessage::RemoveProvider { provider: TRANSFORM_GRS_OVERLAY_PROVIDER, }); + self.disable_software_cursor(responses); } TransformLayerMessage::ConstrainX => { self.state.is_transforming_in_local_space = self.transform_operation.constrain_axis(Axis::X, &mut selected, &self.state, document); @@ -520,10 +535,29 @@ impl MessageHandler> for self.transform_operation.grs_typed(self.typing.evaluate(), &mut selected, &self.state, document); } TransformLayerMessage::PointerMove { slow_key, increments_key } => { + // A locked pointer keeps reporting the same absolute position, which is not movement + let repeated_absolute_pointer = input.mouse.position == self.last_absolute_pointer; + self.last_absolute_pointer = input.mouse.position; + let absolute_tracking_resumes = self.software_cursor_active && self.tracking_locked_deltas && !repeated_absolute_pointer; + + let mouse_position = match self.pointer_lock_delta.take() { + Some(position) => position, + None if self.software_cursor_active && repeated_absolute_pointer => self.mouse_position, + None => input.mouse.position, + }; + self.slow = input.keyboard.get(slow_key as usize); let old_ptz = self.ptz; self.ptz = document.document_ptz; if old_ptz != self.ptz { + // A viewport change invalidates this frame's delta + self.mouse_position = mouse_position; + return; + } + + if absolute_tracking_resumes { + // Adopt the restored position without moving, so the drag doesn't snap back by the wrapped distance + self.tracking_locked_deltas = false; self.mouse_position = input.mouse.position; return; } @@ -538,7 +572,7 @@ impl MessageHandler> for match self.transform_operation { TransformOperation::None => {} TransformOperation::Grabbing(translation) => { - let delta_pos = input.mouse.position - self.mouse_position; + let delta_pos = mouse_position - self.mouse_position; let delta_pos = (self.initial_transform * document_to_viewport.inverse()).transform_vector2(delta_pos); let delta_viewport = if self.slow { delta_pos / SLOWING_DIVISOR } else { delta_pos }; let delta_scaled = delta_viewport / document_to_viewport.y_axis.length(); // Values are local to the viewport but scaled so values are relative to the current scale. @@ -547,7 +581,7 @@ impl MessageHandler> for } TransformOperation::Rotating(rotation) => { let start_offset = self.state.pivot_viewport(document) - self.mouse_position; - let end_offset = self.state.pivot_viewport(document) - input.mouse.position; + let end_offset = self.state.pivot_viewport(document) - mouse_position; let angle = start_offset.angle_to(end_offset); let change = if self.slow { angle / SLOWING_DIVISOR } else { angle }; @@ -558,7 +592,7 @@ impl MessageHandler> for TransformOperation::Scaling(mut scale) => { let axis_constraint = scale.constraint; let to_mouse_final = self.mouse_position - self.state.pivot_viewport(document); - let to_mouse_final_old = input.mouse.position - self.state.pivot_viewport(document); + let to_mouse_final_old = mouse_position - self.state.pivot_viewport(document); let to_mouse_start = self.start_mouse - self.state.pivot_viewport(document); let to_mouse_final = self.state.project_onto_constrained(to_mouse_final, axis_constraint); @@ -581,7 +615,29 @@ impl MessageHandler> for }; } - self.mouse_position = input.mouse.position; + if self.software_cursor_active { + let delta = mouse_position - self.mouse_position; + self.software_cursor_pos += delta; + self.software_cursor_pos = wrap_software_cursor(self.software_cursor_pos, viewport.size().into_dvec2()); + + responses.add(FrontendMessage::UpdateSoftwareCursor { + visible: true, + x: self.software_cursor_pos.x, + y: self.software_cursor_pos.y, + }); + } + + self.mouse_position = mouse_position; + } + TransformLayerMessage::PointerLockMove { delta } => { + if self.software_cursor_active { + self.tracking_locked_deltas = true; + self.pointer_lock_delta = Some(self.mouse_position + delta); + responses.add(TransformLayerMessage::PointerMove { + slow_key: SLOW_KEY, + increments_key: INCREMENTS_KEY, + }); + } } TransformLayerMessage::SelectionChanged => { let target_layers = document.network_interface.selected_nodes().selected_visible_layers(&document.network_interface).collect(); @@ -651,6 +707,37 @@ impl TransformLayerMessageHandler { self.transform_operation.hints(responses, self.state.is_transforming_in_local_space); } + fn enable_software_cursor(&mut self, responses: &mut VecDeque, pos: ViewportPosition) { + if self.software_cursor_active { + return; + } + self.software_cursor_active = true; + self.software_cursor_pos = pos; + // `input.mouse.position` is frozen at the lock origin during pointer lock + self.mouse_position = pos; + self.last_absolute_pointer = pos; + responses.add(FrontendMessage::UpdateSoftwareCursor { visible: true, x: pos.x, y: pos.y }); + responses.add(FrontendMessage::UpdateMouseCursor { cursor: MouseCursorIcon::None }); + responses.add(AppWindowMessage::PointerLock); + } + + fn disable_software_cursor(&mut self, responses: &mut VecDeque) { + if !self.software_cursor_active { + return; + } + self.software_cursor_active = false; + self.pointer_lock_delta = None; + self.tracking_locked_deltas = false; + responses.add(FrontendMessage::UpdateSoftwareCursor { visible: false, x: 0., y: 0. }); + // Let the active tool re-emit its cursor + responses.add(ToolMessage::UpdateCursor); + // Leave the pointer where the software cursor ended, not where the lock began + responses.add(AppWindowMessage::PointerUnlock { + x: self.software_cursor_pos.x, + y: self.software_cursor_pos.y, + }); + } + fn set_ghost_outline(ghost_outline: &mut Vec<(Vec, DAffine2)>, shape_editor: &ShapeState, document: &DocumentMessageHandler) { ghost_outline.clear(); for &layer in shape_editor.selected_shape_state.keys() { @@ -669,6 +756,15 @@ impl TransformLayerMessageHandler { } } +/// Wraps a software cursor position into the viewport bounds +fn wrap_software_cursor(position: ViewportPosition, size: ViewportPosition) -> ViewportPosition { + if size.x > 0. && size.y > 0. { + DVec2::new(((position.x % size.x) + size.x) % size.x, ((position.y % size.y) + size.y) % size.y) + } else { + position + } +} + fn calculate_pivot( document: &DocumentMessageHandler, selected_points: &Vec<&ManipulatorPointId>, @@ -1296,4 +1392,252 @@ mod test_transform_layer { let final_child_transform = get_layer_transform(&mut editor, child_layer_id).await.unwrap(); assert!(!final_child_transform.abs_diff_eq(original_child_transform, 1e-5), "Child layer inside transformed group should change"); } + + #[test] + fn test_wrap_software_cursor() { + let size = DVec2::new(100., 50.); + assert_eq!(super::wrap_software_cursor(DVec2::new(10., 20.), size), DVec2::new(10., 20.)); + assert_eq!(super::wrap_software_cursor(DVec2::new(110., -10.), size), DVec2::new(10., 40.)); + // Zero-sized viewport leaves the position untouched + assert_eq!(super::wrap_software_cursor(DVec2::new(10., 20.), DVec2::ZERO), DVec2::new(10., 20.)); + } + + #[tokio::test] + async fn test_pointer_lock_delta_ignored_when_not_transforming() { + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + editor.drag_tool(ToolType::Rectangle, 0., 0., 100., 100., ModifierKeys::empty()).await; + + let mouse_before = editor.editor.dispatcher.message_handlers.tool_message_handler.transform_layer_handler.mouse_position; + let messages = editor.handle_message(TransformLayerMessage::PointerLockMove { delta: DVec2::new(25., -5.) }).await; + let mouse_after = editor.editor.dispatcher.message_handlers.tool_message_handler.transform_layer_handler.mouse_position; + + assert_eq!(mouse_before, mouse_after, "Locked deltas must not move the editor pointer when no transform is active"); + assert!( + !messages.iter().any(|message| matches!(message, FrontendMessage::UpdateSoftwareCursor { .. })), + "Locked deltas must not drive the software cursor when no transform is active" + ); + } + + #[tokio::test] + async fn test_pointer_lock_delta_drives_software_cursor_during_grab() { + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + editor.drag_tool(ToolType::Rectangle, 0., 0., 100., 100., ModifierKeys::empty()).await; + + editor.handle_message(TransformLayerMessage::BeginGrab).await; + + let (cursor_before, active) = { + let handler = &editor.editor.dispatcher.message_handlers.tool_message_handler.transform_layer_handler; + (handler.software_cursor_pos, handler.software_cursor_active) + }; + assert!(active, "Beginning a grab should activate the software cursor"); + + let delta = DVec2::new(25., -5.); + let messages = editor.handle_message(TransformLayerMessage::PointerLockMove { delta }).await; + + // The test viewport has no size, so the cursor moves by exactly the delta + let expected = cursor_before + delta; + let handler = &editor.editor.dispatcher.message_handlers.tool_message_handler.transform_layer_handler; + assert_eq!(handler.software_cursor_pos, expected, "Locked delta should move the software cursor"); + + let software_cursor_message = messages.iter().find_map(|message| match message { + FrontendMessage::UpdateSoftwareCursor { visible, x, y } => Some((*visible, *x, *y)), + _ => None, + }); + assert_eq!( + software_cursor_message, + Some((true, expected.x, expected.y)), + "A locked delta during a transform should update the software cursor" + ); + + editor.handle_message(TransformLayerMessage::CancelTransformOperation).await; + } + + #[tokio::test] + async fn test_ptz_change_mid_transform_keeps_software_cursor_in_lockstep() { + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + editor.drag_tool(ToolType::Rectangle, 0., 0., 100., 100., ModifierKeys::empty()).await; + let layer = editor.active_document().metadata().all_layers().next().unwrap(); + + editor.handle_message(TransformLayerMessage::BeginGrab).await; + editor.handle_message(TransformLayerMessage::PointerLockMove { delta: DVec2::new(10., 0.) }).await; + + editor.handle_message(NavigationMessage::CanvasPan { delta: DVec2::new(20., 20.) }).await; + editor.handle_message(NavigationMessage::CanvasZoomIncrease { center_on_mouse: false }).await; + + let transform_before = get_layer_transform(&mut editor, layer).await.unwrap(); + let (cursor_before, mouse_before) = { + let handler = &editor.editor.dispatcher.message_handlers.tool_message_handler.transform_layer_handler; + (handler.software_cursor_pos, handler.mouse_position) + }; + + let dropped = DVec2::new(75., 40.); + let messages = editor.handle_message(TransformLayerMessage::PointerLockMove { delta: dropped }).await; + + let (cursor_after, mouse_after) = { + let handler = &editor.editor.dispatcher.message_handlers.tool_message_handler.transform_layer_handler; + (handler.software_cursor_pos, handler.mouse_position) + }; + assert_eq!(cursor_after, cursor_before, "The software cursor must not advance on the frame whose delta the transform drops"); + assert_eq!(mouse_after, mouse_before + dropped, "The tracking position still resyncs to the pointer"); + assert!( + !messages.iter().any(|message| matches!(message, FrontendMessage::UpdateSoftwareCursor { .. })), + "Dropping the delta must not move the software cursor away from the transformed layer" + ); + + let transform_after = get_layer_transform(&mut editor, layer).await.unwrap(); + assert!(transform_after.abs_diff_eq(transform_before, 1e-5), "The dropped delta must not move the layer either"); + + editor.handle_message(TransformLayerMessage::CancelTransformOperation).await; + } + + #[tokio::test] + async fn test_chained_transform_uses_accumulated_locked_position() { + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + editor.drag_tool(ToolType::Rectangle, 0., 0., 100., 100., ModifierKeys::empty()).await; + + editor.handle_message(TransformLayerMessage::BeginGrab).await; + + // Locked deltas accumulate in the handler; the absolute input position stays frozen + let start_position = editor.editor.dispatcher.message_handlers.tool_message_handler.transform_layer_handler.mouse_position; + let delta = DVec2::new(30., 10.); + editor.handle_message(TransformLayerMessage::PointerLockMove { delta }).await; + + let handler = &editor.editor.dispatcher.message_handlers.tool_message_handler.transform_layer_handler; + assert_eq!(handler.mouse_position, start_position + delta, "Locked delta should accumulate into the tracking position"); + + // A chained operation should start from the accumulated position + editor.handle_message(TransformLayerMessage::BeginRotate).await; + let handler = &editor.editor.dispatcher.message_handlers.tool_message_handler.transform_layer_handler; + assert_eq!( + handler.start_mouse, + start_position + delta, + "A chained operation should start from the accumulated locked position instead of resetting to the frozen input position" + ); + + editor.handle_message(TransformLayerMessage::CancelTransformOperation).await; + } + + #[tokio::test] + async fn test_locked_drag_ignores_stale_absolute_reports() { + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + editor.drag_tool(ToolType::Rectangle, 0., 0., 100., 100., ModifierKeys::empty()).await; + let layer = editor.active_document().metadata().all_layers().next().unwrap(); + + editor.handle_message(TransformLayerMessage::BeginGrab).await; + let origin = editor.editor.dispatcher.message_handlers.tool_message_handler.transform_layer_handler.mouse_position; + editor.handle_message(TransformLayerMessage::PointerLockMove { delta: DVec2::new(100., 0.) }).await; + + let transform_after_drag = get_layer_transform(&mut editor, layer).await.unwrap(); + let (cursor_after_drag, mouse_after_drag) = { + let handler = &editor.editor.dispatcher.message_handlers.tool_message_handler.transform_layer_handler; + (handler.software_cursor_pos, handler.mouse_position) + }; + + // Shift and Control re-dispatch this message through the input mapper's `refresh_keys` while the absolute position stays frozen + editor + .handle_message(TransformLayerMessage::PointerMove { + slow_key: Key::Shift, + increments_key: Key::Control, + }) + .await; + + let (cursor_after_refresh, mouse_after_refresh) = { + let handler = &editor.editor.dispatcher.message_handlers.tool_message_handler.transform_layer_handler; + (handler.software_cursor_pos, handler.mouse_position) + }; + assert_eq!(cursor_after_refresh, cursor_after_drag, "A stale absolute report must not move the software cursor"); + assert_eq!(mouse_after_refresh, mouse_after_drag, "A stale absolute report must not move the tracked pointer position"); + assert!( + get_layer_transform(&mut editor, layer).await.unwrap().abs_diff_eq(transform_after_drag, 1e-5), + "A stale absolute report must not move the layer" + ); + + editor.handle_message(TransformLayerMessage::PointerLockMove { delta: DVec2::new(10., 0.) }).await; + let transform_after_more_dragging = get_layer_transform(&mut editor, layer).await.unwrap(); + assert!(!transform_after_more_dragging.abs_diff_eq(transform_after_drag, 1e-5), "Locked deltas must keep driving the transform"); + + // The lock goes away and the platform restores the bare cursor where the lock began + editor.move_mouse(origin.x, origin.y, ModifierKeys::empty(), MouseKeys::NONE).await; + editor + .handle_message(TransformLayerMessage::PointerMove { + slow_key: Key::Shift, + increments_key: Key::Control, + }) + .await; + assert!( + get_layer_transform(&mut editor, layer).await.unwrap().abs_diff_eq(transform_after_more_dragging, 1e-5), + "The restored cursor position must not move the layer" + ); + + // The first absolute movement adopts the restored position, so the drag must not snap back by the wrapped distance + editor.move_mouse(origin.x + 10., origin.y, ModifierKeys::empty(), MouseKeys::NONE).await; + editor + .handle_message(TransformLayerMessage::PointerMove { + slow_key: Key::Shift, + increments_key: Key::Control, + }) + .await; + let transform_after_resuming = get_layer_transform(&mut editor, layer).await.unwrap(); + assert!( + transform_after_resuming.abs_diff_eq(transform_after_more_dragging, 1e-5), + "Resuming absolute tracking must not move the layer by the wrapped distance" + ); + + editor.move_mouse(origin.x + 40., origin.y, ModifierKeys::empty(), MouseKeys::NONE).await; + editor + .handle_message(TransformLayerMessage::PointerMove { + slow_key: Key::Shift, + increments_key: Key::Control, + }) + .await; + let transform_after_moving = get_layer_transform(&mut editor, layer).await.unwrap(); + assert!( + transform_after_moving.translation.x > transform_after_resuming.translation.x + 1., + "Absolute movement must drive the transform again once tracking resumes" + ); + + editor.handle_message(TransformLayerMessage::CancelTransformOperation).await; + } + + #[tokio::test] + async fn test_absolute_pointer_drives_a_grab_without_a_lock() { + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + editor.drag_tool(ToolType::Rectangle, 0., 0., 100., 100., ModifierKeys::empty()).await; + let layer = editor.active_document().metadata().all_layers().next().unwrap(); + + editor.handle_message(TransformLayerMessage::BeginGrab).await; + let transform_before = get_layer_transform(&mut editor, layer).await.unwrap(); + let origin = editor.editor.dispatcher.message_handlers.tool_message_handler.transform_layer_handler.mouse_position; + + editor.move_mouse(origin.x + 60., origin.y + 40., ModifierKeys::empty(), MouseKeys::NONE).await; + editor + .handle_message(TransformLayerMessage::PointerMove { + slow_key: Key::Shift, + increments_key: Key::Control, + }) + .await; + let transform_after_moving = get_layer_transform(&mut editor, layer).await.unwrap(); + assert!(!transform_after_moving.abs_diff_eq(transform_before, 1e-5), "Moving the absolute pointer must drive the transform"); + + editor.move_mouse(origin.x, origin.y, ModifierKeys::empty(), MouseKeys::NONE).await; + editor + .handle_message(TransformLayerMessage::PointerMove { + slow_key: Key::Shift, + increments_key: Key::Control, + }) + .await; + let transform_after_returning = get_layer_transform(&mut editor, layer).await.unwrap(); + assert!( + transform_after_returning.abs_diff_eq(transform_before, 1e-3), + "Returning the absolute pointer to where the grab began must drive the transform back, not be swallowed" + ); + + editor.handle_message(TransformLayerMessage::CancelTransformOperation).await; + } } diff --git a/frontend/src/components/panels/Document.svelte b/frontend/src/components/panels/Document.svelte index e80ed12fe0a..f3edda2a475 100644 --- a/frontend/src/components/panels/Document.svelte +++ b/frontend/src/components/panels/Document.svelte @@ -4,6 +4,7 @@ import EyedropperPreview, { ZOOM_WINDOW_DIMENSIONS } from "/src/components/floating-menus/EyedropperPreview.svelte"; import LayoutCol from "/src/components/layout/LayoutCol.svelte"; import LayoutRow from "/src/components/layout/LayoutRow.svelte"; + import SoftwareCursor from "/src/components/panels/SoftwareCursor.svelte"; import Graph from "/src/components/views/Graph.svelte"; import RulerInput from "/src/components/widgets/inputs/RulerInput.svelte"; import ScrollbarInput from "/src/components/widgets/inputs/ScrollbarInput.svelte"; @@ -18,7 +19,7 @@ import { cleanupInputField } from "/src/utility-functions/input"; import { textInputCleanup } from "/src/utility-functions/keyboard-entry"; import { rasterizeSVGCanvas } from "/src/utility-functions/rasterization"; - import { setupViewportResizeObserver, hasFirstArtworkBeenReceived, markFirstArtworkReceived } from "/src/utility-functions/viewports"; + import { setupViewportResizeObserver, hasFirstArtworkBeenReceived, markFirstArtworkReceived, setSoftwareCursor } from "/src/utility-functions/viewports"; import type { EditorWrapper, MenuDirection, MouseCursorIcon, SRGBA8 } from "/wrapper/pkg/graphite_wasm_wrapper"; let rulerHorizontal: RulerInput | undefined; @@ -75,6 +76,29 @@ let cursorEyedropperPreviewColorPrimary = ""; let cursorEyedropperPreviewColorSecondary = ""; + let softwareCursorVisible = false; + let softwareCursorX = 0; + let softwareCursorY = 0; + + function handleSoftwareCursorWebMove(e: PointerEvent) { + if (!softwareCursorVisible || !isWeb || window.document.pointerLockElement !== viewport) return; + const dx = e.movementX; + const dy = e.movementY; + if (dx === 0 && dy === 0) return; + try { + editor.appWindowPointerLockMove(dx, dy); + } catch { + // The wrapper may not be ready yet + } + } + + function handleSoftwareCursorPointerLockChange() { + if (isWeb && softwareCursorVisible && window.document.pointerLockElement !== viewport) { + editor.onKeyDown("Escape", 0, false); + editor.onKeyUp("Escape", 0, false); + } + } + // Gradient stop color picker let gradientStopPickerColor: SRGBA8 | undefined = undefined; let gradientStopPickerPosition: { x: number; y: number } | undefined = undefined; @@ -520,6 +544,34 @@ updateMouseCursor(data.cursor); }); + // Software cursor that wraps the pointer around the viewport during G/R/S transforms + subscriptions.subscribeFrontendMessage("UpdateSoftwareCursor", async (data) => { + // The browser only grants a lock during a user activation, so request it before the `await` + if (isWeb && viewport) { + if (data.visible) { + try { + Promise.resolve(viewport.requestPointerLock?.()).catch(() => undefined); + } catch { + // The absolute pointer position drives the transform if the lock is refused + } + } else if (window.document.pointerLockElement === viewport) { + // Unlike the desktop, browsers return the pointer to where the lock began, so the wrapped position can't be kept + window.document.exitPointerLock(); + } + } + + await tick(); + + softwareCursorVisible = data.visible; + softwareCursorX = data.x; + softwareCursorY = data.y; + // Hit-testing reports events where this cursor is drawn + setSoftwareCursor({ visible: data.visible, x: data.x, y: data.y }); + }); + + window.addEventListener("pointermove", handleSoftwareCursorWebMove); + window.document.addEventListener("pointerlockchange", handleSoftwareCursorPointerLockChange); + // Text entry subscriptions.subscribeFrontendMessage("TriggerTextCommit", async () => { await tick(); @@ -567,6 +619,8 @@ viewportResizeObserver?.disconnect(); removeUpdatePixelRatio?.(); addedFontFaces.forEach((face) => window.document.fonts.delete(face)); + window.removeEventListener("pointermove", handleSoftwareCursorWebMove); + window.document.removeEventListener("pointerlockchange", handleSoftwareCursorPointerLockChange); cleanupInputField(editor); subscriptions.unsubscribeFrontendMessage("UpdateDocumentArtwork"); @@ -575,6 +629,7 @@ subscriptions.unsubscribeFrontendMessage("UpdateDocumentScrollbars"); subscriptions.unsubscribeFrontendMessage("UpdateDocumentRulers"); subscriptions.unsubscribeFrontendMessage("UpdateMouseCursor"); + subscriptions.unsubscribeFrontendMessage("UpdateSoftwareCursor"); subscriptions.unsubscribeFrontendMessage("TriggerTextCommit"); subscriptions.unsubscribeFrontendMessage("DisplayEditableTextbox"); subscriptions.unsubscribeFrontendMessage("DisplayEditableTextboxUpdateFontData"); @@ -659,6 +714,7 @@ y={cursorTop} /> {/if} +
+ export let x: number = 0; + export let y: number = 0; + export let visible: boolean = false; + + +{#if visible} + +{/if} + + diff --git a/frontend/src/utility-functions/input.ts b/frontend/src/utility-functions/input.ts index 7a5902a5c13..4d765c91910 100644 --- a/frontend/src/utility-functions/input.ts +++ b/frontend/src/utility-functions/input.ts @@ -6,6 +6,7 @@ import type { PortfolioStore } from "/src/stores/portfolio"; import { pasteFile } from "/src/utility-functions/files"; import { makeKeyboardModifiersBitfield, textInputCleanup, getLocalizedScanCode } from "/src/utility-functions/keyboard-entry"; import { operatingSystem } from "/src/utility-functions/platform"; +import { softwareCursorClientPosition } from "/src/utility-functions/viewports"; import type { EditorWrapper } from "/wrapper/pkg/graphite_wasm_wrapper"; const BUTTON_LEFT = 0; @@ -113,8 +114,15 @@ function isObserveOnly(e: MouseEvent): boolean { return import.meta.env.MODE === "native" && e.getModifierState("NumLock"); } +// A locked pointer stays frozen where the lock began, so report events at the software cursor +function pointerEventPosition(e: MouseEvent): { x: number; y: number } { + const softwareCursor = inPointerLock ? softwareCursorClientPosition() : undefined; + return softwareCursor ?? { x: e.clientX, y: e.clientY }; +} + // While any pointer button is already down, additional button down events are not reported, but they are sent as `pointermove` events and these are handled in the backend export function onPointerMove(e: PointerEvent, editor: EditorWrapper, documentStore: DocumentStore) { + if (inPointerLock) return; potentiallyRestoreCanvasFocus(e); if (!e.buttons) viewportPointerInteractionOngoing = false; @@ -166,7 +174,8 @@ export function onPointerDown(e: PointerEvent, editor: EditorWrapper, dialogStor if (viewportPointerInteractionOngoing && isTargetingCanvas instanceof Element) { const modifiers = makeKeyboardModifiersBitfield(e); - editor.onMouseDown(e.clientX, e.clientY, e.buttons, modifiers, ...pointerAttributes(e)); + const { x, y } = pointerEventPosition(e); + editor.onMouseDown(x, y, e.buttons, modifiers, ...pointerAttributes(e)); } } @@ -184,7 +193,8 @@ export function onPointerUp(e: PointerEvent, editor: EditorWrapper) { if (isObserveOnly(e) || textToolInteractiveInputElement) return; const modifiers = makeKeyboardModifiersBitfield(e); - editor.onMouseUp(e.clientX, e.clientY, e.buttons, modifiers, ...pointerAttributes(e)); + const { x, y } = pointerEventPosition(e); + editor.onMouseUp(x, y, e.buttons, modifiers, ...pointerAttributes(e)); } // Mouse events diff --git a/frontend/src/utility-functions/viewports.ts b/frontend/src/utility-functions/viewports.ts index b1916ce47ef..62626a34739 100644 --- a/frontend/src/utility-functions/viewports.ts +++ b/frontend/src/utility-functions/viewports.ts @@ -10,6 +10,21 @@ export function markFirstArtworkReceived(): void { firstArtworkEverReceived = true; } +// Software cursor position in viewport coordinates during G/R/S transforms +let softwareCursor = { visible: false, x: 0, y: 0 }; + +export function setSoftwareCursor(cursor: { visible: boolean; x: number; y: number }): void { + softwareCursor = cursor; +} + +// Window position of the software cursor, or `undefined` while it isn't shown +export function softwareCursorClientPosition(): { x: number; y: number } | undefined { + if (!softwareCursor.visible) return undefined; + + const bounds = window.document.querySelector("[data-viewport-container]")?.getBoundingClientRect(); + return { x: (bounds?.left || 0) + softwareCursor.x, y: (bounds?.top || 0) + softwareCursor.y }; +} + export function setupViewportResizeObserver(editor: EditorWrapper): () => void { const viewports = Array.from(window.document.querySelectorAll("[data-viewport-container]")); if (viewports.length <= 0) return () => {}; diff --git a/frontend/wrapper/src/editor_commands.rs b/frontend/wrapper/src/editor_commands.rs index ceea7c05630..57981db835c 100644 --- a/frontend/wrapper/src/editor_commands.rs +++ b/frontend/wrapper/src/editor_commands.rs @@ -88,6 +88,11 @@ mod editor_commands { AppWindowMessage::PointerLock.into() } + /// Reports pointer movement while the pointer is locked + fn app_window_pointer_lock_move(x: f64, y: f64) -> Message { + AppWindowMessage::PointerLockMove { x, y }.into() + } + /// Minimizes the application window to the taskbar or dock fn app_window_minimize() -> Message { AppWindowMessage::Minimize.into() From 93caec596786cf28dbe811fd5948eb8a05138c67 Mon Sep 17 00:00:00 2001 From: TTyChud <100087133+TTyChud@users.noreply.github.com> Date: Sat, 26 Sep 2026 13:29:04 +0530 Subject: [PATCH 02/26] Ignore the OS pointer position while the pointer is locked A locked pointer keeps reporting the frozen or warp-back OS location, and a lock held without a mouse button routes those reports down the UI path, so they still reached the editor as absolute moves alongside the locked deltas. Drop the event while the pointer is really locked, which leaves the absolute path live exactly when the locked deltas are not. Also move the software cursor state out of a utility-functions module, which must not hold state, into a store so the drawn cursor and the hit-tested position share one source. --- desktop/src/input.rs | 5 +++ .../src/components/panels/Document.svelte | 16 +++------ frontend/src/stores/software-cursor.ts | 34 +++++++++++++++++++ frontend/src/utility-functions/input.ts | 2 +- frontend/src/utility-functions/viewports.ts | 15 -------- 5 files changed, 45 insertions(+), 27 deletions(-) create mode 100644 frontend/src/stores/software-cursor.ts diff --git a/desktop/src/input.rs b/desktop/src/input.rs index d13d249510e..c44df163781 100644 --- a/desktop/src/input.rs +++ b/desktop/src/input.rs @@ -128,6 +128,11 @@ impl InputState { WindowEvent::PointerMoved { position, source, .. } => { self.pointer_position = *position; + // A locked pointer only reports the frozen or warp-back OS location, so the editor follows the locked deltas instead + if self.pointer_locked() { + return; + } + let route = match self.pointer_state { PointerState::Hover { .. } => { let next = self.route(*position); diff --git a/frontend/src/components/panels/Document.svelte b/frontend/src/components/panels/Document.svelte index f3edda2a475..a6efb80960a 100644 --- a/frontend/src/components/panels/Document.svelte +++ b/frontend/src/components/panels/Document.svelte @@ -12,6 +12,7 @@ import WidgetLayout from "/src/components/widgets/WidgetLayout.svelte"; import type { AppWindowStore } from "/src/stores/app-window"; import type { DocumentStore } from "/src/stores/document"; + import { softwareCursor, setSoftwareCursor } from "/src/stores/software-cursor"; import type { SubscriptionsRouter } from "/src/subscriptions-router"; import type { MessageBody } from "/src/subscriptions-router"; import { fillChoiceColor, createSRgba8 } from "/src/utility-functions/colors"; @@ -19,7 +20,7 @@ import { cleanupInputField } from "/src/utility-functions/input"; import { textInputCleanup } from "/src/utility-functions/keyboard-entry"; import { rasterizeSVGCanvas } from "/src/utility-functions/rasterization"; - import { setupViewportResizeObserver, hasFirstArtworkBeenReceived, markFirstArtworkReceived, setSoftwareCursor } from "/src/utility-functions/viewports"; + import { setupViewportResizeObserver, hasFirstArtworkBeenReceived, markFirstArtworkReceived } from "/src/utility-functions/viewports"; import type { EditorWrapper, MenuDirection, MouseCursorIcon, SRGBA8 } from "/wrapper/pkg/graphite_wasm_wrapper"; let rulerHorizontal: RulerInput | undefined; @@ -76,12 +77,8 @@ let cursorEyedropperPreviewColorPrimary = ""; let cursorEyedropperPreviewColorSecondary = ""; - let softwareCursorVisible = false; - let softwareCursorX = 0; - let softwareCursorY = 0; - function handleSoftwareCursorWebMove(e: PointerEvent) { - if (!softwareCursorVisible || !isWeb || window.document.pointerLockElement !== viewport) return; + if (!$softwareCursor.visible || !isWeb || window.document.pointerLockElement !== viewport) return; const dx = e.movementX; const dy = e.movementY; if (dx === 0 && dy === 0) return; @@ -93,7 +90,7 @@ } function handleSoftwareCursorPointerLockChange() { - if (isWeb && softwareCursorVisible && window.document.pointerLockElement !== viewport) { + if (isWeb && $softwareCursor.visible && window.document.pointerLockElement !== viewport) { editor.onKeyDown("Escape", 0, false); editor.onKeyUp("Escape", 0, false); } @@ -562,9 +559,6 @@ await tick(); - softwareCursorVisible = data.visible; - softwareCursorX = data.x; - softwareCursorY = data.y; // Hit-testing reports events where this cursor is drawn setSoftwareCursor({ visible: data.visible, x: data.x, y: data.y }); }); @@ -714,7 +708,7 @@ y={cursorTop} /> {/if} - +
= import.meta.hot?.data?.store || writable(initialState); +if (import.meta.hot) import.meta.hot.data.store = store; + +export const softwareCursor = store; + +// The cursor drawn while G/R/S wraps the pointer around the viewport, positioned in viewport coordinates +export function setSoftwareCursor(cursor: SoftwareCursorState): void { + store.set(cursor); +} + +// Window position of the software cursor, or `undefined` while it isn't shown +export function softwareCursorClientPosition(): { x: number; y: number } | undefined { + const cursor = get(store); + if (!cursor.visible) return undefined; + + const bounds = window.document.querySelector("[data-viewport-container]")?.getBoundingClientRect(); + return { x: (bounds?.left || 0) + cursor.x, y: (bounds?.top || 0) + cursor.y }; +} diff --git a/frontend/src/utility-functions/input.ts b/frontend/src/utility-functions/input.ts index 4d765c91910..9c10532dbd3 100644 --- a/frontend/src/utility-functions/input.ts +++ b/frontend/src/utility-functions/input.ts @@ -3,10 +3,10 @@ import type { DialogStore } from "/src/stores/dialog"; import type { DocumentStore } from "/src/stores/document"; import { toggleFullscreen } from "/src/stores/fullscreen"; import type { PortfolioStore } from "/src/stores/portfolio"; +import { softwareCursorClientPosition } from "/src/stores/software-cursor"; import { pasteFile } from "/src/utility-functions/files"; import { makeKeyboardModifiersBitfield, textInputCleanup, getLocalizedScanCode } from "/src/utility-functions/keyboard-entry"; import { operatingSystem } from "/src/utility-functions/platform"; -import { softwareCursorClientPosition } from "/src/utility-functions/viewports"; import type { EditorWrapper } from "/wrapper/pkg/graphite_wasm_wrapper"; const BUTTON_LEFT = 0; diff --git a/frontend/src/utility-functions/viewports.ts b/frontend/src/utility-functions/viewports.ts index 62626a34739..b1916ce47ef 100644 --- a/frontend/src/utility-functions/viewports.ts +++ b/frontend/src/utility-functions/viewports.ts @@ -10,21 +10,6 @@ export function markFirstArtworkReceived(): void { firstArtworkEverReceived = true; } -// Software cursor position in viewport coordinates during G/R/S transforms -let softwareCursor = { visible: false, x: 0, y: 0 }; - -export function setSoftwareCursor(cursor: { visible: boolean; x: number; y: number }): void { - softwareCursor = cursor; -} - -// Window position of the software cursor, or `undefined` while it isn't shown -export function softwareCursorClientPosition(): { x: number; y: number } | undefined { - if (!softwareCursor.visible) return undefined; - - const bounds = window.document.querySelector("[data-viewport-container]")?.getBoundingClientRect(); - return { x: (bounds?.left || 0) + softwareCursor.x, y: (bounds?.top || 0) + softwareCursor.y }; -} - export function setupViewportResizeObserver(editor: EditorWrapper): () => void { const viewports = Array.from(window.document.querySelectorAll("[data-viewport-container]")); if (viewports.length <= 0) return () => {}; From 5611e2d51ed2580dd70a4b8ce245359e30d7f8a0 Mon Sep 17 00:00:00 2001 From: TTyChud <100087133+TTyChud@users.noreply.github.com> Date: Sun, 4 Oct 2026 01:51:36 +0530 Subject: [PATCH 03/26] Track the software cursor in the input preprocessor - Software cursor state moves from TransformLayerMessageHandler to InputPreprocessorMessageHandler, dropping five fields from the transform layer and letting any message handler use the wrapping. - The desktop software cursor is a glam::DVec2 rather than an (f64, f64) tuple, and window_position documents the coordinate space it converts from. - wrap_software_cursor uses DVec2::rem_euclid instead of a hand-rolled positive modulo. - Locked-pointer movement folds into the existing pointermove/pointerlockchange pipeline instead of adding a second pair of window listeners. - The locked-delta forwarding is a named helper, keeping onPointerMove's cyclomatic complexity under the limit. Verified: cargo check -p graphite-editor --lib clean, cargo fmt --all -- --check clean, eslint clean on the changed frontend files. --- desktop/src/app.rs | 4 +- desktop/src/input.rs | 27 ++- desktop/src/window.rs | 5 +- editor/src/dispatcher.rs | 2 +- .../app_window/app_window_message_handler.rs | 4 +- .../input_preprocessor_message.rs | 5 +- .../input_preprocessor_message_handler.rs | 164 ++++++++++++- .../transform_layer_message.rs | 1 - .../transform_layer_message_handler.rs | 215 ++++-------------- .../src/components/panels/Document.svelte | 24 -- frontend/src/managers/input.ts | 4 +- frontend/src/utility-functions/input.ts | 25 +- 12 files changed, 251 insertions(+), 229 deletions(-) diff --git a/desktop/src/app.rs b/desktop/src/app.rs index d60075568f4..48cfe2ba251 100644 --- a/desktop/src/app.rs +++ b/desktop/src/app.rs @@ -346,11 +346,11 @@ impl App { } } DesktopFrontendMessage::PointerUnlock { x, y } => { - let destination = self.input_state.window_position(x, y); + let destination = self.input_state.window_position(glam::DVec2::new(x, y)); self.unlock_pointer(destination); } DesktopFrontendMessage::UpdateSoftwareCursor { visible, x, y } => { - self.input_state.set_software_cursor(visible.then_some((x, y))); + self.input_state.set_software_cursor(visible.then_some(glam::DVec2::new(x, y))); } DesktopFrontendMessage::WindowClose => { self.app_event_scheduler.schedule(AppEvent::Exit); diff --git a/desktop/src/input.rs b/desktop/src/input.rs index c44df163781..d6bf5fd28a3 100644 --- a/desktop/src/input.rs +++ b/desktop/src/input.rs @@ -26,7 +26,7 @@ pub(crate) struct InputState { modifiers: ModifiersState, pointer_position: PhysicalPosition, pointer_state: PointerState, - software_cursor: Option<(f64, f64)>, + software_cursor: Option, click_tracker: ClickTracker, shake_tracker: ShakeTracker, } @@ -54,7 +54,7 @@ impl InputState { self.direct_input = enabled; } - pub(crate) fn set_software_cursor(&mut self, cursor: Option<(f64, f64)>) { + pub(crate) fn set_software_cursor(&mut self, cursor: Option) { self.software_cursor = cursor; } @@ -99,24 +99,25 @@ impl InputState { matches!(self.pointer_state, PointerState::Locked { .. }) } - pub(crate) fn window_position(&self, x: f64, y: f64) -> Option> { + /// Converts a position in viewport coordinates into the equivalent window coordinates, clamped to the viewport bounds. + pub(crate) fn window_position(&self, position: glam::DVec2) -> Option> { let viewport = self.viewport_info.as_ref()?; - if !x.is_finite() || !y.is_finite() { + if !position.is_finite() { return None; } - let (x, y) = (viewport.x + x * viewport.scale, viewport.y + y * viewport.scale); - let (left, top, right, bottom) = (viewport.x, viewport.y, viewport.x + viewport.width, viewport.y + viewport.height); + let (left, top) = (viewport.x, viewport.y); + let (right, bottom) = (left + viewport.width, top + viewport.height); + let position = glam::DVec2::new(left, top) + position * viewport.scale; Some(PhysicalPosition::new( - if right > left { x.clamp(left, right) } else { x }, - if bottom > top { y.clamp(top, bottom) } else { y }, + if right > left { position.x.clamp(left, right) } else { position.x }, + if bottom > top { position.y.clamp(top, bottom) } else { position.y }, )) } fn locked_position(&self) -> Option> { - let (x, y) = self.software_cursor?; - self.window_position(x, y) + self.window_position(self.software_cursor?) } pub(crate) fn modifiers(&self) -> ModifiersState { @@ -128,7 +129,7 @@ impl InputState { WindowEvent::PointerMoved { position, source, .. } => { self.pointer_position = *position; - // A locked pointer only reports the frozen or warp-back OS location, so the editor follows the locked deltas instead + // A locked pointer reports the OS cursor frozen at the lock origin (or warped back to it), which is not movement, so the editor follows the relative deltas instead if self.pointer_locked() { return; } @@ -148,9 +149,7 @@ impl InputState { match route { 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(), diff --git a/desktop/src/window.rs b/desktop/src/window.rs index 0b651e0cb17..84f7a8ce5d4 100644 --- a/desktop/src/window.rs +++ b/desktop/src/window.rs @@ -188,7 +188,10 @@ impl Window { pub(crate) fn start_pointer_lock(&self) -> bool { let locked = self.winit_window.set_cursor_grab(winit::window::CursorGrabMode::Locked).is_ok(); - self.winit_window.set_cursor_visible(false); + // Never hide the cursor unless the grab actually took: platforms that refuse the lock (notably Wayland) would otherwise leave the app with no usable cursor + if locked { + self.winit_window.set_cursor_visible(false); + } locked } diff --git a/editor/src/dispatcher.rs b/editor/src/dispatcher.rs index 9aa8bc31889..14534dc5642 100644 --- a/editor/src/dispatcher.rs +++ b/editor/src/dispatcher.rs @@ -28,7 +28,7 @@ pub struct DispatcherMessageHandlers { debug_message_handler: DebugMessageHandler, defer_message_handler: DeferMessageHandler, dialog_message_handler: DialogMessageHandler, - input_preprocessor_message_handler: InputPreprocessorMessageHandler, + pub(crate) input_preprocessor_message_handler: InputPreprocessorMessageHandler, key_mapping_message_handler: KeyMappingMessageHandler, layout_message_handler: LayoutMessageHandler, menu_bar_message_handler: MenuBarMessageHandler, 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 4d179ce7a94..a70d29b19bb 100644 --- a/editor/src/messages/app_window/app_window_message_handler.rs +++ b/editor/src/messages/app_window/app_window_message_handler.rs @@ -22,8 +22,8 @@ impl MessageHandler for AppWindowMessageHandler { } AppWindowMessage::PointerLockMove { x, y } => { responses.add(FrontendMessage::WindowPointerLockMove { position: (x, y) }); - // Routed to the transform layer only, so number-input drags don't move the editor pointer - responses.add(TransformLayerMessage::PointerLockMove { delta: glam::DVec2::new(x, y) }); + // Routed to the input preprocessor, which only applies locked deltas while the software cursor is active, so number-input drags don't move the editor pointer + responses.add(InputPreprocessorMessage::PointerLockMove { delta: glam::DVec2::new(x, y) }); } AppWindowMessage::DirectInput { enabled } => { #[cfg(not(target_family = "wasm"))] diff --git a/editor/src/messages/input_preprocessor/input_preprocessor_message.rs b/editor/src/messages/input_preprocessor/input_preprocessor_message.rs index 91663a85485..b8ea2c9bdbe 100644 --- a/editor/src/messages/input_preprocessor/input_preprocessor_message.rs +++ b/editor/src/messages/input_preprocessor/input_preprocessor_message.rs @@ -1,5 +1,5 @@ use crate::messages::input_mapper::utility_types::keyboard::{Key, ModifierKeys}; -use crate::messages::input_mapper::utility_types::pointer::EditorPointerState; +use crate::messages::input_mapper::utility_types::pointer::{EditorPointerState, ViewportPosition}; use crate::messages::prelude::*; #[impl_message(Message, InputPreprocessor)] @@ -12,6 +12,9 @@ pub enum InputPreprocessorMessage { PointerMove { editor_mouse_state: EditorPointerState, modifier_keys: ModifierKeys }, PointerUp { editor_mouse_state: EditorPointerState, modifier_keys: ModifierKeys }, PointerShake { editor_mouse_state: EditorPointerState, modifier_keys: ModifierKeys }, + PointerLockMove { delta: ViewportPosition }, + BeginSoftwareCursor { position: ViewportPosition }, + EndSoftwareCursor, CurrentTime { timestamp: u64 }, WheelScroll { editor_mouse_state: EditorPointerState, modifier_keys: ModifierKeys }, } diff --git a/editor/src/messages/input_preprocessor/input_preprocessor_message_handler.rs b/editor/src/messages/input_preprocessor/input_preprocessor_message_handler.rs index 8e29e813f96..46158341bec 100644 --- a/editor/src/messages/input_preprocessor/input_preprocessor_message_handler.rs +++ b/editor/src/messages/input_preprocessor/input_preprocessor_message_handler.rs @@ -1,7 +1,8 @@ use crate::application::Editor; +use crate::messages::frontend::utility_types::MouseCursorIcon; use crate::messages::input_mapper::utility_types::keyboard::{Key, KeyStates, ModifierKeys}; use crate::messages::input_mapper::utility_types::misc::FrameTimeInfo; -use crate::messages::input_mapper::utility_types::pointer::{MouseButton, MouseKeys, PointerState}; +use crate::messages::input_mapper::utility_types::pointer::{MouseButton, MouseKeys, PointerState, ViewportPosition}; use crate::messages::prelude::*; use std::time::Duration; @@ -17,6 +18,15 @@ pub struct InputPreprocessorMessageHandler { pub keyboard: KeyStates, pub mouse: PointerState, pointer_down_time: f64, + software_cursor: Option, +} + +// The cursor the editor tracks while G/R/S wraps it around the viewport, since the OS cursor is locked in place +#[derive(Debug, Clone, Copy)] +struct SoftwareCursor { + position: ViewportPosition, + last_absolute: ViewportPosition, + tracking_deltas: bool, } #[message_handler_data] @@ -69,8 +79,10 @@ impl<'a> MessageHandler { self.update_states_of_modifier_keys(modifier_keys, responses); - let pointer_state = editor_mouse_state.to_pointer_state(viewport); + let mut pointer_state = editor_mouse_state.to_pointer_state(viewport); + pointer_state.position = self.update_pointer_position(pointer_state.position); self.mouse.position = pointer_state.position; + self.send_software_cursor(viewport, responses); responses.add(InputMapperMessage::PointerMove); @@ -93,6 +105,42 @@ impl<'a> MessageHandler { + let Some(cursor) = &mut self.software_cursor else { return }; + + cursor.position += delta; + cursor.tracking_deltas = true; + self.mouse.position = cursor.position; + self.send_software_cursor(viewport, responses); + + responses.add(InputMapperMessage::PointerMove); + } + InputPreprocessorMessage::BeginSoftwareCursor { position } => { + if self.software_cursor.is_some() { + return; + } + + self.software_cursor = Some(SoftwareCursor { + position, + last_absolute: position, + tracking_deltas: false, + }); + self.mouse.position = position; + + self.send_software_cursor(viewport, responses); + responses.add(FrontendMessage::UpdateMouseCursor { cursor: MouseCursorIcon::None }); + responses.add(AppWindowMessage::PointerLock); + } + InputPreprocessorMessage::EndSoftwareCursor => { + let Some(cursor) = self.software_cursor.take() else { return }; + + responses.add(FrontendMessage::UpdateSoftwareCursor { visible: false, x: 0., y: 0. }); + // Let the active tool re-emit its cursor + responses.add(ToolMessage::UpdateCursor); + // Leave the pointer where the software cursor ended, not where the lock began + let position = wrap_software_cursor(cursor.position, viewport.size().into_dvec2()); + responses.add(AppWindowMessage::PointerUnlock { x: position.x, y: position.y }); + } InputPreprocessorMessage::CurrentTime { timestamp } => { responses.add(AnimationMessage::SetTime { time: timestamp as f64 }); self.time = timestamp; @@ -117,6 +165,37 @@ impl<'a> MessageHandler ViewportPosition { + let Some(cursor) = &mut self.software_cursor else { return reported }; + + let motion = reported - cursor.last_absolute; + cursor.last_absolute = reported; + + if motion != ViewportPosition::ZERO { + // The OS cursor only reappears once the lock is lost, and that jump is not movement to apply + if cursor.tracking_deltas { + cursor.tracking_deltas = false; + } else { + cursor.position += motion; + } + } + + cursor.position + } + + // Tells the frontend where to draw the software cursor, which wraps around the viewport while the OS cursor is locked + fn send_software_cursor(&self, viewport: &ViewportMessageHandler, responses: &mut VecDeque) { + let Some(cursor) = self.software_cursor else { return }; + + let position = wrap_software_cursor(cursor.position, viewport.size().into_dvec2()); + responses.add(FrontendMessage::UpdateSoftwareCursor { + visible: true, + x: position.x, + y: position.y, + }); + } + fn translate_mouse_event(&mut self, mut new_state: PointerState, allow_first_button_down: bool, responses: &mut VecDeque) { let click_mappings = [ (MouseKeys::LEFT, Key::MouseLeft), @@ -191,12 +270,91 @@ impl InputPreprocessorMessageHandler { } } +/// Wraps a software cursor position into the viewport bounds +fn wrap_software_cursor(position: ViewportPosition, size: ViewportPosition) -> ViewportPosition { + if size.x > 0. && size.y > 0. { position.rem_euclid(size) } else { position } +} + #[cfg(test)] mod test { use crate::messages::input_mapper::utility_types::keyboard::{Key, ModifierKeys}; - use crate::messages::input_mapper::utility_types::pointer::EditorPointerState; + use crate::messages::input_mapper::utility_types::pointer::{EditorPointerState, ViewportPosition}; use crate::messages::prelude::*; + fn pointer_state(x: f64, y: f64) -> EditorPointerState { + EditorPointerState { + editor_position: ViewportPosition::new(x, y), + ..Default::default() + } + } + + #[test] + fn software_cursor_tracks_locked_deltas_and_ignores_the_reported_position() { + let mut input_preprocessor = InputPreprocessorMessageHandler::default(); + let viewport = ViewportMessageHandler::default(); + let mut responses = VecDeque::new(); + let context = || InputPreprocessorMessageContext { viewport: &viewport }; + let moved = |x: f64, y: f64| InputPreprocessorMessage::PointerMove { + editor_mouse_state: pointer_state(x, y), + modifier_keys: ModifierKeys::empty(), + }; + + let begin = InputPreprocessorMessage::BeginSoftwareCursor { + position: ViewportPosition::new(10., 10.), + }; + input_preprocessor.process_message(begin, &mut responses, context()); + + input_preprocessor.process_message(moved(10., 10.), &mut responses, context()); + assert_eq!( + input_preprocessor.mouse.position, + ViewportPosition::new(10., 10.), + "The frozen OS position reported by a locked pointer is not movement" + ); + + let delta = InputPreprocessorMessage::PointerLockMove { + delta: ViewportPosition::new(25., -5.), + }; + input_preprocessor.process_message(delta, &mut responses, context()); + assert_eq!(input_preprocessor.mouse.position, ViewportPosition::new(35., 5.), "A locked delta moves the tracked pointer"); + assert!( + responses.contains(&FrontendMessage::UpdateSoftwareCursor { visible: true, x: 35., y: 5. }.into()), + "A locked delta moves the drawn software cursor" + ); + + input_preprocessor.process_message(moved(10., 10.), &mut responses, context()); + assert_eq!( + input_preprocessor.mouse.position, + ViewportPosition::new(35., 5.), + "The frozen position is still not movement after a locked delta" + ); + + input_preprocessor.process_message(moved(0., 0.), &mut responses, context()); + assert_eq!( + input_preprocessor.mouse.position, + ViewportPosition::new(35., 5.), + "The restored OS position is adopted without moving the pointer" + ); + + input_preprocessor.process_message(moved(0., 4.), &mut responses, context()); + assert_eq!( + input_preprocessor.mouse.position, + ViewportPosition::new(35., 9.), + "Movement is tracked relative to the restored position once the lock is lost" + ); + } + + #[test] + fn test_wrap_software_cursor() { + let size = ViewportPosition::new(100., 50.); + assert_eq!(super::wrap_software_cursor(ViewportPosition::new(10., 20.), size), ViewportPosition::new(10., 20.)); + assert_eq!(super::wrap_software_cursor(ViewportPosition::new(110., -10.), size), ViewportPosition::new(10., 40.)); + assert_eq!( + super::wrap_software_cursor(ViewportPosition::new(10., 20.), ViewportPosition::ZERO), + ViewportPosition::new(10., 20.), + "A zero-sized viewport leaves the position untouched" + ); + } + #[test] fn process_action_mouse_move_handle_modifier_keys() { let mut input_preprocessor = InputPreprocessorMessageHandler::default(); diff --git a/editor/src/messages/tool/transform_layer/transform_layer_message.rs b/editor/src/messages/tool/transform_layer/transform_layer_message.rs index ef7b5b3ecd9..76f33718cfe 100644 --- a/editor/src/messages/tool/transform_layer/transform_layer_message.rs +++ b/editor/src/messages/tool/transform_layer/transform_layer_message.rs @@ -23,7 +23,6 @@ pub enum TransformLayerMessage { ConstrainX, ConstrainY, PointerMove { slow_key: Key, increments_key: Key }, - PointerLockMove { delta: DVec2 }, SelectionChanged, TypeBackspace, TypeDecimalPoint, 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 0dce6c6c7ba..2902b8bff1c 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 @@ -1,5 +1,4 @@ use crate::consts::{ANGLE_MEASURE_RADIUS_FACTOR, ARC_MEASURE_RADIUS_FACTOR_RANGE, COLOR_OVERLAY_BLUE, COLOR_OVERLAY_GRAY, SLOWING_DIVISOR}; -use crate::messages::frontend::utility_types::MouseCursorIcon; use crate::messages::input_mapper::utility_types::pointer::{DocumentPosition, ViewportPosition}; use crate::messages::portfolio::document::overlays::utility_functions::text_width; use crate::messages::portfolio::document::overlays::utility_types::{OverlayProvider, Pivot}; @@ -97,14 +96,6 @@ pub struct TransformLayerMessageHandler { // Path tool (ghost outlines showing pre-transform geometry) ghost_outline: Vec<(Vec, DAffine2)>, - - // Software cursor shown while G/R/S wraps the pointer around the viewport - software_cursor_active: bool, - software_cursor_pos: ViewportPosition, - // A locked pointer delta waiting to be applied by the next `PointerMove` - pointer_lock_delta: Option, - last_absolute_pointer: ViewportPosition, - tracking_locked_deltas: bool, } #[message_handler_data] @@ -143,7 +134,7 @@ impl MessageHandler> for ); let document_to_viewport = document.metadata().document_to_viewport; - let mut begin_operation = |operation: TransformOperation, typing: &mut Typing, mouse_position: &mut DVec2, start_mouse: &mut DVec2, transform: &mut DAffine2| { + let mut begin_operation = |operation: TransformOperation, typing: &mut Typing, cursor_position: &mut DVec2, start_mouse: &mut DVec2, transform: &mut DAffine2| { if operation != TransformOperation::None { selected.revert_operation(); typing.clear(); @@ -200,10 +191,10 @@ impl MessageHandler> for } } - // `mouse_position` already includes any locked deltas accumulated so far - *start_mouse = *mouse_position; + // The tracked position already includes any locked deltas accumulated so far + *start_mouse = *cursor_position; *transform = document_to_viewport; - self.local_mouse_start = document.metadata().document_to_viewport.inverse().transform_point2(*mouse_position); + self.local_mouse_start = document.metadata().document_to_viewport.inverse().transform_point2(*cursor_position); selected.original_transforms.clear(); selected.responses.add(DocumentMessage::StartTransaction); @@ -348,7 +339,7 @@ impl MessageHandler> for responses.add(OverlaysMessage::RemoveProvider { provider: TRANSFORM_GRS_OVERLAY_PROVIDER, }); - self.disable_software_cursor(responses); + responses.add(InputPreprocessorMessage::EndSoftwareCursor); } } TransformLayerMessage::BeginTransformOperation { operation } => { @@ -368,10 +359,7 @@ impl MessageHandler> for self.last_point = last_point; self.handle = handle; self.grs_pen_handle = true; - // Keep the accumulated position while the software cursor is active - if !self.software_cursor_active { - self.mouse_position = input.mouse.position; - } + self.mouse_position = input.mouse.position; self.start_mouse = self.mouse_position; let top_left = DVec2::new(last_point.x, handle.y); @@ -397,7 +385,7 @@ impl MessageHandler> for responses.add(OverlaysMessage::AddProvider { provider: TRANSFORM_GRS_OVERLAY_PROVIDER, }); - self.enable_software_cursor(responses, input.mouse.position); + responses.add(InputPreprocessorMessage::BeginSoftwareCursor { position: input.mouse.position }); // Find a way better than this hack responses.add(TransformLayerMessage::PointerMove { slow_key: SLOW_KEY, @@ -485,7 +473,8 @@ impl MessageHandler> for responses.add(OverlaysMessage::AddProvider { provider: TRANSFORM_GRS_OVERLAY_PROVIDER, }); - self.enable_software_cursor(responses, input.mouse.position); + self.mouse_position = input.mouse.position; + responses.add(InputPreprocessorMessage::BeginSoftwareCursor { position: input.mouse.position }); } responses.add(TransformLayerMessage::BeginTransformOperation { operation: transform_type }); responses.add(TransformLayerMessage::PointerMove { @@ -525,7 +514,7 @@ impl MessageHandler> for responses.add(OverlaysMessage::RemoveProvider { provider: TRANSFORM_GRS_OVERLAY_PROVIDER, }); - self.disable_software_cursor(responses); + responses.add(InputPreprocessorMessage::EndSoftwareCursor); } TransformLayerMessage::ConstrainX => { self.state.is_transforming_in_local_space = self.transform_operation.constrain_axis(Axis::X, &mut selected, &self.state, document); @@ -536,30 +525,15 @@ impl MessageHandler> for self.transform_operation.grs_typed(self.typing.evaluate(), &mut selected, &self.state, document); } TransformLayerMessage::PointerMove { slow_key, increments_key } => { - // A locked pointer keeps reporting the same absolute position, which is not movement - let repeated_absolute_pointer = input.mouse.position == self.last_absolute_pointer; - self.last_absolute_pointer = input.mouse.position; - let absolute_tracking_resumes = self.software_cursor_active && self.tracking_locked_deltas && !repeated_absolute_pointer; - - let mouse_position = match self.pointer_lock_delta.take() { - Some(position) => position, - None if self.software_cursor_active && repeated_absolute_pointer => self.mouse_position, - None => input.mouse.position, - }; + // The input preprocessor tracks the pointer, wrapping it around the viewport while G/R/S holds the pointer lock + let cursor_position = input.mouse.position; self.slow = input.keyboard.get(slow_key as usize); let old_ptz = self.ptz; self.ptz = document.document_ptz; if old_ptz != self.ptz { // A viewport change invalidates this frame's delta - self.mouse_position = mouse_position; - return; - } - - if absolute_tracking_resumes { - // Adopt the restored position without moving, so the drag doesn't snap back by the wrapped distance - self.tracking_locked_deltas = false; - self.mouse_position = input.mouse.position; + self.mouse_position = cursor_position; return; } @@ -573,7 +547,7 @@ impl MessageHandler> for match self.transform_operation { TransformOperation::None => {} TransformOperation::Grabbing(translation) => { - let delta_pos = mouse_position - self.mouse_position; + let delta_pos = cursor_position - self.mouse_position; let delta_pos = (self.initial_transform * document_to_viewport.inverse()).transform_vector2(delta_pos); let delta_viewport = if self.slow { delta_pos / SLOWING_DIVISOR } else { delta_pos }; let delta_scaled = delta_viewport / document_to_viewport.y_axis.length(); // Values are local to the viewport but scaled so values are relative to the current scale. @@ -582,7 +556,7 @@ impl MessageHandler> for } TransformOperation::Rotating(rotation) => { let start_offset = self.state.pivot_viewport(document) - self.mouse_position; - let end_offset = self.state.pivot_viewport(document) - mouse_position; + let end_offset = self.state.pivot_viewport(document) - cursor_position; if let Some(angle) = start_offset.try_angle_to(end_offset) { let change = if self.slow { angle / SLOWING_DIVISOR } else { angle }; @@ -593,7 +567,7 @@ impl MessageHandler> for TransformOperation::Scaling(mut scale) => { let axis_constraint = scale.constraint; let to_mouse_final = self.mouse_position - self.state.pivot_viewport(document); - let to_mouse_final_old = mouse_position - self.state.pivot_viewport(document); + let to_mouse_final_old = cursor_position - self.state.pivot_viewport(document); let to_mouse_start = self.start_mouse - self.state.pivot_viewport(document); let to_mouse_final = self.state.project_onto_constrained(to_mouse_final, axis_constraint); @@ -616,29 +590,7 @@ impl MessageHandler> for }; } - if self.software_cursor_active { - let delta = mouse_position - self.mouse_position; - self.software_cursor_pos += delta; - self.software_cursor_pos = wrap_software_cursor(self.software_cursor_pos, viewport.size().into_dvec2()); - - responses.add(FrontendMessage::UpdateSoftwareCursor { - visible: true, - x: self.software_cursor_pos.x, - y: self.software_cursor_pos.y, - }); - } - - self.mouse_position = mouse_position; - } - TransformLayerMessage::PointerLockMove { delta } => { - if self.software_cursor_active { - self.tracking_locked_deltas = true; - self.pointer_lock_delta = Some(self.mouse_position + delta); - responses.add(TransformLayerMessage::PointerMove { - slow_key: SLOW_KEY, - increments_key: INCREMENTS_KEY, - }); - } + self.mouse_position = cursor_position; } TransformLayerMessage::SelectionChanged => { let target_layers = document.network_interface.selected_nodes().selected_visible_layers(&document.network_interface).collect(); @@ -708,37 +660,6 @@ impl TransformLayerMessageHandler { self.transform_operation.hints(responses, self.state.is_transforming_in_local_space); } - fn enable_software_cursor(&mut self, responses: &mut VecDeque, pos: ViewportPosition) { - if self.software_cursor_active { - return; - } - self.software_cursor_active = true; - self.software_cursor_pos = pos; - // `input.mouse.position` is frozen at the lock origin during pointer lock - self.mouse_position = pos; - self.last_absolute_pointer = pos; - responses.add(FrontendMessage::UpdateSoftwareCursor { visible: true, x: pos.x, y: pos.y }); - responses.add(FrontendMessage::UpdateMouseCursor { cursor: MouseCursorIcon::None }); - responses.add(AppWindowMessage::PointerLock); - } - - fn disable_software_cursor(&mut self, responses: &mut VecDeque) { - if !self.software_cursor_active { - return; - } - self.software_cursor_active = false; - self.pointer_lock_delta = None; - self.tracking_locked_deltas = false; - responses.add(FrontendMessage::UpdateSoftwareCursor { visible: false, x: 0., y: 0. }); - // Let the active tool re-emit its cursor - responses.add(ToolMessage::UpdateCursor); - // Leave the pointer where the software cursor ended, not where the lock began - responses.add(AppWindowMessage::PointerUnlock { - x: self.software_cursor_pos.x, - y: self.software_cursor_pos.y, - }); - } - fn set_ghost_outline(ghost_outline: &mut Vec<(Vec, DAffine2)>, shape_editor: &ShapeState, document: &DocumentMessageHandler) { ghost_outline.clear(); for &layer in shape_editor.selected_shape_state.keys() { @@ -757,15 +678,6 @@ impl TransformLayerMessageHandler { } } -/// Wraps a software cursor position into the viewport bounds -fn wrap_software_cursor(position: ViewportPosition, size: ViewportPosition) -> ViewportPosition { - if size.x > 0. && size.y > 0. { - DVec2::new(((position.x % size.x) + size.x) % size.x, ((position.y % size.y) + size.y) % size.y) - } else { - position - } -} - fn calculate_pivot( document: &DocumentMessageHandler, selected_points: &Vec<&ManipulatorPointId>, @@ -1392,53 +1304,32 @@ mod test_transform_layer { assert!(!final_child_transform.abs_diff_eq(original_child_transform, 1e-5), "Child layer inside transformed group should change"); } - #[test] - fn test_wrap_software_cursor() { - let size = DVec2::new(100., 50.); - assert_eq!(super::wrap_software_cursor(DVec2::new(10., 20.), size), DVec2::new(10., 20.)); - assert_eq!(super::wrap_software_cursor(DVec2::new(110., -10.), size), DVec2::new(10., 40.)); - // Zero-sized viewport leaves the position untouched - assert_eq!(super::wrap_software_cursor(DVec2::new(10., 20.), DVec2::ZERO), DVec2::new(10., 20.)); + fn pointer_position(editor: &EditorTestUtils) -> DVec2 { + editor.editor.dispatcher.message_handlers.input_preprocessor_message_handler.mouse.position } #[tokio::test] - async fn test_pointer_lock_delta_ignored_when_not_transforming() { + async fn test_pointer_lock_deltas_only_apply_during_a_transform() { let mut editor = EditorTestUtils::create(); editor.new_document().await; editor.drag_tool(ToolType::Rectangle, 0., 0., 100., 100., ModifierKeys::empty()).await; - let mouse_before = editor.editor.dispatcher.message_handlers.tool_message_handler.transform_layer_handler.mouse_position; - let messages = editor.handle_message(TransformLayerMessage::PointerLockMove { delta: DVec2::new(25., -5.) }).await; - let mouse_after = editor.editor.dispatcher.message_handlers.tool_message_handler.transform_layer_handler.mouse_position; + let pointer_before = pointer_position(&editor); + let messages = editor.handle_message(InputPreprocessorMessage::PointerLockMove { delta: DVec2::new(25., -5.) }).await; - assert_eq!(mouse_before, mouse_after, "Locked deltas must not move the editor pointer when no transform is active"); + assert_eq!(pointer_before, pointer_position(&editor), "Locked deltas must not move the pointer when no transform is active"); assert!( !messages.iter().any(|message| matches!(message, FrontendMessage::UpdateSoftwareCursor { .. })), "Locked deltas must not drive the software cursor when no transform is active" ); - } - - #[tokio::test] - async fn test_pointer_lock_delta_drives_software_cursor_during_grab() { - let mut editor = EditorTestUtils::create(); - editor.new_document().await; - editor.drag_tool(ToolType::Rectangle, 0., 0., 100., 100., ModifierKeys::empty()).await; editor.handle_message(TransformLayerMessage::BeginGrab).await; - - let (cursor_before, active) = { - let handler = &editor.editor.dispatcher.message_handlers.tool_message_handler.transform_layer_handler; - (handler.software_cursor_pos, handler.software_cursor_active) - }; - assert!(active, "Beginning a grab should activate the software cursor"); + let pointer_at_begin = pointer_position(&editor); let delta = DVec2::new(25., -5.); - let messages = editor.handle_message(TransformLayerMessage::PointerLockMove { delta }).await; + let messages = editor.handle_message(InputPreprocessorMessage::PointerLockMove { delta }).await; - // The test viewport has no size, so the cursor moves by exactly the delta - let expected = cursor_before + delta; - let handler = &editor.editor.dispatcher.message_handlers.tool_message_handler.transform_layer_handler; - assert_eq!(handler.software_cursor_pos, expected, "Locked delta should move the software cursor"); + assert_eq!(pointer_position(&editor), pointer_at_begin + delta, "A locked delta should move the pointer during a transform"); let software_cursor_message = messages.iter().find_map(|message| match message { FrontendMessage::UpdateSoftwareCursor { visible, x, y } => Some((*visible, *x, *y)), @@ -1446,7 +1337,7 @@ mod test_transform_layer { }); assert_eq!( software_cursor_message, - Some((true, expected.x, expected.y)), + Some((true, pointer_at_begin.x + delta.x, pointer_at_begin.y + delta.y)), "A locked delta during a transform should update the software cursor" ); @@ -1454,38 +1345,29 @@ mod test_transform_layer { } #[tokio::test] - async fn test_ptz_change_mid_transform_keeps_software_cursor_in_lockstep() { + async fn test_ptz_change_mid_transform_drops_the_delta_without_moving_the_layer() { let mut editor = EditorTestUtils::create(); editor.new_document().await; editor.drag_tool(ToolType::Rectangle, 0., 0., 100., 100., ModifierKeys::empty()).await; let layer = editor.active_document().metadata().all_layers().next().unwrap(); editor.handle_message(TransformLayerMessage::BeginGrab).await; - editor.handle_message(TransformLayerMessage::PointerLockMove { delta: DVec2::new(10., 0.) }).await; + editor.handle_message(InputPreprocessorMessage::PointerLockMove { delta: DVec2::new(10., 0.) }).await; editor.handle_message(NavigationMessage::CanvasPan { delta: DVec2::new(20., 20.) }).await; editor.handle_message(NavigationMessage::CanvasZoomIncrease { center_on_mouse: false }).await; let transform_before = get_layer_transform(&mut editor, layer).await.unwrap(); - let (cursor_before, mouse_before) = { - let handler = &editor.editor.dispatcher.message_handlers.tool_message_handler.transform_layer_handler; - (handler.software_cursor_pos, handler.mouse_position) - }; + let pointer_before = pointer_position(&editor); let dropped = DVec2::new(75., 40.); - let messages = editor.handle_message(TransformLayerMessage::PointerLockMove { delta: dropped }).await; + editor.handle_message(InputPreprocessorMessage::PointerLockMove { delta: dropped }).await; - let (cursor_after, mouse_after) = { - let handler = &editor.editor.dispatcher.message_handlers.tool_message_handler.transform_layer_handler; - (handler.software_cursor_pos, handler.mouse_position) - }; - assert_eq!(cursor_after, cursor_before, "The software cursor must not advance on the frame whose delta the transform drops"); - assert_eq!(mouse_after, mouse_before + dropped, "The tracking position still resyncs to the pointer"); - assert!( - !messages.iter().any(|message| matches!(message, FrontendMessage::UpdateSoftwareCursor { .. })), - "Dropping the delta must not move the software cursor away from the transformed layer" + assert_eq!( + pointer_position(&editor), + pointer_before + dropped, + "The pointer follows the locked delta even on the frame whose movement the transform drops" ); - let transform_after = get_layer_transform(&mut editor, layer).await.unwrap(); assert!(transform_after.abs_diff_eq(transform_before, 1e-5), "The dropped delta must not move the layer either"); @@ -1500,15 +1382,13 @@ mod test_transform_layer { editor.handle_message(TransformLayerMessage::BeginGrab).await; - // Locked deltas accumulate in the handler; the absolute input position stays frozen - let start_position = editor.editor.dispatcher.message_handlers.tool_message_handler.transform_layer_handler.mouse_position; + let start_position = pointer_position(&editor); let delta = DVec2::new(30., 10.); - editor.handle_message(TransformLayerMessage::PointerLockMove { delta }).await; + editor.handle_message(InputPreprocessorMessage::PointerLockMove { delta }).await; let handler = &editor.editor.dispatcher.message_handlers.tool_message_handler.transform_layer_handler; assert_eq!(handler.mouse_position, start_position + delta, "Locked delta should accumulate into the tracking position"); - // A chained operation should start from the accumulated position editor.handle_message(TransformLayerMessage::BeginRotate).await; let handler = &editor.editor.dispatcher.message_handlers.tool_message_handler.transform_layer_handler; assert_eq!( @@ -1528,16 +1408,12 @@ mod test_transform_layer { let layer = editor.active_document().metadata().all_layers().next().unwrap(); editor.handle_message(TransformLayerMessage::BeginGrab).await; - let origin = editor.editor.dispatcher.message_handlers.tool_message_handler.transform_layer_handler.mouse_position; - editor.handle_message(TransformLayerMessage::PointerLockMove { delta: DVec2::new(100., 0.) }).await; + let origin = pointer_position(&editor); + editor.handle_message(InputPreprocessorMessage::PointerLockMove { delta: DVec2::new(100., 0.) }).await; let transform_after_drag = get_layer_transform(&mut editor, layer).await.unwrap(); - let (cursor_after_drag, mouse_after_drag) = { - let handler = &editor.editor.dispatcher.message_handlers.tool_message_handler.transform_layer_handler; - (handler.software_cursor_pos, handler.mouse_position) - }; + let pointer_after_drag = pointer_position(&editor); - // Shift and Control re-dispatch this message through the input mapper's `refresh_keys` while the absolute position stays frozen editor .handle_message(TransformLayerMessage::PointerMove { slow_key: Key::Shift, @@ -1545,22 +1421,16 @@ mod test_transform_layer { }) .await; - let (cursor_after_refresh, mouse_after_refresh) = { - let handler = &editor.editor.dispatcher.message_handlers.tool_message_handler.transform_layer_handler; - (handler.software_cursor_pos, handler.mouse_position) - }; - assert_eq!(cursor_after_refresh, cursor_after_drag, "A stale absolute report must not move the software cursor"); - assert_eq!(mouse_after_refresh, mouse_after_drag, "A stale absolute report must not move the tracked pointer position"); + assert_eq!(pointer_position(&editor), pointer_after_drag, "A stale absolute report must not move the pointer"); assert!( get_layer_transform(&mut editor, layer).await.unwrap().abs_diff_eq(transform_after_drag, 1e-5), "A stale absolute report must not move the layer" ); - editor.handle_message(TransformLayerMessage::PointerLockMove { delta: DVec2::new(10., 0.) }).await; + editor.handle_message(InputPreprocessorMessage::PointerLockMove { delta: DVec2::new(10., 0.) }).await; let transform_after_more_dragging = get_layer_transform(&mut editor, layer).await.unwrap(); assert!(!transform_after_more_dragging.abs_diff_eq(transform_after_drag, 1e-5), "Locked deltas must keep driving the transform"); - // The lock goes away and the platform restores the bare cursor where the lock began editor.move_mouse(origin.x, origin.y, ModifierKeys::empty(), MouseKeys::NONE).await; editor .handle_message(TransformLayerMessage::PointerMove { @@ -1573,7 +1443,6 @@ mod test_transform_layer { "The restored cursor position must not move the layer" ); - // The first absolute movement adopts the restored position, so the drag must not snap back by the wrapped distance editor.move_mouse(origin.x + 10., origin.y, ModifierKeys::empty(), MouseKeys::NONE).await; editor .handle_message(TransformLayerMessage::PointerMove { diff --git a/frontend/src/components/panels/Document.svelte b/frontend/src/components/panels/Document.svelte index a6efb80960a..914ce908305 100644 --- a/frontend/src/components/panels/Document.svelte +++ b/frontend/src/components/panels/Document.svelte @@ -77,25 +77,6 @@ let cursorEyedropperPreviewColorPrimary = ""; let cursorEyedropperPreviewColorSecondary = ""; - function handleSoftwareCursorWebMove(e: PointerEvent) { - if (!$softwareCursor.visible || !isWeb || window.document.pointerLockElement !== viewport) return; - const dx = e.movementX; - const dy = e.movementY; - if (dx === 0 && dy === 0) return; - try { - editor.appWindowPointerLockMove(dx, dy); - } catch { - // The wrapper may not be ready yet - } - } - - function handleSoftwareCursorPointerLockChange() { - if (isWeb && $softwareCursor.visible && window.document.pointerLockElement !== viewport) { - editor.onKeyDown("Escape", 0, false); - editor.onKeyUp("Escape", 0, false); - } - } - // Gradient stop color picker let gradientStopPickerColor: SRGBA8 | undefined = undefined; let gradientStopPickerPosition: { x: number; y: number } | undefined = undefined; @@ -563,9 +544,6 @@ setSoftwareCursor({ visible: data.visible, x: data.x, y: data.y }); }); - window.addEventListener("pointermove", handleSoftwareCursorWebMove); - window.document.addEventListener("pointerlockchange", handleSoftwareCursorPointerLockChange); - // Text entry subscriptions.subscribeFrontendMessage("TriggerTextCommit", async () => { await tick(); @@ -613,8 +591,6 @@ viewportResizeObserver?.disconnect(); removeUpdatePixelRatio?.(); addedFontFaces.forEach((face) => window.document.fonts.delete(face)); - window.removeEventListener("pointermove", handleSoftwareCursorWebMove); - window.document.removeEventListener("pointerlockchange", handleSoftwareCursorPointerLockChange); cleanupInputField(editor); subscriptions.unsubscribeFrontendMessage("UpdateDocumentArtwork"); diff --git a/frontend/src/managers/input.ts b/frontend/src/managers/input.ts index 44a0c5ba19d..21c9c5a8bc0 100644 --- a/frontend/src/managers/input.ts +++ b/frontend/src/managers/input.ts @@ -52,8 +52,8 @@ const listeners: Listener[] = [ { target: window.document, eventName: "contextmenu", action: (e: MouseEvent) => onContextMenu(e) }, { target: window.document, eventName: "fullscreenchange", action: () => fullscreenModeChanged() }, { target: window.document.body, eventName: "paste", action: (e: ClipboardEvent) => editorWrapper && onPaste(e, editorWrapper) }, - { target: window.document, eventName: "pointerlockchange", action: onPointerLockChange }, - { target: window.document, eventName: "pointerlockerror", action: onPointerLockChange }, + { target: window.document, eventName: "pointerlockchange", action: () => editorWrapper && onPointerLockChange(editorWrapper) }, + { target: window.document, eventName: "pointerlockerror", action: () => editorWrapper && onPointerLockChange(editorWrapper) }, ]; let subscriptionsRouter: SubscriptionsRouter | undefined = undefined; diff --git a/frontend/src/utility-functions/input.ts b/frontend/src/utility-functions/input.ts index e175f531b00..2192bf923cb 100644 --- a/frontend/src/utility-functions/input.ts +++ b/frontend/src/utility-functions/input.ts @@ -3,7 +3,7 @@ import type { DialogStore } from "/src/stores/dialog"; import type { DocumentStore } from "/src/stores/document"; import { toggleFullscreen } from "/src/stores/fullscreen"; import type { PortfolioStore } from "/src/stores/portfolio"; -import { softwareCursorClientPosition } from "/src/stores/software-cursor"; +import { softwareCursor, softwareCursorClientPosition } from "/src/stores/software-cursor"; import { pasteFile } from "/src/utility-functions/files"; import { makeKeyboardModifiersBitfield, textInputCleanup, getLocalizedScanCode } from "/src/utility-functions/keyboard-entry"; import { operatingSystem } from "/src/utility-functions/platform"; @@ -116,13 +116,21 @@ function isObserveOnly(e: MouseEvent): boolean { // A locked pointer stays frozen where the lock began, so report events at the software cursor function pointerEventPosition(e: MouseEvent): { x: number; y: number } { - const softwareCursor = inPointerLock ? softwareCursorClientPosition() : undefined; - return softwareCursor ?? { x: e.clientX, y: e.clientY }; + const cursorPosition = inPointerLock ? softwareCursorClientPosition() : undefined; + return cursorPosition ?? { x: e.clientX, y: e.clientY }; +} + +// A locked pointer only reports a frozen position, so its movement deltas are what go to the backend +function forwardLockedPointerDeltas(e: PointerEvent, editor: EditorWrapper): void { + if (get(softwareCursor).visible && (e.movementX !== 0 || e.movementY !== 0)) editor.appWindowPointerLockMove(e.movementX, e.movementY); } // While any pointer button is already down, additional button down events are not reported, but they are sent as `pointermove` events and these are handled in the backend export function onPointerMove(e: PointerEvent, editor: EditorWrapper, documentStore: DocumentStore) { - if (inPointerLock) return; + if (inPointerLock) { + forwardLockedPointerDeltas(e, editor); + return; + } potentiallyRestoreCanvasFocus(e); if (!e.buttons) viewportPointerInteractionOngoing = false; @@ -232,8 +240,15 @@ export function onContextMenu(e: MouseEvent) { } } -export function onPointerLockChange() { +export function onPointerLockChange(editor: EditorWrapper) { + const wasLocked = inPointerLock; inPointerLock = Boolean(window.document.pointerLockElement); + + // Losing an acquired lock mid-transform (Escape, tab switch) cancels it + if (wasLocked && !inPointerLock && get(softwareCursor).visible) { + editor.onKeyDown("Escape", 0, false); + editor.onKeyUp("Escape", 0, false); + } } // Wheel events From 8843a86138211cd6316241e3a5e4fc16dff6e69d Mon Sep 17 00:00:00 2001 From: TTyChud <100087133+TTyChud@users.noreply.github.com> Date: Sun, 4 Oct 2026 15:59:57 +0530 Subject: [PATCH 04/26] Harden the software cursor against pointer locks that never deliver movement - A granted lock that never reports movement no longer freezes the pointer: the reported position keeps driving it until a locked delta proves the deltas exist, and the pointer routes like an unlocked one until then. - The OS cursor stays hidden for the whole transform while the software cursor is drawn, instead of hover changes revealing it. - Web only: the lock is not re-requested once the viewport owns it, and the Escape that released the lock no longer cancels the transform a second time. Verified: cargo check -p graphite-editor --tests clean, cargo fmt --all -- --check clean, eslint clean and svelte-check reports no problems in the changed frontend files. --- desktop/src/app.rs | 12 ++++++--- desktop/src/input.rs | 27 +++++++++++++++---- .../src/components/panels/Document.svelte | 17 +++++++++--- frontend/src/utility-functions/input.ts | 14 +++++++--- 4 files changed, 55 insertions(+), 15 deletions(-) diff --git a/desktop/src/app.rs b/desktop/src/app.rs index 48cfe2ba251..7a85df125c8 100644 --- a/desktop/src/app.rs +++ b/desktop/src/app.rs @@ -22,7 +22,7 @@ use crate::input::InputState; use crate::persist; use crate::preferences; use crate::render::{RenderError, RenderState}; -use crate::ui::{InputEvent, UiCommand, UiInstance}; +use crate::ui::{Cursor, InputEvent, UiCommand, UiInstance}; use crate::window::Window; use crate::wrapper::messages::{DesktopFrontendMessage, DesktopWrapperMessage, IngestAction, Preferences}; use crate::wrapper::{DesktopWrapper, FolderStore, MmapResourceStorage, NodeGraphExecutionResult, WgpuContext, serialize_frontend_messages}; @@ -494,8 +494,11 @@ impl App { } } AppEvent::CursorChange(cursor) => { - if let Some(window) = &mut self.window { - window.set_cursor(event_loop, cursor); + // The software cursor owns the pointer while it's drawn, so the cursor changes that keep arriving (tool hover icons, and the ones CEF reports for the web page's CSS) must not reveal the OS cursor mid-transform + if matches!(&cursor, Cursor::None) || !self.input_state.software_cursor_active() { + if let Some(window) = &mut self.window { + window.set_cursor(event_loop, cursor); + } } } AppEvent::Exit => { @@ -720,7 +723,10 @@ impl ApplicationHandler for App { fn device_event(&mut self, _event_loop: &dyn ActiveEventLoop, _device_id: Option, event: winit::event::DeviceEvent) { if self.input_state.pointer_locked() && let winit::event::DeviceEvent::PointerMotion { delta: (x, y) } = event + // A zero delta is no movement, and it would otherwise pass for the proof that the platform delivers locked deltas + && (x != 0. || y != 0.) { + self.input_state.record_locked_delta(); // Device deltas are physical pixels; the transform layer works in logical units let scale = self.input_state.viewport_scale(); let (x, y) = if scale != 0. { (x / scale, y / scale) } else { (x, y) }; diff --git a/desktop/src/input.rs b/desktop/src/input.rs index d6bf5fd28a3..5ca13d2e172 100644 --- a/desktop/src/input.rs +++ b/desktop/src/input.rs @@ -27,6 +27,7 @@ pub(crate) struct InputState { pointer_position: PhysicalPosition, pointer_state: PointerState, software_cursor: Option, + locked_deltas: bool, click_tracker: ClickTracker, shake_tracker: ShakeTracker, } @@ -41,6 +42,7 @@ impl InputState { pointer_position: PhysicalPosition::default(), pointer_state: PointerState::Hover { route: Route::Ui }, software_cursor: None, + locked_deltas: false, click_tracker: ClickTracker::default(), shake_tracker: ShakeTracker::default(), } @@ -58,7 +60,19 @@ impl InputState { self.software_cursor = cursor; } + /// Whether the editor is currently drawing the software cursor that owns the pointer during a transform. + pub(crate) fn software_cursor_active(&self) -> bool { + self.software_cursor.is_some() + } + + /// Records that the locked pointer reported actual movement, which is the only proof that the platform honors the lock. + pub(crate) fn record_locked_delta(&mut self) { + self.locked_deltas = true; + } + pub(crate) fn lock_pointer(&mut self) { + // Until the platform proves it delivers locked movement, the reported OS position remains the pointer's source + self.locked_deltas = false; self.pointer_state = match self.pointer_state { PointerState::Hover { route } => PointerState::Locked { route, @@ -129,8 +143,9 @@ impl InputState { WindowEvent::PointerMoved { position, source, .. } => { self.pointer_position = *position; - // A locked pointer reports the OS cursor frozen at the lock origin (or warped back to it), which is not movement, so the editor follows the relative deltas instead - if self.pointer_locked() { + // A locked pointer reports the OS cursor frozen at the lock origin (or warped back to it), which is not movement, so the editor follows the relative deltas instead. + // Not every platform honors the lock it accepted (a compositor can take the grab and then never deliver locked movement), so the reported position keeps driving the pointer until a locked delta proves those deltas exist. + if self.pointer_locked() && self.locked_deltas { return; } @@ -141,9 +156,11 @@ impl InputState { next } PointerState::Stroke { route, .. } => route, - PointerState::Locked { keys, route: resume, .. } => match keys.is_empty() { - true => Route::Ui, - false => resume, + PointerState::Locked { keys, route: resume, .. } => match (self.locked_deltas, keys.is_empty()) { + // A lock that never delivered movement isn't locking anything, so the pointer routes like an unlocked one + (_, false) => resume, + (false, true) => self.route(*position), + (true, true) => Route::Ui, }, }; match route { diff --git a/frontend/src/components/panels/Document.svelte b/frontend/src/components/panels/Document.svelte index 914ce908305..d6c82685fea 100644 --- a/frontend/src/components/panels/Document.svelte +++ b/frontend/src/components/panels/Document.svelte @@ -1,5 +1,6 @@ - -{#if visible} - -{/if} - - diff --git a/frontend/src/managers/input.ts b/frontend/src/managers/input.ts index 21c9c5a8bc0..44a0c5ba19d 100644 --- a/frontend/src/managers/input.ts +++ b/frontend/src/managers/input.ts @@ -52,8 +52,8 @@ const listeners: Listener[] = [ { target: window.document, eventName: "contextmenu", action: (e: MouseEvent) => onContextMenu(e) }, { target: window.document, eventName: "fullscreenchange", action: () => fullscreenModeChanged() }, { target: window.document.body, eventName: "paste", action: (e: ClipboardEvent) => editorWrapper && onPaste(e, editorWrapper) }, - { target: window.document, eventName: "pointerlockchange", action: () => editorWrapper && onPointerLockChange(editorWrapper) }, - { target: window.document, eventName: "pointerlockerror", action: () => editorWrapper && onPointerLockChange(editorWrapper) }, + { target: window.document, eventName: "pointerlockchange", action: onPointerLockChange }, + { target: window.document, eventName: "pointerlockerror", action: onPointerLockChange }, ]; let subscriptionsRouter: SubscriptionsRouter | undefined = undefined; diff --git a/frontend/src/stores/software-cursor.ts b/frontend/src/stores/software-cursor.ts deleted file mode 100644 index bde9be3d251..00000000000 --- a/frontend/src/stores/software-cursor.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { writable } from "svelte/store"; -import type { Writable } from "svelte/store"; - -export type SoftwareCursorState = { - visible: boolean; - x: number; - y: number; -}; - -const initialState: SoftwareCursorState = { - visible: false, - x: 0, - y: 0, -}; - -// Store state persisted across HMR to maintain reactive subscriptions in the component tree -const store: Writable = import.meta.hot?.data?.store || writable(initialState); -if (import.meta.hot) import.meta.hot.data.store = store; - -export const softwareCursor = store; - -// Drawn while G/R/S wraps the pointer, positioned in viewport coordinates -export function setSoftwareCursor(cursor: SoftwareCursorState): void { - store.set(cursor); -} - -// Clears the cursor when the component drawing it unmounts mid-transform, so a stale one can't reappear on remount -export function resetSoftwareCursor(): void { - store.set(initialState); -} diff --git a/frontend/src/utility-functions/input.ts b/frontend/src/utility-functions/input.ts index 9cc54389f5f..829ad21af10 100644 --- a/frontend/src/utility-functions/input.ts +++ b/frontend/src/utility-functions/input.ts @@ -3,7 +3,6 @@ import type { DialogStore } from "/src/stores/dialog"; import type { DocumentStore } from "/src/stores/document"; import { toggleFullscreen } from "/src/stores/fullscreen"; import type { PortfolioStore } from "/src/stores/portfolio"; -import { softwareCursor } from "/src/stores/software-cursor"; import { pasteFile } from "/src/utility-functions/files"; import { makeKeyboardModifiersBitfield, textInputCleanup, getLocalizedScanCode } from "/src/utility-functions/keyboard-entry"; import { operatingSystem } from "/src/utility-functions/platform"; @@ -19,7 +18,6 @@ let viewportPointerInteractionOngoing = false; let textToolInteractiveInputElement: HTMLDivElement | undefined = undefined; let canvasFocused = true; let inPointerLock = false; -let escapeConsumed = false; let lastShakeTime = 0; const shakeSamples: { x: number; y: number; time: number }[] = []; const openFloatingMenus = new Set(); @@ -81,9 +79,6 @@ export async function shouldRedirectKeyboardEventToBackend(e: KeyboardEvent, dia } export async function onKeyDown(e: KeyboardEvent, editor: EditorWrapper, dialogStore: DialogStore) { - // The browser uses this Escape to drop the pointer lock, but it only cancels the transform if the key reaches the editor below - const escapeReleasesLock = e.code === "Escape" && inPointerLock; - const key = await getLocalizedScanCode(e); const NO_KEY_REPEAT_MODIFIER_KEYS = ["ControlLeft", "ControlRight", "ShiftLeft", "ShiftRight", "MetaLeft", "MetaRight", "AltLeft", "AltRight", "AltGraph", "CapsLock", "Fn", "FnLock"]; @@ -92,7 +87,6 @@ export async function onKeyDown(e: KeyboardEvent, editor: EditorWrapper, dialogS if (await shouldRedirectKeyboardEventToBackend(e, dialogStore)) { e.preventDefault(); const modifiers = makeKeyboardModifiersBitfield(e); - if (escapeReleasesLock) escapeConsumed = true; editor.onKeyDown(key, modifiers, e.repeat); return; } @@ -119,34 +113,8 @@ function isObserveOnly(e: MouseEvent): boolean { return import.meta.env.MODE === "native" && e.getModifierState("NumLock"); } -// The software cursor's position in client coordinates, used while the frozen OS pointer reports elsewhere -function softwareCursorClientPosition(): { x: number; y: number } | undefined { - const cursor = get(softwareCursor); - if (!cursor.visible) return undefined; - - const bounds = window.document.querySelector("[data-viewport-container]")?.getBoundingClientRect(); - if (!bounds) return undefined; - return { x: bounds.left + cursor.x, y: bounds.top + cursor.y }; -} - -// Events are reported at the software cursor, since a locked pointer is frozen -function pointerEventPosition(e: MouseEvent): { x: number; y: number } { - const cursorPosition = inPointerLock ? softwareCursorClientPosition() : undefined; - return cursorPosition ?? { x: e.clientX, y: e.clientY }; -} - -// The frozen position is useless while locked, so forward the movement deltas instead -function forwardLockedPointerDeltas(e: PointerEvent, editor: EditorWrapper): void { - if (e.movementX !== 0 || e.movementY !== 0) editor.appWindowPointerLockMove(e.movementX, e.movementY); -} - // While any pointer button is already down, additional button down events are not reported, but they are sent as `pointermove` events and these are handled in the backend export function onPointerMove(e: PointerEvent, editor: EditorWrapper, documentStore: DocumentStore) { - // Number inputs lock the pointer too, so only swallow moves while the software cursor is the thing driving it - if (inPointerLock && get(softwareCursor).visible) { - forwardLockedPointerDeltas(e, editor); - return; - } potentiallyRestoreCanvasFocus(e); if (!e.buttons) viewportPointerInteractionOngoing = false; @@ -198,8 +166,7 @@ export function onPointerDown(e: PointerEvent, editor: EditorWrapper, dialogStor if (viewportPointerInteractionOngoing && isTargetingCanvas instanceof Element) { const modifiers = makeKeyboardModifiersBitfield(e); - const { x, y } = pointerEventPosition(e); - editor.onMouseDown(x, y, e.buttons, modifiers, ...pointerAttributes(e)); + editor.onMouseDown(e.clientX, e.clientY, e.buttons, modifiers, ...pointerAttributes(e)); } } @@ -217,8 +184,7 @@ export function onPointerUp(e: PointerEvent, editor: EditorWrapper) { if (isObserveOnly(e) || textToolInteractiveInputElement) return; const modifiers = makeKeyboardModifiersBitfield(e); - const { x, y } = pointerEventPosition(e); - editor.onMouseUp(x, y, e.buttons, modifiers, ...pointerAttributes(e)); + editor.onMouseUp(e.clientX, e.clientY, e.buttons, modifiers, ...pointerAttributes(e)); } // Mouse events @@ -256,21 +222,8 @@ export function onContextMenu(e: MouseEvent) { } } -export function onPointerLockChange(editor: EditorWrapper) { - const wasLocked = inPointerLock; +export function onPointerLockChange() { inPointerLock = Boolean(window.document.pointerLockElement); - - // An Escape from before this lock can't be the one that releases it - if (inPointerLock) escapeConsumed = false; - - // Losing an acquired lock mid-transform (Escape, tab switch) cancels it, unless the Escape that released it already did - if (wasLocked && !inPointerLock) { - if (escapeConsumed) escapeConsumed = false; - else if (get(softwareCursor).visible) { - editor.onKeyDown("Escape", 0, false); - editor.onKeyUp("Escape", 0, false); - } - } } // Wheel events diff --git a/frontend/wrapper/src/editor_commands.rs b/frontend/wrapper/src/editor_commands.rs index dbad580e362..d830b1db5df 100644 --- a/frontend/wrapper/src/editor_commands.rs +++ b/frontend/wrapper/src/editor_commands.rs @@ -88,11 +88,6 @@ mod editor_commands { AppWindowMessage::PointerLock.into() } - /// Reports pointer movement while the pointer is locked - fn app_window_pointer_lock_move(x: f64, y: f64) -> Message { - AppWindowMessage::PointerLockMove { x, y }.into() - } - /// Minimizes the application window to the taskbar or dock fn app_window_minimize() -> Message { AppWindowMessage::Minimize.into() From e820ea48e42c4efc7afcf10b337faad26df9fdf5 Mon Sep 17 00:00:00 2001 From: TTyChud <100087133+TTyChud@users.noreply.github.com> Date: Tue, 6 Oct 2026 20:04:03 +0530 Subject: [PATCH 18/26] Fix the native pointer warp's tracked position and cursor reveal - Keep the tracked pointer position continuous across a warp, so a G/R/S transform no longer jumps by the viewport size each time the pointer crosses an edge. Only point the next report at the warp target. - Only hide the OS cursor when the pointer-lock grab actually succeeds, so a platform that refuses the lock isn't left with no visible cursor. - Add a regression test for the continuous tracked position. - Rename the scaling branch's mouse offsets to previous/current and drop dead scaffolding from the transform test helper. --- desktop/src/app.rs | 6 ++-- desktop/src/input.rs | 29 +++++++++++++++++-- desktop/src/window.rs | 10 +++++-- .../transform_layer_message_handler.rs | 15 ++++------ 4 files changed, 43 insertions(+), 17 deletions(-) diff --git a/desktop/src/app.rs b/desktop/src/app.rs index 2767d41799d..3121493a06e 100644 --- a/desktop/src/app.rs +++ b/desktop/src/app.rs @@ -340,9 +340,9 @@ 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 } => { diff --git a/desktop/src/input.rs b/desktop/src/input.rs index 8b1c8025016..0670df2af60 100644 --- a/desktop/src/input.rs +++ b/desktop/src/input.rs @@ -119,8 +119,7 @@ impl InputState { let warp_to = viewport.and_then(|viewport| wrap_into_viewport(wrap.position, viewport)); if let Some(wrapped) = warp_to { - wrap.position = wrapped; - // The cursor is moved to the opposite edge, so the next report is relative to it + // The cursor is moved to the opposite edge, so the next report is relative to it, but the tracked position stays continuous wrap.last_reported = wrapped; } @@ -553,4 +552,30 @@ mod test { "a zero-sized viewport should not wrap" ); } + + #[test] + fn wrapped_position_stays_continuous_across_a_wrap() { + let mut input = InputState::new(); + let viewport = viewport(); + input.set_viewport_info(viewport.x, viewport.y, viewport.width, viewport.height, viewport.scale); + input.pointer_position = PhysicalPosition::new(299., 80.); + input.set_pointer_wrap(true); + + assert_eq!( + input.wrapped_position(PhysicalPosition::new(299., 80.)), + PhysicalPosition::new(299., 80.), + "a move inside the viewport is reported as-is" + ); + + assert_eq!( + input.wrapped_position(PhysicalPosition::new(301., 80.)), + PhysicalPosition::new(301., 80.), + "crossing the right edge keeps the tracked position continuous so a transform doesn't jump" + ); + assert_eq!( + input.take_pending_warp(), + Some(PhysicalPosition::new(101., 80.)), + "the OS cursor should still be moved to the opposite edge" + ); + } } diff --git a/desktop/src/window.rs b/desktop/src/window.rs index d63b0c69cf6..81f11e178fd 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(); + // Only hide the cursor if the grab actually took, otherwise a platform that refuses the lock leaves the app with no visible cursor + if locked { + self.winit_window.set_cursor_visible(false); + } + locked } pub(crate) fn end_pointer_lock(&self) { 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 0498dd9d5a0..fe6eeea62d2 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 @@ -562,17 +562,17 @@ impl MessageHandler> for } TransformOperation::Scaling(mut scale) => { let axis_constraint = scale.constraint; - let to_mouse_final = self.mouse_position - self.state.pivot_viewport(document); - let to_mouse_final_old = input.mouse.position - self.state.pivot_viewport(document); + let to_mouse_previous = self.mouse_position - self.state.pivot_viewport(document); + let to_mouse_current = input.mouse.position - self.state.pivot_viewport(document); let to_mouse_start = self.start_mouse - self.state.pivot_viewport(document); - let to_mouse_final = self.state.project_onto_constrained(to_mouse_final, axis_constraint); - let to_mouse_final_old = self.state.project_onto_constrained(to_mouse_final_old, axis_constraint); + let to_mouse_previous = self.state.project_onto_constrained(to_mouse_previous, axis_constraint); + let to_mouse_current = self.state.project_onto_constrained(to_mouse_current, axis_constraint); let to_mouse_start = self.state.project_onto_constrained(to_mouse_start, axis_constraint); let change = { - let previous_frame_dist = to_mouse_final.dot(to_mouse_start); - let current_frame_dist = to_mouse_final_old.dot(to_mouse_start); + let previous_frame_dist = to_mouse_previous.dot(to_mouse_start); + let current_frame_dist = to_mouse_current.dot(to_mouse_start); let start_transform_dist = to_mouse_start.length_squared(); (current_frame_dist - previous_frame_dist) / start_transform_dist @@ -767,17 +767,14 @@ mod test_transform_layer { use crate::messages::portfolio::document::graph_operation::utility_types::ModifyInputsContext; use crate::messages::portfolio::document::node_graph::document_node_definitions::DefinitionIdentifier; use crate::messages::portfolio::document::utility_types::misc::GroupFolderType; - use crate::messages::prelude::Message; use crate::messages::tool::transform_layer::transform_layer_message_handler::VectorModificationType; use crate::test_utils::test_prelude::*; use glam::DAffine2; use graphene_std::vector::PointId; - use std::collections::VecDeque; async fn get_layer_transform(editor: &mut EditorTestUtils, layer: LayerNodeIdentifier) -> Option { let document = editor.active_document(); let network_interface = &document.network_interface; - let _responses: VecDeque = VecDeque::new(); let transform_node_id = ModifyInputsContext::locate_node_in_layer_chain(&DefinitionIdentifier::ProtoNode(graphene_std::transform_nodes::transform::IDENTIFIER), layer, network_interface)?; let document_node = network_interface.document_network().nodes.get(&transform_node_id)?; Some(transform_utils::get_current_transform(&document_node.inputs)) From 076fa36355fe55ca3804437eb490ac83b53174ec Mon Sep 17 00:00:00 2001 From: TTyChud <100087133+TTyChud@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:50:18 +0530 Subject: [PATCH 19/26] Address review feedback on the native pointer warp - Route pointer moves to the editor while a G/R/S wrap is active, so the transform no longer stalls once the tracked position leaves the viewport. Adds a regression test. - Have the window report whether the OS accepted the cursor move, and stop wrapping when it did not. - Rename the transform layer's stored pointer position to previous_mouse_position, drop the closure parameter it replaced, and reuse the cached document-to-viewport matrix. - Tighten the pointer wrap comments. --- desktop/src/app.rs | 12 +++---- desktop/src/input.rs | 33 ++++++++++++++++--- desktop/src/window.rs | 11 +++++-- .../transform_layer_message_handler.rs | 24 +++++++------- 4 files changed, 55 insertions(+), 25 deletions(-) diff --git a/desktop/src/app.rs b/desktop/src/app.rs index 3121493a06e..4b55eff44c0 100644 --- a/desktop/src/app.rs +++ b/desktop/src/app.rs @@ -581,7 +581,6 @@ impl ApplicationHandler for App { )); } - // A transform's wrap can't keep following the pointer once the window loses focus if matches!(event, WindowEvent::Focused(false)) { self.input_state.set_pointer_wrap(false); } @@ -592,11 +591,12 @@ impl ApplicationHandler for App { |input| self.ui.send(UiCommand::Input(input)), ); - // A wrap moved the pointer to the opposite edge of the viewport, so put the OS cursor there - if let Some(position) = self.input_state.take_pending_warp() - && let Some(window) = &self.window - { - window.set_cursor_position(position); + 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)); + // A platform that refuses to move the pointer (Wayland) gets no wrapping + if !moved { + self.input_state.set_pointer_wrap(false); + } } match event { diff --git a/desktop/src/input.rs b/desktop/src/input.rs index 0670df2af60..d07686aef49 100644 --- a/desktop/src/input.rs +++ b/desktop/src/input.rs @@ -56,7 +56,6 @@ impl InputState { self.direct_input = enabled; } - /// Starts or stops wrapping the pointer around the viewport during a G/R/S transform. pub(crate) fn set_pointer_wrap(&mut self, enabled: bool) { self.pointer_wrap = enabled.then_some(PointerWrap { position: self.pointer_position, @@ -106,7 +105,7 @@ impl InputState { matches!(self.pointer_state, PointerState::Locked { .. }) } - /// Turns the reported pointer position into a continuous one while G/R/S wraps it around the viewport. + /// Accumulates the reported window position into a continuous one while G/R/S wraps it around the viewport. fn wrapped_position(&mut self, reported: PhysicalPosition) -> PhysicalPosition { let viewport = self.viewport_info; let Some(wrap) = self.pointer_wrap.as_mut() else { return reported }; @@ -119,7 +118,7 @@ impl InputState { let warp_to = viewport.and_then(|viewport| wrap_into_viewport(wrap.position, viewport)); if let Some(wrapped) = warp_to { - // The cursor is moved to the opposite edge, so the next report is relative to it, but the tracked position stays continuous + // The next report is measured from the wrap target, but the tracked position stays continuous wrap.last_reported = wrapped; } @@ -311,6 +310,12 @@ impl InputState { } fn route(&self, position: PhysicalPosition) -> Route { + // A G/R/S wrap keeps the tracked position continuous, so it may lie outside the viewport, but the editor + // still owns pointer routing for the duration of the transform. + if self.direct_input && self.pointer_wrap.is_some() { + return Route::Editor; + } + if self.ui_captures(position) { Route::Ui } else { Route::Editor } } @@ -364,12 +369,13 @@ enum Route { Editor, } -/// Tracks the continuous pointer position while G/R/S wraps it around the viewport. +/// Tracks the continuous pointer position, in physical window coordinates, while G/R/S wraps it around the viewport. struct PointerWrap { position: PhysicalPosition, last_reported: PhysicalPosition, } +/// The viewport's bounds in physical window coordinates, and the window's scale factor. #[derive(Clone, Copy)] struct ViewportInfo { x: f64, @@ -578,4 +584,23 @@ mod test { "the OS cursor should still be moved to the opposite edge" ); } + + #[test] + fn wrapped_position_keeps_the_editor_routed_to_receive_moves() { + 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 = PhysicalPosition::new(299., 80.); + input.set_pointer_wrap(true); + + let position = input.wrapped_position(PhysicalPosition::new(301., 80.)); + assert!( + matches!(input.route(position), Route::Editor), + "the editor must keep receiving moves once the pointer wraps outside the viewport" + ); + + input.set_pointer_wrap(false); + assert!(matches!(input.route(position), Route::Ui), "without a wrap the UI captures an outside position again"); + } } diff --git a/desktop/src/window.rs b/desktop/src/window.rs index 81f11e178fd..2725c885127 100644 --- a/desktop/src/window.rs +++ b/desktop/src/window.rs @@ -200,9 +200,14 @@ impl Window { self.winit_window.set_cursor_visible(true); } - pub(crate) fn set_cursor_position(&self, position: winit::dpi::PhysicalPosition) { - if let Err(e) = self.winit_window.set_cursor_position(position.into()) { - tracing::warn!("Failed to place the cursor: {e}"); + /// Moves the OS cursor, returning whether the platform allowed it. + 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 + } } } 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 fe6eeea62d2..b9b13bf3c82 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 @@ -76,7 +76,7 @@ pub struct TransformLayerMessageHandler { slow: bool, layer_bounding_box: Quad, typing: Typing, - mouse_position: ViewportPosition, + previous_mouse_position: ViewportPosition, start_mouse: ViewportPosition, original_transforms: OriginalTransforms, pivot_gizmo: PivotGizmo, @@ -134,7 +134,7 @@ impl MessageHandler> for ); let document_to_viewport = document.metadata().document_to_viewport; - let mut begin_operation = |operation: TransformOperation, typing: &mut Typing, mouse_position: &mut DVec2, start_mouse: &mut DVec2, transform: &mut DAffine2| { + let mut begin_operation = |operation: TransformOperation, typing: &mut Typing, start_mouse: &mut DVec2, transform: &mut DAffine2| { if operation != TransformOperation::None { selected.revert_operation(); typing.clear(); @@ -191,10 +191,10 @@ impl MessageHandler> for } } - *mouse_position = input.mouse.position; + self.previous_mouse_position = input.mouse.position; *start_mouse = input.mouse.position; *transform = document_to_viewport; - self.local_mouse_start = document.metadata().document_to_viewport.inverse().transform_point2(input.mouse.position); + self.local_mouse_start = document_to_viewport.inverse().transform_point2(input.mouse.position); selected.original_transforms.clear(); @@ -344,7 +344,7 @@ impl MessageHandler> for } } TransformLayerMessage::BeginTransformOperation { operation } => { - begin_operation(self.transform_operation, &mut self.typing, &mut self.mouse_position, &mut self.start_mouse, &mut self.initial_transform); + begin_operation(self.transform_operation, &mut self.typing, &mut self.start_mouse, &mut self.initial_transform); self.transform_operation = match operation { TransformType::Grab => TransformOperation::Grabbing(Default::default()), TransformType::Rotate => TransformOperation::Rotating(Default::default()), @@ -360,7 +360,7 @@ impl MessageHandler> for self.last_point = last_point; self.handle = handle; self.grs_pen_handle = true; - self.mouse_position = input.mouse.position; + self.previous_mouse_position = input.mouse.position; self.start_mouse = input.mouse.position; let top_left = DVec2::new(last_point.x, handle.y); @@ -372,7 +372,7 @@ impl MessageHandler> for self.grab_target = document.metadata().document_to_viewport.inverse().transform_point2(handle); let pivot = last_point; self.state.document_space_pivot = document.metadata().document_to_viewport.inverse().transform_point2(pivot); - self.local_mouse_start = document.metadata().document_to_viewport.inverse().transform_point2(self.start_mouse); + self.local_mouse_start = document_to_viewport.inverse().transform_point2(self.start_mouse); self.handle = handle; // Operation-specific logic @@ -529,7 +529,7 @@ impl MessageHandler> for let old_ptz = self.ptz; self.ptz = document.document_ptz; if old_ptz != self.ptz { - self.mouse_position = input.mouse.position; + self.previous_mouse_position = input.mouse.position; return; } @@ -543,7 +543,7 @@ impl MessageHandler> for match self.transform_operation { TransformOperation::None => {} TransformOperation::Grabbing(translation) => { - let delta_pos = input.mouse.position - self.mouse_position; + let delta_pos = input.mouse.position - self.previous_mouse_position; let delta_pos = (self.initial_transform * document_to_viewport.inverse()).transform_vector2(delta_pos); let delta_viewport = if self.slow { delta_pos / SLOWING_DIVISOR } else { delta_pos }; let delta_scaled = delta_viewport / document_to_viewport.y_axis.length(); // Values are local to the viewport but scaled so values are relative to the current scale. @@ -551,7 +551,7 @@ impl MessageHandler> for self.transform_operation.apply_transform_operation(&mut selected, &self.state, document); } TransformOperation::Rotating(rotation) => { - let start_offset = self.state.pivot_viewport(document) - self.mouse_position; + let start_offset = self.state.pivot_viewport(document) - self.previous_mouse_position; let end_offset = self.state.pivot_viewport(document) - input.mouse.position; if let Some(angle) = start_offset.try_angle_to(end_offset) { let change = if self.slow { angle / SLOWING_DIVISOR } else { angle }; @@ -562,7 +562,7 @@ impl MessageHandler> for } TransformOperation::Scaling(mut scale) => { let axis_constraint = scale.constraint; - let to_mouse_previous = self.mouse_position - self.state.pivot_viewport(document); + let to_mouse_previous = self.previous_mouse_position - self.state.pivot_viewport(document); let to_mouse_current = input.mouse.position - self.state.pivot_viewport(document); let to_mouse_start = self.start_mouse - self.state.pivot_viewport(document); @@ -586,7 +586,7 @@ impl MessageHandler> for }; } - self.mouse_position = input.mouse.position; + self.previous_mouse_position = input.mouse.position; } TransformLayerMessage::SelectionChanged => { let target_layers = document.network_interface.selected_nodes().selected_visible_layers(&document.network_interface).collect(); From 157aa255e0815344a4dc83860c0ed3d1dc57a235 Mon Sep 17 00:00:00 2001 From: TTyChud <100087133+TTyChud@users.noreply.github.com> Date: Tue, 6 Oct 2026 23:02:44 +0530 Subject: [PATCH 20/26] Resume the pointer wrap when the window regains focus Losing focus suspended the wrap for good while the transform stayed active, so a drag after refocusing stopped wrapping at the viewport edge. Keep the editor's request and re-apply it when focus returns, and cover the suspend/resume and an already-ended transform with tests. --- desktop/src/app.rs | 4 ++-- desktop/src/input.rs | 48 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/desktop/src/app.rs b/desktop/src/app.rs index 4b55eff44c0..72ba0bb95f4 100644 --- a/desktop/src/app.rs +++ b/desktop/src/app.rs @@ -581,8 +581,8 @@ impl ApplicationHandler for App { )); } - if matches!(event, WindowEvent::Focused(false)) { - self.input_state.set_pointer_wrap(false); + if let WindowEvent::Focused(focused) = &event { + self.input_state.set_window_focused(*focused); } self.input_state.process( diff --git a/desktop/src/input.rs b/desktop/src/input.rs index d07686aef49..0d5f7ff512b 100644 --- a/desktop/src/input.rs +++ b/desktop/src/input.rs @@ -27,6 +27,8 @@ pub(crate) struct InputState { pointer_position: PhysicalPosition, pointer_state: PointerState, pointer_wrap: Option, + pointer_wrap_requested: bool, + window_focused: bool, pending_warp: Option>, click_tracker: ClickTracker, shake_tracker: ShakeTracker, @@ -42,6 +44,8 @@ impl InputState { pointer_position: PhysicalPosition::default(), pointer_state: PointerState::Hover { route: Route::Ui }, pointer_wrap: None, + pointer_wrap_requested: false, + window_focused: true, pending_warp: None, click_tracker: ClickTracker::default(), shake_tracker: ShakeTracker::default(), @@ -57,7 +61,18 @@ impl InputState { } pub(crate) fn set_pointer_wrap(&mut self, enabled: bool) { - self.pointer_wrap = enabled.then_some(PointerWrap { + self.pointer_wrap_requested = enabled; + self.apply_pointer_wrap(); + } + + /// Suspends wrapping while the window is unfocused and resumes the editor's request when focus returns. + pub(crate) fn set_window_focused(&mut self, focused: bool) { + self.window_focused = focused; + self.apply_pointer_wrap(); + } + + fn apply_pointer_wrap(&mut self) { + self.pointer_wrap = (self.window_focused && self.pointer_wrap_requested).then_some(PointerWrap { position: self.pointer_position, last_reported: self.pointer_position, }); @@ -603,4 +618,35 @@ mod test { 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_resumes_when_focus_returns() { + 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 = PhysicalPosition::new(299., 80.); + input.set_pointer_wrap(true); + + input.set_window_focused(false); + assert!(input.pointer_wrap.is_none(), "losing focus suspends the wrap"); + + input.set_window_focused(true); + assert!(input.pointer_wrap.is_some(), "regaining focus resumes the wrap the editor asked for"); + + let position = input.wrapped_position(PhysicalPosition::new(301., 80.)); + assert!(matches!(input.route(position), Route::Editor), "the resumed wrap still keeps the editor routed"); + } + + #[test] + fn pointer_wrap_stays_off_after_the_transform_ends() { + let mut input = InputState::new(); + input.set_pointer_wrap(true); + + input.set_window_focused(false); + input.set_pointer_wrap(false); + input.set_window_focused(true); + + assert!(input.pointer_wrap.is_none(), "regaining focus must not restart a wrap the editor has ended"); + } } From 1f709e5a6c2a1d69aecda116ccf3a05b9d6e1510 Mon Sep 17 00:00:00 2001 From: TTyChud <100087133+TTyChud@users.noreply.github.com> Date: Tue, 6 Oct 2026 23:29:19 +0530 Subject: [PATCH 21/26] Wrap from the OS cursor position The wrap decision used the tracked position, which is deliberately offset from the OS cursor after a wrap, so the cursor could leave the viewport and the transform could no longer reach the far side. Wrap from the reported position and cover the reverse-direction escape with a test. --- desktop/src/input.rs | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/desktop/src/input.rs b/desktop/src/input.rs index 0d5f7ff512b..c71734930e6 100644 --- a/desktop/src/input.rs +++ b/desktop/src/input.rs @@ -131,7 +131,7 @@ impl InputState { wrap.position.x += dx; wrap.position.y += dy; - let warp_to = viewport.and_then(|viewport| wrap_into_viewport(wrap.position, viewport)); + let warp_to = viewport.and_then(|viewport| wrap_into_viewport(reported, viewport)); if let Some(wrapped) = warp_to { // The next report is measured from the wrap target, but the tracked position stays continuous wrap.last_reported = wrapped; @@ -619,6 +619,30 @@ mod test { assert!(matches!(input.route(position), Route::Ui), "without a wrap the UI captures an outside position again"); } + #[test] + fn wrap_uses_the_os_position_not_the_tracked_one() { + let mut input = InputState::new(); + let viewport = viewport(); + input.set_viewport_info(viewport.x, viewport.y, viewport.width, viewport.height, viewport.scale); + input.pointer_position = PhysicalPosition::new(299., 80.); + input.set_pointer_wrap(true); + + input.wrapped_position(PhysicalPosition::new(301., 80.)); + assert_eq!( + input.take_pending_warp(), + Some(PhysicalPosition::new(101., 80.)), + "crossing the right edge moves the OS cursor to the left edge" + ); + + let position = input.wrapped_position(PhysicalPosition::new(99., 80.)); + assert_eq!( + input.take_pending_warp(), + Some(PhysicalPosition::new(299., 80.)), + "the OS cursor must be warped again at the left edge even though the tracked position is back inside" + ); + assert_eq!(position, PhysicalPosition::new(299., 80.)); + } + #[test] fn pointer_wrap_resumes_when_focus_returns() { let mut input = InputState::new(); From 039f894a369057c1f573dbe72ed2a39400411411 Mon Sep 17 00:00:00 2001 From: TTyChud <100087133+TTyChud@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:21:57 +0530 Subject: [PATCH 22/26] Keep the pointer wrap's position continuous across focus changes and clicks - Pause the wrap while the window is unfocused and re-base the movement when it returns, so alt-tabbing mid-transform no longer moves the layer by a viewport - Report the tracked position for clicks and window re-entry, which otherwise arrive at the warped OS cursor instead of where the drag is - Measure movement the short way around the viewport, so a report from before the OS moves the cursor is not counted as a viewport-sized jump - Reword the comments the branch adds --- desktop/src/app.rs | 2 +- desktop/src/input.rs | 225 ++++++++---------- desktop/src/window.rs | 4 +- .../src/messages/tool/tool_message_handler.rs | 2 +- 4 files changed, 108 insertions(+), 125 deletions(-) diff --git a/desktop/src/app.rs b/desktop/src/app.rs index 72ba0bb95f4..e9a09522b5d 100644 --- a/desktop/src/app.rs +++ b/desktop/src/app.rs @@ -593,7 +593,7 @@ impl ApplicationHandler for App { 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)); - // A platform that refuses to move the pointer (Wayland) gets no wrapping + // Wayland refuses to move the pointer, and then the wrap turns itself off if !moved { self.input_state.set_pointer_wrap(false); } diff --git a/desktop/src/input.rs b/desktop/src/input.rs index c71734930e6..3f617e5b7ee 100644 --- a/desktop/src/input.rs +++ b/desktop/src/input.rs @@ -27,7 +27,6 @@ pub(crate) struct InputState { pointer_position: PhysicalPosition, pointer_state: PointerState, pointer_wrap: Option, - pointer_wrap_requested: bool, window_focused: bool, pending_warp: Option>, click_tracker: ClickTracker, @@ -44,7 +43,6 @@ impl InputState { pointer_position: PhysicalPosition::default(), pointer_state: PointerState::Hover { route: Route::Ui }, pointer_wrap: None, - pointer_wrap_requested: false, window_focused: true, pending_warp: None, click_tracker: ClickTracker::default(), @@ -61,25 +59,24 @@ impl InputState { } pub(crate) fn set_pointer_wrap(&mut self, enabled: bool) { - self.pointer_wrap_requested = enabled; - self.apply_pointer_wrap(); + self.pointer_wrap = enabled.then_some(PointerWrap { + position: self.pointer_position, + last_reported: Some(self.pointer_position), + }); + self.pending_warp = None; } - /// Suspends wrapping while the window is unfocused and resumes the editor's request when focus returns. + /// Pauses the wrap while the window is unfocused, since the cursor is free to move anywhere in the meantime. pub(crate) fn set_window_focused(&mut self, focused: bool) { self.window_focused = focused; - self.apply_pointer_wrap(); - } - - fn apply_pointer_wrap(&mut self) { - self.pointer_wrap = (self.window_focused && self.pointer_wrap_requested).then_some(PointerWrap { - position: self.pointer_position, - last_reported: self.pointer_position, - }); + // The cursor may be somewhere else now, so the next report only sets a new baseline + if let Some(wrap) = &mut self.pointer_wrap { + wrap.last_reported = None; + } self.pending_warp = None; } - /// The window position the OS cursor should be moved to, if the last pointer event wrapped it. + /// The window position to move the OS cursor to, when the last pointer event wrapped it. pub(crate) fn take_pending_warp(&mut self) -> Option> { self.pending_warp.take() } @@ -120,28 +117,39 @@ impl InputState { matches!(self.pointer_state, PointerState::Locked { .. }) } - /// Accumulates the reported window position into a continuous one while G/R/S wraps it around the viewport. + /// Accumulates the reported window position into a continuous one, which is what the editor receives during a G/R/S wrap. fn wrapped_position(&mut self, reported: PhysicalPosition) -> PhysicalPosition { let viewport = self.viewport_info; let Some(wrap) = self.pointer_wrap.as_mut() else { return reported }; - let dx = reported.x - wrap.last_reported.x; - let dy = reported.y - wrap.last_reported.y; - wrap.last_reported = reported; + // The cursor can end up anywhere while the window is unfocused, so the drag holds still until it comes back + if !self.window_focused { + return wrap.position; + } + + // The report can still come from before the OS moved the cursor, so the movement is measured the short way around + let width = viewport.map_or(0., |viewport| viewport.width); + let height = viewport.map_or(0., |viewport| viewport.height); + let (dx, dy) = match wrap.last_reported { + Some(last) => (wrap_delta(reported.x - last.x, width), wrap_delta(reported.y - last.y, height)), + None => (0., 0.), // Nothing to measure from after a pause, so this report only sets the baseline + }; wrap.position.x += dx; wrap.position.y += dy; + wrap.last_reported = Some(reported); let warp_to = viewport.and_then(|viewport| wrap_into_viewport(reported, viewport)); - if let Some(wrapped) = warp_to { - // The next report is measured from the wrap target, but the tracked position stays continuous - wrap.last_reported = wrapped; - } let position = wrap.position; self.pending_warp = warp_to; position } + /// The position the editor has while a wrap runs, which stays continuous across the warp back to the other edge. + 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 } @@ -183,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; + // A wrap keeps the pointer continuous, so a click belongs where the editor's pointer is, not where the OS cursor is + let position = self.tracked_position(*position); + self.pointer_position = position; let mouse_button = button.clone().mouse_button(); let keys = match mouse_button { @@ -207,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 } => { @@ -234,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), @@ -325,8 +337,7 @@ impl InputState { } fn route(&self, position: PhysicalPosition) -> Route { - // A G/R/S wrap keeps the tracked position continuous, so it may lie outside the viewport, but the editor - // still owns pointer routing for the duration of the transform. + // During a G/R/S wrap the tracked position stays continuous and can sit outside the viewport, but pointer routing still belongs to the editor. if self.direct_input && self.pointer_wrap.is_some() { return Route::Editor; } @@ -384,13 +395,14 @@ enum Route { Editor, } -/// Tracks the continuous pointer position, in physical window coordinates, while G/R/S wraps it around the viewport. +/// Tracks the pointer position in physical window coordinates, accumulating across wraps so the editor never sees the jump back to the other edge. struct PointerWrap { position: PhysicalPosition, - last_reported: PhysicalPosition, + /// The last position the OS reported, or `None` until a report arrives after the wrap pauses and resumes + last_reported: Option>, } -/// The viewport's bounds in physical window coordinates, and the window's scale factor. +/// Where the viewport sits in the window, in physical coordinates, plus the window's scale factor. #[derive(Clone, Copy)] struct ViewportInfo { x: f64, @@ -406,7 +418,7 @@ impl ViewportInfo { } } -/// Wraps a window position into the viewport bounds, returning `None` when it is already inside. +/// Wraps a window position back into the viewport, or returns `None` when it sits inside already. fn wrap_into_viewport(position: PhysicalPosition, viewport: ViewportInfo) -> Option> { if viewport.width <= 0. || viewport.height <= 0. { return None; @@ -418,6 +430,16 @@ fn wrap_into_viewport(position: PhysicalPosition, viewport: ViewportInfo) - (wrapped != position).then_some(wrapped) } +/// Takes a movement the short way around the viewport, so a report from before the OS moved the cursor adds the real movement instead of the wrap distance. +/// shortcut: a single report that moves more than half the viewport is read as backwards. Upgrade if reports ever arrive that far apart. +fn wrap_delta(delta: f64, size: f64) -> f64 { + if size <= 0. { + return delta; + } + + (delta + size / 2.).rem_euclid(size) - size / 2. +} + #[derive(Default)] struct ClickTracker { left: ClickChains, @@ -549,128 +571,89 @@ mod test { } } + /// An input state with direct input and wrapping on, like the editor has during a G/R/S transform. + 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 should stay put"); + 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.)), - "a position past the right edge should wrap to the left" + "past the right edge wraps to the left" ); assert_eq!( wrap_into_viewport(PhysicalPosition::new(90., 80.), viewport()), Some(PhysicalPosition::new(290., 80.)), - "a position before the left edge should wrap to the right" + "past the left edge wraps to the right" ); assert_eq!( wrap_into_viewport(PhysicalPosition::new(150., 160.), viewport()), Some(PhysicalPosition::new(150., 60.)), - "a position past the bottom edge should wrap to the top" + "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 should not wrap" + "a zero-sized viewport never wraps" ); } #[test] - fn wrapped_position_stays_continuous_across_a_wrap() { - let mut input = InputState::new(); - let viewport = viewport(); - input.set_viewport_info(viewport.x, viewport.y, viewport.width, viewport.height, viewport.scale); - input.pointer_position = PhysicalPosition::new(299., 80.); - input.set_pointer_wrap(true); + fn wrap_crosses_the_edges_using_the_os_position() { + let mut input = wrapping_input(PhysicalPosition::new(299., 80.)); - assert_eq!( - input.wrapped_position(PhysicalPosition::new(299., 80.)), - PhysicalPosition::new(299., 80.), - "a move inside the viewport is reported as-is" - ); - - assert_eq!( - input.wrapped_position(PhysicalPosition::new(301., 80.)), - PhysicalPosition::new(301., 80.), - "crossing the right edge keeps the tracked position continuous so a transform doesn't jump" - ); + 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.)), - "the OS cursor should still be moved to the opposite edge" + "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"); - #[test] - fn wrapped_position_keeps_the_editor_routed_to_receive_moves() { - 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 = PhysicalPosition::new(299., 80.); - input.set_pointer_wrap(true); - - let position = input.wrapped_position(PhysicalPosition::new(301., 80.)); - assert!( - matches!(input.route(position), Route::Editor), - "the editor must keep receiving moves once the pointer wraps outside the viewport" - ); + // The tracked position is back inside the viewport, but the OS cursor crossed the left edge, so warp it again + 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 wrap_uses_the_os_position_not_the_tracked_one() { - let mut input = InputState::new(); - let viewport = viewport(); - input.set_viewport_info(viewport.x, viewport.y, viewport.width, viewport.height, viewport.scale); - input.pointer_position = PhysicalPosition::new(299., 80.); - input.set_pointer_wrap(true); + 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.wrapped_position(PhysicalPosition::new(301., 80.)); + // The cursor can end up anywhere while the app is unfocused, so the drag holds still instead of following it + input.set_window_focused(false); + assert_eq!(input.wrapped_position(PhysicalPosition::new(150., 80.)), tracked, "an unfocused window ignores the cursor"); assert_eq!( - input.take_pending_warp(), - Some(PhysicalPosition::new(101., 80.)), - "crossing the right edge moves the OS cursor to the left edge" + 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"); - let position = input.wrapped_position(PhysicalPosition::new(99., 80.)); + // Focus returns wherever the cursor is now, so the first report only sets a new baseline + input.set_window_focused(true); assert_eq!( - input.take_pending_warp(), - Some(PhysicalPosition::new(299., 80.)), - "the OS cursor must be warped again at the left edge even though the tracked position is back inside" + 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" ); - assert_eq!(position, PhysicalPosition::new(299., 80.)); - } - - #[test] - fn pointer_wrap_resumes_when_focus_returns() { - 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 = PhysicalPosition::new(299., 80.); - input.set_pointer_wrap(true); - - input.set_window_focused(false); - assert!(input.pointer_wrap.is_none(), "losing focus suspends the wrap"); - - input.set_window_focused(true); - assert!(input.pointer_wrap.is_some(), "regaining focus resumes the wrap the editor asked for"); - - let position = input.wrapped_position(PhysicalPosition::new(301., 80.)); - assert!(matches!(input.route(position), Route::Editor), "the resumed wrap still keeps the editor routed"); - } - - #[test] - fn pointer_wrap_stays_off_after_the_transform_ends() { - let mut input = InputState::new(); - input.set_pointer_wrap(true); - - input.set_window_focused(false); - input.set_pointer_wrap(false); - input.set_window_focused(true); - - assert!(input.pointer_wrap.is_none(), "regaining focus must not restart a wrap the editor has ended"); } } diff --git a/desktop/src/window.rs b/desktop/src/window.rs index 2725c885127..f8cf3b051fb 100644 --- a/desktop/src/window.rs +++ b/desktop/src/window.rs @@ -188,7 +188,7 @@ impl Window { pub(crate) fn start_pointer_lock(&self) -> bool { let locked = self.winit_window.set_cursor_grab(winit::window::CursorGrabMode::Locked).is_ok(); - // Only hide the cursor if the grab actually took, otherwise a platform that refuses the lock leaves the app with no visible cursor + // Hide the cursor only when the grab took, since a platform that refuses the lock would otherwise leave the user without one if locked { self.winit_window.set_cursor_visible(false); } @@ -200,7 +200,7 @@ impl Window { self.winit_window.set_cursor_visible(true); } - /// Moves the OS cursor, returning whether the platform allowed it. + /// Moves the OS cursor, returning false when the platform refuses to move it. pub(crate) fn set_cursor_position(&self, position: winit::dpi::PhysicalPosition) -> bool { match self.winit_window.set_cursor_position(position.into()) { Ok(()) => true, diff --git a/editor/src/messages/tool/tool_message_handler.rs b/editor/src/messages/tool/tool_message_handler.rs index d6b9f95d6b5..b9e7b771cdb 100644 --- a/editor/src/messages/tool/tool_message_handler.rs +++ b/editor/src/messages/tool/tool_message_handler.rs @@ -188,7 +188,7 @@ impl MessageHandler> for ToolMessageHandler send: Box::new(TransformLayerMessage::SelectionChanged.into()), }); - // A running transform can outlive the tools it started under, so end its pointer wrap with them + // A transform can outlive the tools that started it, so end it here, and its pointer wrap goes with it if self.transform_layer_handler.is_transforming() { responses.add(TransformLayerMessage::CancelTransformOperation); } From c5c1d195ca3fee0e066c716e81973deeb4b77260 Mon Sep 17 00:00:00 2001 From: TTyChud <100087133+TTyChud@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:21:58 +0530 Subject: [PATCH 23/26] Drop the transform handler's unrelated rename from the pointer wrap Only the four PointerWrap responses belong to the wrap here, so the field and offset renames, and the test scaffolding, stay as master has them. --- .../transform_layer_message_handler.rs | 37 ++++++++++--------- 1 file changed, 20 insertions(+), 17 deletions(-) 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 b9b13bf3c82..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 @@ -76,7 +76,7 @@ pub struct TransformLayerMessageHandler { slow: bool, layer_bounding_box: Quad, typing: Typing, - previous_mouse_position: ViewportPosition, + mouse_position: ViewportPosition, start_mouse: ViewportPosition, original_transforms: OriginalTransforms, pivot_gizmo: PivotGizmo, @@ -134,7 +134,7 @@ impl MessageHandler> for ); let document_to_viewport = document.metadata().document_to_viewport; - let mut begin_operation = |operation: TransformOperation, typing: &mut Typing, start_mouse: &mut DVec2, transform: &mut DAffine2| { + let mut begin_operation = |operation: TransformOperation, typing: &mut Typing, mouse_position: &mut DVec2, start_mouse: &mut DVec2, transform: &mut DAffine2| { if operation != TransformOperation::None { selected.revert_operation(); typing.clear(); @@ -191,10 +191,10 @@ impl MessageHandler> for } } - self.previous_mouse_position = input.mouse.position; + *mouse_position = input.mouse.position; *start_mouse = input.mouse.position; *transform = document_to_viewport; - self.local_mouse_start = document_to_viewport.inverse().transform_point2(input.mouse.position); + self.local_mouse_start = document.metadata().document_to_viewport.inverse().transform_point2(input.mouse.position); selected.original_transforms.clear(); @@ -344,7 +344,7 @@ impl MessageHandler> for } } TransformLayerMessage::BeginTransformOperation { operation } => { - begin_operation(self.transform_operation, &mut self.typing, &mut self.start_mouse, &mut self.initial_transform); + begin_operation(self.transform_operation, &mut self.typing, &mut self.mouse_position, &mut self.start_mouse, &mut self.initial_transform); self.transform_operation = match operation { TransformType::Grab => TransformOperation::Grabbing(Default::default()), TransformType::Rotate => TransformOperation::Rotating(Default::default()), @@ -360,7 +360,7 @@ impl MessageHandler> for self.last_point = last_point; self.handle = handle; self.grs_pen_handle = true; - self.previous_mouse_position = input.mouse.position; + self.mouse_position = input.mouse.position; self.start_mouse = input.mouse.position; let top_left = DVec2::new(last_point.x, handle.y); @@ -372,7 +372,7 @@ impl MessageHandler> for self.grab_target = document.metadata().document_to_viewport.inverse().transform_point2(handle); let pivot = last_point; self.state.document_space_pivot = document.metadata().document_to_viewport.inverse().transform_point2(pivot); - self.local_mouse_start = document_to_viewport.inverse().transform_point2(self.start_mouse); + self.local_mouse_start = document.metadata().document_to_viewport.inverse().transform_point2(self.start_mouse); self.handle = handle; // Operation-specific logic @@ -529,7 +529,7 @@ impl MessageHandler> for let old_ptz = self.ptz; self.ptz = document.document_ptz; if old_ptz != self.ptz { - self.previous_mouse_position = input.mouse.position; + self.mouse_position = input.mouse.position; return; } @@ -543,7 +543,7 @@ impl MessageHandler> for match self.transform_operation { TransformOperation::None => {} TransformOperation::Grabbing(translation) => { - let delta_pos = input.mouse.position - self.previous_mouse_position; + let delta_pos = input.mouse.position - self.mouse_position; let delta_pos = (self.initial_transform * document_to_viewport.inverse()).transform_vector2(delta_pos); let delta_viewport = if self.slow { delta_pos / SLOWING_DIVISOR } else { delta_pos }; let delta_scaled = delta_viewport / document_to_viewport.y_axis.length(); // Values are local to the viewport but scaled so values are relative to the current scale. @@ -551,7 +551,7 @@ impl MessageHandler> for self.transform_operation.apply_transform_operation(&mut selected, &self.state, document); } TransformOperation::Rotating(rotation) => { - let start_offset = self.state.pivot_viewport(document) - self.previous_mouse_position; + let start_offset = self.state.pivot_viewport(document) - self.mouse_position; let end_offset = self.state.pivot_viewport(document) - input.mouse.position; if let Some(angle) = start_offset.try_angle_to(end_offset) { let change = if self.slow { angle / SLOWING_DIVISOR } else { angle }; @@ -562,17 +562,17 @@ impl MessageHandler> for } TransformOperation::Scaling(mut scale) => { let axis_constraint = scale.constraint; - let to_mouse_previous = self.previous_mouse_position - self.state.pivot_viewport(document); - let to_mouse_current = input.mouse.position - self.state.pivot_viewport(document); + let to_mouse_final = self.mouse_position - self.state.pivot_viewport(document); + let to_mouse_final_old = input.mouse.position - self.state.pivot_viewport(document); let to_mouse_start = self.start_mouse - self.state.pivot_viewport(document); - let to_mouse_previous = self.state.project_onto_constrained(to_mouse_previous, axis_constraint); - let to_mouse_current = self.state.project_onto_constrained(to_mouse_current, axis_constraint); + let to_mouse_final = self.state.project_onto_constrained(to_mouse_final, axis_constraint); + let to_mouse_final_old = self.state.project_onto_constrained(to_mouse_final_old, axis_constraint); let to_mouse_start = self.state.project_onto_constrained(to_mouse_start, axis_constraint); let change = { - let previous_frame_dist = to_mouse_previous.dot(to_mouse_start); - let current_frame_dist = to_mouse_current.dot(to_mouse_start); + let previous_frame_dist = to_mouse_final.dot(to_mouse_start); + let current_frame_dist = to_mouse_final_old.dot(to_mouse_start); let start_transform_dist = to_mouse_start.length_squared(); (current_frame_dist - previous_frame_dist) / start_transform_dist @@ -586,7 +586,7 @@ impl MessageHandler> for }; } - self.previous_mouse_position = input.mouse.position; + self.mouse_position = input.mouse.position; } TransformLayerMessage::SelectionChanged => { let target_layers = document.network_interface.selected_nodes().selected_visible_layers(&document.network_interface).collect(); @@ -767,14 +767,17 @@ mod test_transform_layer { use crate::messages::portfolio::document::graph_operation::utility_types::ModifyInputsContext; use crate::messages::portfolio::document::node_graph::document_node_definitions::DefinitionIdentifier; use crate::messages::portfolio::document::utility_types::misc::GroupFolderType; + use crate::messages::prelude::Message; use crate::messages::tool::transform_layer::transform_layer_message_handler::VectorModificationType; use crate::test_utils::test_prelude::*; use glam::DAffine2; use graphene_std::vector::PointId; + use std::collections::VecDeque; async fn get_layer_transform(editor: &mut EditorTestUtils, layer: LayerNodeIdentifier) -> Option { let document = editor.active_document(); let network_interface = &document.network_interface; + let _responses: VecDeque = VecDeque::new(); let transform_node_id = ModifyInputsContext::locate_node_in_layer_chain(&DefinitionIdentifier::ProtoNode(graphene_std::transform_nodes::transform::IDENTIFIER), layer, network_interface)?; let document_node = network_interface.document_network().nodes.get(&transform_node_id)?; Some(transform_utils::get_current_transform(&document_node.inputs)) From aae9246bdff21b72dbbbfd482cee7ef862e44c46 Mon Sep 17 00:00:00 2001 From: TTyChud <100087133+TTyChud@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:08:38 +0530 Subject: [PATCH 24/26] Use vector maths for the wrap and tighten its viewport size guard - Take and return `glam::DVec2` movements in `wrap_delta`, so the wrap needs no component-wise tuples - Document that the position the wrap tracks is in the OS's physical window space - Assert the viewport has a non-negative size, leaving the guard for the zero-sized viewport that has nothing to wrap around --- desktop/src/input.rs | 32 ++++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/desktop/src/input.rs b/desktop/src/input.rs index 3f617e5b7ee..5fae2aeee29 100644 --- a/desktop/src/input.rs +++ b/desktop/src/input.rs @@ -118,6 +118,7 @@ impl InputState { } /// Accumulates the reported window position into a continuous one, which is what the editor receives during a G/R/S wrap. + /// `reported` is a physical window position as the OS reports it, and the position returned 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 }; @@ -128,14 +129,13 @@ impl InputState { } // The report can still come from before the OS moved the cursor, so the movement is measured the short way around - let width = viewport.map_or(0., |viewport| viewport.width); - let height = viewport.map_or(0., |viewport| viewport.height); - let (dx, dy) = match wrap.last_reported { - Some(last) => (wrap_delta(reported.x - last.x, width), wrap_delta(reported.y - last.y, height)), - None => (0., 0.), // Nothing to measure from after a pause, so this report only sets the baseline + 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 after a pause, so this report only sets the baseline }; - wrap.position.x += dx; - wrap.position.y += dy; + 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)); @@ -420,7 +420,9 @@ impl ViewportInfo { /// Wraps a window position back into the viewport, or returns `None` when it sits inside already. fn wrap_into_viewport(position: PhysicalPosition, viewport: ViewportInfo) -> Option> { - if viewport.width <= 0. || viewport.height <= 0. { + assert!(viewport.width >= 0. && viewport.height >= 0., "a viewport never has a negative size"); + // A viewport without an area yet has nothing to wrap around + if viewport.width == 0. || viewport.height == 0. { return None; } @@ -432,12 +434,18 @@ fn wrap_into_viewport(position: PhysicalPosition, viewport: ViewportInfo) - /// Takes a movement the short way around the viewport, so a report from before the OS moved the cursor adds the real movement instead of the wrap distance. /// shortcut: a single report that moves more than half the viewport is read as backwards. Upgrade if reports ever arrive that far apart. -fn wrap_delta(delta: f64, size: f64) -> f64 { - if size <= 0. { - return delta; +fn wrap_delta(delta: glam::DVec2, size: glam::DVec2) -> glam::DVec2 { + if size.min_element() <= 0. { + return delta; // Nothing to wrap around until the viewport has a size } - (delta + size / 2.).rem_euclid(size) - size / 2. + let half = size / 2.; + (delta + half).rem_euclid(size) - half +} + +/// The physical window position as a vector, so the wrap's maths stays component-wise free. +fn to_dvec(position: PhysicalPosition) -> glam::DVec2 { + glam::DVec2::new(position.x, position.y) } #[derive(Default)] From 7f09dbfc99da29a4fcd555dabc01a4dc29a071a5 Mon Sep 17 00:00:00 2001 From: TTyChud <100087133+TTyChud@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:25:40 +0530 Subject: [PATCH 25/26] Cut the comments from the wrap tests The test names and assertion messages already say what each case covers. --- desktop/src/input.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/desktop/src/input.rs b/desktop/src/input.rs index 5fae2aeee29..8580e263905 100644 --- a/desktop/src/input.rs +++ b/desktop/src/input.rs @@ -579,7 +579,6 @@ mod test { } } - /// An input state with direct input and wrapping on, like the editor has during a G/R/S transform. fn wrapping_input(position: PhysicalPosition) -> InputState { let mut input = InputState::new(); let viewport = viewport(); @@ -628,7 +627,6 @@ mod test { ); assert!(matches!(input.route(position), Route::Editor), "the editor keeps receiving moves once the pointer wraps"); - // The tracked position is back inside the viewport, but the OS cursor crossed the left edge, so warp it again assert_eq!(input.wrapped_position(PhysicalPosition::new(99., 80.)), PhysicalPosition::new(299., 80.)); assert_eq!(input.take_pending_warp(), Some(PhysicalPosition::new(299., 80.))); @@ -641,7 +639,6 @@ mod test { let mut input = wrapping_input(PhysicalPosition::new(299., 80.)); let tracked = input.wrapped_position(PhysicalPosition::new(301., 80.)); - // The cursor can end up anywhere while the app is unfocused, so the drag holds still instead of following it input.set_window_focused(false); assert_eq!(input.wrapped_position(PhysicalPosition::new(150., 80.)), tracked, "an unfocused window ignores the cursor"); assert_eq!( @@ -651,7 +648,6 @@ mod test { ); assert_eq!(input.take_pending_warp(), None, "the cursor stays put while the window is unfocused"); - // Focus returns wherever the cursor is now, so the first report only sets a new baseline input.set_window_focused(true); assert_eq!( input.wrapped_position(PhysicalPosition::new(170., 80.)), From 68abc7595fc1b0c3f746f7efd729c43f9d0fe8ac Mon Sep 17 00:00:00 2001 From: TTyChud <100087133+TTyChud@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:44:37 +0530 Subject: [PATCH 26/26] Shorten the comments the wrap adds --- desktop/src/app.rs | 2 +- desktop/src/input.rs | 40 +++++++++---------- desktop/src/window.rs | 4 +- .../src/messages/tool/tool_message_handler.rs | 2 +- 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/desktop/src/app.rs b/desktop/src/app.rs index e9a09522b5d..63f4f81b4ce 100644 --- a/desktop/src/app.rs +++ b/desktop/src/app.rs @@ -593,7 +593,7 @@ impl ApplicationHandler for App { 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, and then the wrap turns itself off + // Wayland refuses to move the pointer, so the wrap turns itself off if !moved { self.input_state.set_pointer_wrap(false); } diff --git a/desktop/src/input.rs b/desktop/src/input.rs index 8580e263905..63b23a45f80 100644 --- a/desktop/src/input.rs +++ b/desktop/src/input.rs @@ -66,17 +66,17 @@ impl InputState { self.pending_warp = None; } - /// Pauses the wrap while the window is unfocused, since the cursor is free to move anywhere in the meantime. + /// Pauses the wrap while the window is unfocused. pub(crate) fn set_window_focused(&mut self, focused: bool) { self.window_focused = focused; - // The cursor may be somewhere else now, so the next report only sets a new baseline + // 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; } - /// The window position to move the OS cursor to, when the last pointer event wrapped it. + /// 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() } @@ -117,22 +117,22 @@ impl InputState { matches!(self.pointer_state, PointerState::Locked { .. }) } - /// Accumulates the reported window position into a continuous one, which is what the editor receives during a G/R/S wrap. - /// `reported` is a physical window position as the OS reports it, and the position returned is in that same space. + /// 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 }; - // The cursor can end up anywhere while the window is unfocused, so the drag holds still until it comes back + // Hold the drag still while the window is unfocused if !self.window_focused { return wrap.position; } - // The report can still come from before the OS moved the cursor, so the movement is measured the short way around + // 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 after a pause, so this report only sets the baseline + None => glam::DVec2::ZERO, // Nothing to measure from, so this sets the baseline }; wrap.position.x += delta.x; wrap.position.y += delta.y; @@ -145,7 +145,7 @@ impl InputState { position } - /// The position the editor has while a wrap runs, which stays continuous across the warp back to the other edge. + /// 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) } @@ -202,7 +202,7 @@ impl InputState { } WindowEvent::PointerLeft { position: None, .. } => ui_callback(InputEvent::pointer().exited().modifiers(self.modifiers).build()), WindowEvent::PointerButton { state, button, position, .. } => { - // A wrap keeps the pointer continuous, so a click belongs where the editor's pointer is, not where the OS cursor is + // 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; @@ -337,7 +337,7 @@ impl InputState { } fn route(&self, position: PhysicalPosition) -> Route { - // During a G/R/S wrap the tracked position stays continuous and can sit outside the viewport, but pointer routing still belongs to the editor. + // 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; } @@ -395,14 +395,14 @@ enum Route { Editor, } -/// Tracks the pointer position in physical window coordinates, accumulating across wraps so the editor never sees the jump back to the other edge. +/// 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` until a report arrives after the wrap pauses and resumes + /// 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, plus the window's scale factor. +/// Where the viewport sits in the window, in physical coordinates. #[derive(Clone, Copy)] struct ViewportInfo { x: f64, @@ -418,10 +418,10 @@ impl ViewportInfo { } } -/// Wraps a window position back into the viewport, or returns `None` when it sits inside already. +/// 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"); - // A viewport without an area yet has nothing to wrap around + // Nothing to wrap around until the viewport has an area if viewport.width == 0. || viewport.height == 0. { return None; } @@ -432,18 +432,18 @@ fn wrap_into_viewport(position: PhysicalPosition, viewport: ViewportInfo) - (wrapped != position).then_some(wrapped) } -/// Takes a movement the short way around the viewport, so a report from before the OS moved the cursor adds the real movement instead of the wrap distance. -/// shortcut: a single report that moves more than half the viewport is read as backwards. Upgrade if reports ever arrive that far apart. +/// 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; // Nothing to wrap around until the viewport has a size + return delta; } let half = size / 2.; (delta + half).rem_euclid(size) - half } -/// The physical window position as a vector, so the wrap's maths stays component-wise free. +/// The physical window position as a vector. fn to_dvec(position: PhysicalPosition) -> glam::DVec2 { glam::DVec2::new(position.x, position.y) } diff --git a/desktop/src/window.rs b/desktop/src/window.rs index f8cf3b051fb..48c90b8c8dc 100644 --- a/desktop/src/window.rs +++ b/desktop/src/window.rs @@ -188,7 +188,7 @@ impl Window { 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, since a platform that refuses the lock would otherwise leave the user without one + // 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); } @@ -200,7 +200,7 @@ impl Window { self.winit_window.set_cursor_visible(true); } - /// Moves the OS cursor, returning false when the platform refuses to move it. + /// 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, diff --git a/editor/src/messages/tool/tool_message_handler.rs b/editor/src/messages/tool/tool_message_handler.rs index b9e7b771cdb..960aed6049a 100644 --- a/editor/src/messages/tool/tool_message_handler.rs +++ b/editor/src/messages/tool/tool_message_handler.rs @@ -188,7 +188,7 @@ impl MessageHandler> for ToolMessageHandler send: Box::new(TransformLayerMessage::SelectionChanged.into()), }); - // A transform can outlive the tools that started it, so end it here, and its pointer wrap goes with it + // A transform can outlive the tools that started it, so end it here if self.transform_layer_handler.is_transforming() { responses.add(TransformLayerMessage::CancelTransformOperation); }