From 9f5bb2f807af3e20edc4a6bd50b27ce391c1e062 Mon Sep 17 00:00:00 2001 From: Himanshu121865 Date: Sat, 12 Sep 2026 18:26:16 +0530 Subject: [PATCH 1/8] Implement viewport cursor-wrap for G/R/S transforms Route pointer-lock deltas through the transform layer so only G/R/S consumes them, keep the wrapped position across chained operations, wrap the software cursor at the viewport edges, and cancel the transform when pointer lock ends. Re-emit the active tool's cursor on unlock. --- desktop/src/app.rs | 54 +++++- desktop/src/input.rs | 4 +- .../wrapper/src/intercept_frontend_message.rs | 3 + desktop/wrapper/src/messages.rs | 1 + .../messages/app_window/app_window_message.rs | 2 + .../app_window/app_window_message_handler.rs | 6 + .../src/messages/frontend/frontend_message.rs | 7 + .../transform_layer_message.rs | 1 + .../transform_layer_message_handler.rs | 183 ++++++++++++++++-- .../src/components/panels/Document.svelte | 54 ++++++ .../components/panels/SoftwareCursor.svelte | 22 +++ frontend/src/utility-functions/input.ts | 1 + frontend/wrapper/src/editor_commands.rs | 5 + 13 files changed, 321 insertions(+), 22 deletions(-) create mode 100644 frontend/src/components/panels/SoftwareCursor.svelte diff --git a/desktop/src/app.rs b/desktop/src/app.rs index b89d1a5a964..4b3e9f140e4 100644 --- a/desktop/src/app.rs +++ b/desktop/src/app.rs @@ -23,7 +23,7 @@ use crate::preferences; use crate::render::{RenderError, RenderState}; use crate::ui::{InputEvent, UiCommand, UiInstance}; use crate::window::Window; -use crate::wrapper::messages::{DesktopFrontendMessage, DesktopWrapperMessage, Preferences}; +use crate::wrapper::messages::{DesktopFrontendMessage, DesktopWrapperMessage, InputMessage, Key, ModifierKeys, Preferences}; use crate::wrapper::{DesktopWrapper, MmapResourceStorage, NodeGraphExecutionResult, WgpuContext, serialize_frontend_messages}; pub(crate) struct App { @@ -352,6 +352,9 @@ impl App { window.start_pointer_lock(); } } + DesktopFrontendMessage::PointerUnlock => { + self.unlock_pointer(); + } DesktopFrontendMessage::WindowClose => { self.app_event_scheduler.schedule(AppEvent::Exit); } @@ -509,6 +512,34 @@ impl App { } } } + + fn unlock_pointer(&mut self) { + if let Some(restore) = self.input_state.unlock_pointer() { + if let Some(window) = &self.window { + window.end_pointer_lock(); + } + self.ui + .send(UiCommand::Input(InputEvent::pointer().position(restore).moved().modifiers(self.input_state.modifiers()).build())); + } + } + + /// Synthesizes an Escape press and release to cancel the active transform, as a user pressing Escape would. + fn send_cancel_escape(&mut self) { + for message in [ + InputMessage::KeyDown { + key: Key::Escape, + key_repeat: false, + modifier_keys: ModifierKeys::empty(), + }, + InputMessage::KeyUp { + key: Key::Escape, + key_repeat: false, + modifier_keys: ModifierKeys::empty(), + }, + ] { + self.app_event_scheduler.schedule(AppEvent::DesktopWrapperMessage(DesktopWrapperMessage::Input(message))); + } + } } impl ApplicationHandler for App { fn can_create_surfaces(&mut self, event_loop: &dyn ActiveEventLoop) { @@ -539,20 +570,22 @@ impl ApplicationHandler for App { } fn window_event(&mut self, _event_loop: &dyn ActiveEventLoop, _window_id: WindowId, event: WindowEvent) { - // Handle pointer lock release if let WindowEvent::PointerButton { state: ElementState::Released, 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(); + } + + // The window lost focus while pointer-locked: cancel the transform and release the grab + if let WindowEvent::Focused(false) = &event + && self.input_state.pointer_locked() + { + self.unlock_pointer(); + self.send_cancel_escape(); } self.input_state.process( @@ -641,6 +674,9 @@ impl ApplicationHandler for App { if self.input_state.pointer_locked() && let winit::event::DeviceEvent::PointerMotion { delta: (x, y) } = event { + // Device deltas are in physical pixels; convert them to logical viewport 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 731f43fdba1..c45955899cb 100644 --- a/desktop/src/input.rs +++ b/desktop/src/input.rs @@ -259,7 +259,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) } @@ -280,7 +280,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/wrapper/src/intercept_frontend_message.rs b/desktop/wrapper/src/intercept_frontend_message.rs index 6d13e4bc4c6..0ba146adf19 100644 --- a/desktop/wrapper/src/intercept_frontend_message.rs +++ b/desktop/wrapper/src/intercept_frontend_message.rs @@ -122,6 +122,9 @@ pub(super) fn intercept_frontend_message(dispatcher: &mut DesktopWrapperMessageD FrontendMessage::WindowPointerLock => { dispatcher.respond(DesktopFrontendMessage::PointerLock); } + FrontendMessage::WindowPointerUnlock => { + dispatcher.respond(DesktopFrontendMessage::PointerUnlock); + } FrontendMessage::WindowUpdateDirectInput { enabled } => { dispatcher.respond(DesktopFrontendMessage::WindowUpdateDirectInput { enabled }); } diff --git a/desktop/wrapper/src/messages.rs b/desktop/wrapper/src/messages.rs index 0559e02f36a..09b4732b1b3 100644 --- a/desktop/wrapper/src/messages.rs +++ b/desktop/wrapper/src/messages.rs @@ -70,6 +70,7 @@ pub enum DesktopFrontendMessage { content: String, }, PointerLock, + PointerUnlock, 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..e109b222b8c 100644 --- a/editor/src/messages/app_window/app_window_message.rs +++ b/editor/src/messages/app_window/app_window_message.rs @@ -4,6 +4,8 @@ use crate::messages::prelude::*; #[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)] pub enum AppWindowMessage { PointerLock, + PointerUnlock, + // Relative pointer movement in logical viewport units (platforms divide physical deltas by the viewport scale) 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..15cdf35ee25 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,14 @@ impl MessageHandler for AppWindowMessageHandler { #[cfg(not(target_family = "wasm"))] responses.add(FrontendMessage::WindowPointerLock); } + AppWindowMessage::PointerUnlock => { + #[cfg(not(target_family = "wasm"))] + responses.add(FrontendMessage::WindowPointerUnlock); + } AppWindowMessage::PointerLockMove { x, y } => { responses.add(FrontendMessage::WindowPointerLockMove { position: (x, y) }); + // Send locked deltas only to the transform layer, 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 29daaa269b9..5c39f5affd2 100644 --- a/editor/src/messages/frontend/frontend_message.rs +++ b/editor/src/messages/frontend/frontend_message.rs @@ -350,6 +350,13 @@ pub enum FrontendMessage { position: (f64, f64), }, #[cfg(not(target_family = "wasm"))] + WindowPointerUnlock, + 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..af44d5513b5 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,12 @@ pub struct TransformLayerMessageHandler { // Path tool (ghost outlines showing pre-transform geometry) ghost_outline: Vec<(Vec, DAffine2)>, + + // Software cursor 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, } #[message_handler_data] @@ -190,11 +197,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 +345,7 @@ impl MessageHandler> for responses.add(OverlaysMessage::RemoveProvider { provider: TRANSFORM_GRS_OVERLAY_PROVIDER, }); + self.disable_software_cursor(responses); } } TransformLayerMessage::BeginTransformOperation { operation } => { @@ -358,8 +365,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 when the pointer is already locked + 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 +394,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 +482,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 +522,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,11 +533,14 @@ impl MessageHandler> for self.transform_operation.grs_typed(self.typing.evaluate(), &mut selected, &self.state, document); } TransformLayerMessage::PointerMove { slow_key, increments_key } => { + // Use a pending locked delta if there is one, otherwise the absolute pointer position + let mouse_position = self.pointer_lock_delta.take().unwrap_or(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 { - self.mouse_position = input.mouse.position; + self.mouse_position = mouse_position; return; } @@ -538,7 +554,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 +563,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 +574,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 +597,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 } => { + // Locked deltas only matter while a G/R/S transform owns the pointer + if self.software_cursor_active { + 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 +689,31 @@ 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 during pointer lock, so seed the tracking position here + self.mouse_position = 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; + responses.add(FrontendMessage::UpdateSoftwareCursor { visible: false, x: 0., y: 0. }); + // Let the active tool re-emit its own cursor instead of resetting to a default + responses.add(ToolMessage::UpdateCursor); + responses.add(AppWindowMessage::PointerUnlock); + } + 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 +732,15 @@ impl TransformLayerMessageHandler { } } +/// Wraps a software cursor position into the viewport bounds, or returns it unchanged if the viewport has no size. +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 +1368,93 @@ 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_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; + } } diff --git a/frontend/src/components/panels/Document.svelte b/frontend/src/components/panels/Document.svelte index e80ed12fe0a..eada97137f9 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"; @@ -75,6 +76,30 @@ 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 { + // Ignore failures before the wrapper is ready + } + } + + function handleSoftwareCursorPointerLockChange() { + // The browser can end pointer lock on its own (Escape, alt-tab), which would otherwise leave the transform stuck running + 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 +545,31 @@ updateMouseCursor(data.cursor); }); + // Software cursor that wraps the pointer around the viewport during G/R/S transforms + subscriptions.subscribeFrontendMessage("UpdateSoftwareCursor", async (data) => { + await tick(); + + softwareCursorVisible = data.visible; + softwareCursorX = data.x; + softwareCursorY = data.y; + + if (!isWeb) return; + + // Browsers reject a re-lock request shortly after an unlock, so retry on each update + if (data.visible && viewport && window.document.pointerLockElement !== viewport) { + try { + viewport.requestPointerLock?.().catch(() => undefined); + } catch { + // Retried on the next update + } + } else if (!data.visible && window.document.pointerLockElement === viewport) { + window.document.exitPointerLock(); + } + }); + + window.addEventListener("pointermove", handleSoftwareCursorWebMove); + window.document.addEventListener("pointerlockchange", handleSoftwareCursorPointerLockChange); + // Text entry subscriptions.subscribeFrontendMessage("TriggerTextCommit", async () => { await tick(); @@ -567,6 +617,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 +627,7 @@ subscriptions.unsubscribeFrontendMessage("UpdateDocumentScrollbars"); subscriptions.unsubscribeFrontendMessage("UpdateDocumentRulers"); subscriptions.unsubscribeFrontendMessage("UpdateMouseCursor"); + subscriptions.unsubscribeFrontendMessage("UpdateSoftwareCursor"); subscriptions.unsubscribeFrontendMessage("TriggerTextCommit"); subscriptions.unsubscribeFrontendMessage("DisplayEditableTextbox"); subscriptions.unsubscribeFrontendMessage("DisplayEditableTextboxUpdateFontData"); @@ -659,6 +712,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 463002c3b09..722d5fd681e 100644 --- a/frontend/src/utility-functions/input.ts +++ b/frontend/src/utility-functions/input.ts @@ -115,6 +115,7 @@ function isObserveOnly(e: MouseEvent): boolean { // 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; diff --git a/frontend/wrapper/src/editor_commands.rs b/frontend/wrapper/src/editor_commands.rs index 96f514dc0b4..5830c2389ad 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, so tools can keep tracking + 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 8fbb427560d8a784ff4105f39e2c6b4745ba0cf7 Mon Sep 17 00:00:00 2001 From: he mansu <100087133+Himanshu121865@users.noreply.github.com> Date: Sat, 12 Sep 2026 20:25:00 +0530 Subject: [PATCH 2/8] Update editor/src/messages/tool/transform_layer/transform_layer_message_handler.rs Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --- .../transform_layer_message_handler.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) 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 af44d5513b5..7004c74b878 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 @@ -540,6 +540,17 @@ impl MessageHandler> for let old_ptz = self.ptz; self.ptz = document.document_ptz; if old_ptz != self.ptz { + 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; return; } From 24cf124d254416de6f4bb916df0809f5e5ff1cc4 Mon Sep 17 00:00:00 2001 From: Himanshu121865 Date: Sat, 12 Sep 2026 21:16:48 +0530 Subject: [PATCH 3/8] Keep the software cursor in lockstep when the viewport changes mid-gesture A pan/tilt/zoom during a G/R/S transform makes the next pointer move drop its delta for the transform, so the software cursor must drop it too instead of advancing away from the grabbed point on the layer. --- .../transform_layer_message_handler.rs | 53 +++++++++++++++---- 1 file changed, 42 insertions(+), 11 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 7004c74b878..d83eef04946 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 @@ -540,17 +540,8 @@ impl MessageHandler> for let old_ptz = self.ptz; self.ptz = document.document_ptz; if old_ptz != self.ptz { - 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, - }); - } + // The viewport changed, so this frame's pointer delta can't be applied to the transform without a jump. + // The software cursor must drop it too, since it would otherwise drift away from the transformed layer. self.mouse_position = mouse_position; return; } @@ -1441,6 +1432,46 @@ mod test_transform_layer { 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; + + // A viewport change makes the next pointer move drop its delta for the transform, so the software cursor must not consume it either + 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(); From 62a0c296220e4e1a9652760083b907f05e413b57 Mon Sep 17 00:00:00 2001 From: Himanshu121865 Date: Sat, 12 Sep 2026 21:32:23 +0530 Subject: [PATCH 4/8] Stop modifier presses from jumping a pointer-locked transform The input mapper re-dispatches PointerMove when Shift or Control changes, and the absolute pointer position stays frozen behind the pointer lock. That frozen position was read as movement, so pressing either key mid-drag yanked the layer and the software cursor back towards the lock origin. Ignore a repeat of the frozen position while the lock is held, but keep honoring real absolute movement for platforms where the lock request was rejected. --- .../transform_layer_message_handler.rs | 85 ++++++++++++++++++- 1 file changed, 84 insertions(+), 1 deletion(-) 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 d83eef04946..3a015ffc24c 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 @@ -102,6 +102,8 @@ pub struct TransformLayerMessageHandler { software_cursor_pos: ViewportPosition, // A locked pointer delta waiting to be applied by the next `PointerMove` pointer_lock_delta: Option, + // The absolute pointer position, which stays frozen there for as long as the pointer is locked + pointer_lock_origin: ViewportPosition, } #[message_handler_data] @@ -534,7 +536,13 @@ impl MessageHandler> for } TransformLayerMessage::PointerMove { slow_key, increments_key } => { // Use a pending locked delta if there is one, otherwise the absolute pointer position - let mouse_position = self.pointer_lock_delta.take().unwrap_or(input.mouse.position); + let mouse_position = match self.pointer_lock_delta.take() { + Some(position) => position, + // While the pointer is locked, the absolute position stays frozen where the lock began, so a repeat of it + // isn't movement. That happens when the input mapper re-dispatches this message for a Shift or Control change. + None if self.software_cursor_active && input.mouse.position == self.pointer_lock_origin => self.mouse_position, + None => input.mouse.position, + }; self.slow = input.keyboard.get(slow_key as usize); let old_ptz = self.ptz; @@ -699,6 +707,7 @@ impl TransformLayerMessageHandler { self.software_cursor_pos = pos; // `input.mouse.position` is frozen during pointer lock, so seed the tracking position here self.mouse_position = pos; + self.pointer_lock_origin = pos; responses.add(FrontendMessage::UpdateSoftwareCursor { visible: true, x: pos.x, y: pos.y }); responses.add(FrontendMessage::UpdateMouseCursor { cursor: MouseCursorIcon::None }); responses.add(AppWindowMessage::PointerLock); @@ -1499,4 +1508,78 @@ mod test_transform_layer { editor.handle_message(TransformLayerMessage::CancelTransformOperation).await; } + + #[tokio::test] + async fn test_modifier_refresh_does_not_move_a_locked_transform() { + 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(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`, but the absolute pointer + // position stays frozen behind the lock, so the repeated report must not be treated as movement + 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 modifier refresh must not move the software cursor"); + assert_eq!(mouse_after_refresh, mouse_after_drag, "A modifier refresh must not move the tracked pointer position"); + + let transform_after_refresh = get_layer_transform(&mut editor, layer).await.unwrap(); + assert!(transform_after_refresh.abs_diff_eq(transform_after_drag, 1e-5), "A modifier refresh must not move the layer"); + + // Locked deltas must keep driving the transform after the refresh + 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 moving the layer after a modifier refresh" + ); + + editor.handle_message(TransformLayerMessage::CancelTransformOperation).await; + } + + #[tokio::test] + async fn test_absolute_pointer_drives_a_grab_when_the_lock_never_engaged() { + 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(); + + // If the platform rejected the lock request, the pointer keeps reporting absolute positions, which must still drag + editor.move_mouse(220., 160., ModifierKeys::empty(), MouseKeys::NONE).await; + editor + .handle_message(TransformLayerMessage::PointerMove { + slow_key: Key::Shift, + increments_key: Key::Control, + }) + .await; + + let transform_after = get_layer_transform(&mut editor, layer).await.unwrap(); + assert!( + !transform_after.abs_diff_eq(transform_before, 1e-5), + "An absolute pointer position away from the lock origin must still drive the transform" + ); + + editor.handle_message(TransformLayerMessage::CancelTransformOperation).await; + } } From 8221e458b69acadc4adc853e6e1f26dba5912d1e Mon Sep 17 00:00:00 2001 From: Himanshu121865 Date: Sat, 12 Sep 2026 21:48:47 +0530 Subject: [PATCH 5/8] Fix the absolute pointer fallback when pointer lock never engages The guard against reading the frozen absolute position as movement keyed off the position itself, so with no pointer lock the drag ignored any report that landed back on the position where the grab began. Track whether the platform is actually reporting locked deltas instead. --- .../transform_layer_message_handler.rs | 58 +++++++++++++++++-- 1 file changed, 52 insertions(+), 6 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 3a015ffc24c..dad36378d4e 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 @@ -102,8 +102,8 @@ pub struct TransformLayerMessageHandler { software_cursor_pos: ViewportPosition, // A locked pointer delta waiting to be applied by the next `PointerMove` pointer_lock_delta: Option, - // The absolute pointer position, which stays frozen there for as long as the pointer is locked - pointer_lock_origin: ViewportPosition, + // Set once the platform reports locked deltas, which it only does after the pointer lock is actually engaged + pointer_lock_engaged: bool, } #[message_handler_data] @@ -538,9 +538,10 @@ impl MessageHandler> for // Use a pending locked delta if there is one, otherwise the absolute pointer position let mouse_position = match self.pointer_lock_delta.take() { Some(position) => position, - // While the pointer is locked, the absolute position stays frozen where the lock began, so a repeat of it - // isn't movement. That happens when the input mapper re-dispatches this message for a Shift or Control change. - None if self.software_cursor_active && input.mouse.position == self.pointer_lock_origin => self.mouse_position, + // An engaged lock reports no absolute movement, so the absolute position is stale there and must not be read + // as movement. That would otherwise happen when the input mapper re-dispatches this message for a Shift or + // Control change, which would yank the transform back towards where the lock began. + None if self.software_cursor_active && self.pointer_lock_engaged => self.mouse_position, None => input.mouse.position, }; @@ -624,6 +625,7 @@ impl MessageHandler> for TransformLayerMessage::PointerLockMove { delta } => { // Locked deltas only matter while a G/R/S transform owns the pointer if self.software_cursor_active { + self.pointer_lock_engaged = true; self.pointer_lock_delta = Some(self.mouse_position + delta); responses.add(TransformLayerMessage::PointerMove { slow_key: SLOW_KEY, @@ -707,7 +709,7 @@ impl TransformLayerMessageHandler { self.software_cursor_pos = pos; // `input.mouse.position` is frozen during pointer lock, so seed the tracking position here self.mouse_position = pos; - self.pointer_lock_origin = pos; + self.pointer_lock_engaged = false; responses.add(FrontendMessage::UpdateSoftwareCursor { visible: true, x: pos.x, y: pos.y }); responses.add(FrontendMessage::UpdateMouseCursor { cursor: MouseCursorIcon::None }); responses.add(AppWindowMessage::PointerLock); @@ -719,6 +721,7 @@ impl TransformLayerMessageHandler { } self.software_cursor_active = false; self.pointer_lock_delta = None; + self.pointer_lock_engaged = false; responses.add(FrontendMessage::UpdateSoftwareCursor { visible: false, x: 0., y: 0. }); // Let the active tool re-emit its own cursor instead of resetting to a default responses.add(ToolMessage::UpdateCursor); @@ -1582,4 +1585,47 @@ mod test_transform_layer { editor.handle_message(TransformLayerMessage::CancelTransformOperation).await; } + + #[tokio::test] + async fn test_absolute_pointer_returning_to_the_lock_origin_still_drives_the_grab() { + 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; + + // Since the lock never engaged, the absolute pointer keeps driving the transform + 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"); + + // Dragging back to where the grab began must not be mistaken for a frozen report behind a lock + 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_after_moving, 1e-5), + "Returning the absolute pointer to the position where the lock began must not be swallowed" + ); + assert!( + transform_after_returning.abs_diff_eq(transform_before, 1e-3), + "Returning to where the grab began should undo the drag of the grabbed layer" + ); + + editor.handle_message(TransformLayerMessage::CancelTransformOperation).await; + } } From 667eae54b9be29cbc6b4b333d15cf4bd7670f80f Mon Sep 17 00:00:00 2001 From: Himanshu121865 Date: Sat, 12 Sep 2026 22:59:33 +0530 Subject: [PATCH 6/8] Read a repeated absolute pointer position as no movement Tracking whether the pointer lock had engaged left that flag stuck when the platform released the lock without ending the transform, which froze the G/R/S drag until the user cancelled it. Detect a repeated absolute position instead: a frozen report never counts as movement, while real absolute movement always keeps driving the transform. --- .../transform_layer_message_handler.rs | 52 +++++++++++++++---- 1 file changed, 43 insertions(+), 9 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 dad36378d4e..ef84bf91799 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 @@ -102,8 +102,8 @@ pub struct TransformLayerMessageHandler { software_cursor_pos: ViewportPosition, // A locked pointer delta waiting to be applied by the next `PointerMove` pointer_lock_delta: Option, - // Set once the platform reports locked deltas, which it only does after the pointer lock is actually engaged - pointer_lock_engaged: bool, + // The last absolute pointer position reported by the input, so a repeat of it isn't mistaken for movement + last_absolute_pointer: ViewportPosition, } #[message_handler_data] @@ -535,13 +535,16 @@ impl MessageHandler> for self.transform_operation.grs_typed(self.typing.evaluate(), &mut selected, &self.state, document); } TransformLayerMessage::PointerMove { slow_key, increments_key } => { + // A repeated absolute position isn't movement. That matters while the software cursor is active because a locked + // pointer leaves that position frozen, so reading it as movement would undo the relative-delta dragging, which + // happens whenever Shift or Control re-dispatches this message through the input mapper's `refresh_keys`. + let repeated_absolute_pointer = input.mouse.position == self.last_absolute_pointer; + self.last_absolute_pointer = input.mouse.position; + // Use a pending locked delta if there is one, otherwise the absolute pointer position let mouse_position = match self.pointer_lock_delta.take() { Some(position) => position, - // An engaged lock reports no absolute movement, so the absolute position is stale there and must not be read - // as movement. That would otherwise happen when the input mapper re-dispatches this message for a Shift or - // Control change, which would yank the transform back towards where the lock began. - None if self.software_cursor_active && self.pointer_lock_engaged => self.mouse_position, + None if self.software_cursor_active && repeated_absolute_pointer => self.mouse_position, None => input.mouse.position, }; @@ -625,7 +628,6 @@ impl MessageHandler> for TransformLayerMessage::PointerLockMove { delta } => { // Locked deltas only matter while a G/R/S transform owns the pointer if self.software_cursor_active { - self.pointer_lock_engaged = true; self.pointer_lock_delta = Some(self.mouse_position + delta); responses.add(TransformLayerMessage::PointerMove { slow_key: SLOW_KEY, @@ -709,7 +711,7 @@ impl TransformLayerMessageHandler { self.software_cursor_pos = pos; // `input.mouse.position` is frozen during pointer lock, so seed the tracking position here self.mouse_position = pos; - self.pointer_lock_engaged = false; + 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); @@ -721,7 +723,6 @@ impl TransformLayerMessageHandler { } self.software_cursor_active = false; self.pointer_lock_delta = None; - self.pointer_lock_engaged = false; responses.add(FrontendMessage::UpdateSoftwareCursor { visible: false, x: 0., y: 0. }); // Let the active tool re-emit its own cursor instead of resetting to a default responses.add(ToolMessage::UpdateCursor); @@ -1628,4 +1629,37 @@ mod test_transform_layer { editor.handle_message(TransformLayerMessage::CancelTransformOperation).await; } + + #[tokio::test] + async fn test_absolute_pointer_resumes_driving_after_the_lock_goes_away() { + 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; + + // Drag with relative deltas while the pointer is locked + editor.handle_message(TransformLayerMessage::PointerLockMove { delta: DVec2::new(40., 20.) }).await; + let transform_after_locked_drag = get_layer_transform(&mut editor, layer).await.unwrap(); + assert!(!transform_after_locked_drag.abs_diff_eq(transform_before, 1e-5), "Locked deltas must drive the transform"); + + // If the lock is released without ending the transform, absolute positions resume and must drive it again + editor.move_mouse(origin.x + 70., origin.y + 30., ModifierKeys::empty(), MouseKeys::NONE).await; + editor + .handle_message(TransformLayerMessage::PointerMove { + slow_key: Key::Shift, + increments_key: Key::Control, + }) + .await; + let transform_after_unlocked_move = get_layer_transform(&mut editor, layer).await.unwrap(); + assert!( + !transform_after_unlocked_move.abs_diff_eq(transform_after_locked_drag, 1e-5), + "The transform must keep responding to absolute movement once the lock is gone" + ); + + editor.handle_message(TransformLayerMessage::CancelTransformOperation).await; + } } From 6cd3c8e436a287995401a5c895ab66c605fcc7f3 Mon Sep 17 00:00:00 2001 From: Himanshu121865 Date: Sun, 13 Sep 2026 00:00:02 +0530 Subject: [PATCH 7/8] Keep the drag from snapping back when absolute tracking resumes Locked relative deltas carry the tracking position away from where the absolute pointer source sits, so once the lock goes away mid-drag the next absolute report applied the whole wrapped distance as a delta. Adopt the restored position as the new reference without moving the transform, then let absolute movement drive it again. --- .../transform_layer_message_handler.rs | 66 +++++++++++++------ 1 file changed, 45 insertions(+), 21 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 ef84bf91799..8feb5c53a1f 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 @@ -102,8 +102,8 @@ pub struct TransformLayerMessageHandler { software_cursor_pos: ViewportPosition, // A locked pointer delta waiting to be applied by the next `PointerMove` pointer_lock_delta: Option, - // The last absolute pointer position reported by the input, so a repeat of it isn't mistaken for movement last_absolute_pointer: ViewportPosition, + tracking_locked_deltas: bool, } #[message_handler_data] @@ -535,11 +535,10 @@ impl MessageHandler> for self.transform_operation.grs_typed(self.typing.evaluate(), &mut selected, &self.state, document); } TransformLayerMessage::PointerMove { slow_key, increments_key } => { - // A repeated absolute position isn't movement. That matters while the software cursor is active because a locked - // pointer leaves that position frozen, so reading it as movement would undo the relative-delta dragging, which - // happens whenever Shift or Control re-dispatches this message through the input mapper's `refresh_keys`. + // A repeated absolute position isn't movement: a locked pointer keeps it frozen where the lock began 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; // Use a pending locked delta if there is one, otherwise the absolute pointer position let mouse_position = match self.pointer_lock_delta.take() { @@ -552,12 +551,18 @@ impl MessageHandler> for let old_ptz = self.ptz; self.ptz = document.document_ptz; if old_ptz != self.ptz { - // The viewport changed, so this frame's pointer delta can't be applied to the transform without a jump. - // The software cursor must drop it too, since it would otherwise drift away from the transformed layer. + // The viewport changed, so this frame's delta can't move the transform, and the software cursor must drop it too self.mouse_position = mouse_position; return; } + if absolute_tracking_resumes { + // Adopt the restored absolute position without moving the transform, so the drag doesn't snap back by the wrapped distance + self.tracking_locked_deltas = false; + self.mouse_position = input.mouse.position; + return; + } + let new_increments = input.keyboard.get(increments_key as usize); if new_increments != self.state.is_rounded_to_intervals { self.state.is_rounded_to_intervals = new_increments; @@ -628,6 +633,7 @@ impl MessageHandler> for TransformLayerMessage::PointerLockMove { delta } => { // Locked deltas only matter while a G/R/S transform owns the pointer 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, @@ -723,6 +729,7 @@ impl TransformLayerMessageHandler { } 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 own cursor instead of resetting to a default responses.add(ToolMessage::UpdateCursor); @@ -1455,7 +1462,6 @@ mod test_transform_layer { editor.handle_message(TransformLayerMessage::BeginGrab).await; editor.handle_message(TransformLayerMessage::PointerLockMove { delta: DVec2::new(10., 0.) }).await; - // A viewport change makes the next pointer move drop its delta for the transform, so the software cursor must not consume it either editor.handle_message(NavigationMessage::CanvasPan { delta: DVec2::new(20., 20.) }).await; editor.handle_message(NavigationMessage::CanvasZoomIncrease { center_on_mouse: false }).await; @@ -1529,8 +1535,6 @@ mod test_transform_layer { (handler.software_cursor_pos, handler.mouse_position) }; - // Shift and Control re-dispatch this message through the input mapper's `refresh_keys`, but the absolute pointer - // position stays frozen behind the lock, so the repeated report must not be treated as movement editor .handle_message(TransformLayerMessage::PointerMove { slow_key: Key::Shift, @@ -1548,7 +1552,6 @@ mod test_transform_layer { let transform_after_refresh = get_layer_transform(&mut editor, layer).await.unwrap(); assert!(transform_after_refresh.abs_diff_eq(transform_after_drag, 1e-5), "A modifier refresh must not move the layer"); - // Locked deltas must keep driving the transform after the refresh 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!( @@ -1569,7 +1572,6 @@ mod test_transform_layer { editor.handle_message(TransformLayerMessage::BeginGrab).await; let transform_before = get_layer_transform(&mut editor, layer).await.unwrap(); - // If the platform rejected the lock request, the pointer keeps reporting absolute positions, which must still drag editor.move_mouse(220., 160., ModifierKeys::empty(), MouseKeys::NONE).await; editor .handle_message(TransformLayerMessage::PointerMove { @@ -1598,7 +1600,6 @@ mod test_transform_layer { 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; - // Since the lock never engaged, the absolute pointer keeps driving the transform editor.move_mouse(origin.x + 60., origin.y + 40., ModifierKeys::empty(), MouseKeys::NONE).await; editor .handle_message(TransformLayerMessage::PointerMove { @@ -1609,7 +1610,6 @@ mod test_transform_layer { 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"); - // Dragging back to where the grab began must not be mistaken for a frozen report behind a lock editor.move_mouse(origin.x, origin.y, ModifierKeys::empty(), MouseKeys::NONE).await; editor .handle_message(TransformLayerMessage::PointerMove { @@ -1631,7 +1631,7 @@ mod test_transform_layer { } #[tokio::test] - async fn test_absolute_pointer_resumes_driving_after_the_lock_goes_away() { + async fn test_absolute_pointer_after_the_lock_goes_away_does_not_jump_back() { let mut editor = EditorTestUtils::create(); editor.new_document().await; editor.drag_tool(ToolType::Rectangle, 0., 0., 100., 100., ModifierKeys::empty()).await; @@ -1641,23 +1641,47 @@ mod test_transform_layer { 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; - // Drag with relative deltas while the pointer is locked - editor.handle_message(TransformLayerMessage::PointerLockMove { delta: DVec2::new(40., 20.) }).await; + editor.handle_message(TransformLayerMessage::PointerLockMove { delta: DVec2::new(100., 0.) }).await; let transform_after_locked_drag = get_layer_transform(&mut editor, layer).await.unwrap(); assert!(!transform_after_locked_drag.abs_diff_eq(transform_before, 1e-5), "Locked deltas must drive the transform"); - // If the lock is released without ending the transform, absolute positions resume and must drive it again - editor.move_mouse(origin.x + 70., origin.y + 30., ModifierKeys::empty(), MouseKeys::NONE).await; + 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_unlocked_move = get_layer_transform(&mut editor, layer).await.unwrap(); + let transform_after_restoring = get_layer_transform(&mut editor, layer).await.unwrap(); + assert!( + transform_after_restoring.abs_diff_eq(transform_after_locked_drag, 1e-5), + "The restored cursor position must not move the layer" + ); + + 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_locked_drag, 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_unlocked_move.abs_diff_eq(transform_after_locked_drag, 1e-5), - "The transform must keep responding to absolute movement once the lock is gone" + 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; From 56e0d357e0421c72d15ef5ccfccad0caf42b120e Mon Sep 17 00:00:00 2001 From: Himanshu121865 Date: Sun, 13 Sep 2026 00:06:38 +0530 Subject: [PATCH 8/8] Consolidate the software cursor regression tests Fold the cases that shared a scenario into one test each: a locked drag handling stale absolute reports and resuming after the lock goes away, and a grab driven by the absolute pointer with no lock, which includes returning to the position where the grab began. --- .../transform_layer_message_handler.rs | 114 +++++------------- 1 file changed, 33 insertions(+), 81 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 8feb5c53a1f..f93f73aad89 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 @@ -1520,13 +1520,14 @@ mod test_transform_layer { } #[tokio::test] - async fn test_modifier_refresh_does_not_move_a_locked_transform() { + 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(); @@ -1535,6 +1536,7 @@ mod test_transform_layer { (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, @@ -1546,92 +1548,62 @@ mod test_transform_layer { 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 modifier refresh must not move the software cursor"); - assert_eq!(mouse_after_refresh, mouse_after_drag, "A modifier refresh must not move the tracked pointer position"); - - let transform_after_refresh = get_layer_transform(&mut editor, layer).await.unwrap(); - assert!(transform_after_refresh.abs_diff_eq(transform_after_drag, 1e-5), "A modifier refresh 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_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!( - !transform_after_more_dragging.abs_diff_eq(transform_after_drag, 1e-5), - "Locked deltas must keep moving the layer after a modifier refresh" + 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::CancelTransformOperation).await; - } - - #[tokio::test] - async fn test_absolute_pointer_drives_a_grab_when_the_lock_never_engaged() { - 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(); + 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"); - editor.move_mouse(220., 160., ModifierKeys::empty(), MouseKeys::NONE).await; + // 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; - - let transform_after = get_layer_transform(&mut editor, layer).await.unwrap(); assert!( - !transform_after.abs_diff_eq(transform_before, 1e-5), - "An absolute pointer position away from the lock origin must still drive the transform" + 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" ); - editor.handle_message(TransformLayerMessage::CancelTransformOperation).await; - } - - #[tokio::test] - async fn test_absolute_pointer_returning_to_the_lock_origin_still_drives_the_grab() { - 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; + // 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_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"); + 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, origin.y, ModifierKeys::empty(), MouseKeys::NONE).await; + 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_returning = get_layer_transform(&mut editor, layer).await.unwrap(); - assert!( - !transform_after_returning.abs_diff_eq(transform_after_moving, 1e-5), - "Returning the absolute pointer to the position where the lock began must not be swallowed" - ); + let transform_after_moving = get_layer_transform(&mut editor, layer).await.unwrap(); assert!( - transform_after_returning.abs_diff_eq(transform_before, 1e-3), - "Returning to where the grab began should undo the drag of the grabbed layer" + 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_after_the_lock_goes_away_does_not_jump_back() { + 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; @@ -1641,47 +1613,27 @@ mod test_transform_layer { 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.handle_message(TransformLayerMessage::PointerLockMove { delta: DVec2::new(100., 0.) }).await; - let transform_after_locked_drag = get_layer_transform(&mut editor, layer).await.unwrap(); - assert!(!transform_after_locked_drag.abs_diff_eq(transform_before, 1e-5), "Locked deltas 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_restoring = get_layer_transform(&mut editor, layer).await.unwrap(); - assert!( - transform_after_restoring.abs_diff_eq(transform_after_locked_drag, 1e-5), - "The restored cursor position must not move the layer" - ); - - editor.move_mouse(origin.x + 10., origin.y, ModifierKeys::empty(), MouseKeys::NONE).await; + 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_resuming = get_layer_transform(&mut editor, layer).await.unwrap(); - assert!( - transform_after_resuming.abs_diff_eq(transform_after_locked_drag, 1e-5), - "Resuming absolute tracking must not move the layer by the wrapped distance" - ); + 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 + 40., origin.y, ModifierKeys::empty(), MouseKeys::NONE).await; + 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_moving = get_layer_transform(&mut editor, layer).await.unwrap(); + let transform_after_returning = 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" + 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;