From 784a670594d45ee05cbe4dd6925a8e80c14813dd Mon Sep 17 00:00:00 2001 From: Himanshu121865 Date: Sun, 30 Aug 2026 21:16:17 +0530 Subject: [PATCH 1/5] wrap cursor around viewport during G/R/S transforms --- desktop/src/app.rs | 42 +++++++++++---- 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 | 1 + .../app_window/app_window_message_handler.rs | 6 +++ .../src/messages/frontend/frontend_message.rs | 7 +++ .../input_preprocessor_message.rs | 2 + .../input_preprocessor_message_handler.rs | 5 ++ .../transform_layer_message_handler.rs | 51 +++++++++++++++++++ .../src/components/panels/Document.svelte | 45 ++++++++++++++++ .../components/panels/SoftwareCursor.svelte | 19 +++++++ frontend/src/utility-functions/input.ts | 1 + frontend/wrapper/src/editor_commands.rs | 8 +++ 14 files changed, 184 insertions(+), 11 deletions(-) create mode 100644 frontend/src/components/panels/SoftwareCursor.svelte diff --git a/desktop/src/app.rs b/desktop/src/app.rs index f87a09d439..fbf669140b 100644 --- a/desktop/src/app.rs +++ b/desktop/src/app.rs @@ -349,6 +349,9 @@ impl App { window.start_pointer_lock(); } } + DesktopFrontendMessage::PointerUnlock => { + self.unlock_pointer(); + } DesktopFrontendMessage::WindowClose => { self.app_event_scheduler.schedule(AppEvent::Exit); } @@ -506,6 +509,22 @@ impl App { } } } + + fn unlock_pointer(&mut self) { + if let Some(pos) = self.input_state.unlock_pointer() + && let Some(window) = &self.window + { + window.end_pointer_lock(); + self.ui.send(UiCommand::Input(WindowEvent::PointerMoved { + device_id: None, + position: pos, + primary: true, + source: winit::event::PointerSource::Mouse, + })); + } else if let Some(window) = &self.window { + window.end_pointer_lock(); + } + } } impl ApplicationHandler for App { fn can_create_surfaces(&mut self, event_loop: &dyn ActiveEventLoop) { @@ -536,23 +555,21 @@ 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(WindowEvent::PointerMoved { - device_id: None, - position: pointer_lock_position, - primary: true, - source: winit::event::PointerSource::Mouse, - })); + self.unlock_pointer(); + } + + // Release a pointer lock when the window loses focus + if let WindowEvent::Focused(false) = &event + && self.input_state.pointer_locked() + { + self.unlock_pointer(); } for action in self.input_state.process(&event) { @@ -642,6 +659,9 @@ impl ApplicationHandler for App { if self.input_state.pointer_locked() && let winit::event::DeviceEvent::PointerMotion { delta: (x, y) } = event { + // DeviceEvent deltas are physical pixels + 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 63ba804981..6ae728e7f1 100644 --- a/desktop/src/input.rs +++ b/desktop/src/input.rs @@ -187,6 +187,10 @@ impl InputState { self.viewport_info.as_ref().map_or(1., |info| info.scale) } + pub(crate) fn viewport_scale(&self) -> f64 { + self.scale() + } + fn in_viewport(&self, position: PhysicalPosition) -> bool { self.viewport_info.as_ref().is_some_and(|info| info.contains(position)) } diff --git a/desktop/wrapper/src/intercept_frontend_message.rs b/desktop/wrapper/src/intercept_frontend_message.rs index b82b837929..a86ce0d508 100644 --- a/desktop/wrapper/src/intercept_frontend_message.rs +++ b/desktop/wrapper/src/intercept_frontend_message.rs @@ -125,6 +125,9 @@ pub(super) fn intercept_frontend_message(dispatcher: &mut DesktopWrapperMessageD FrontendMessage::WindowPointerLock => { dispatcher.respond(DesktopFrontendMessage::PointerLock); } + FrontendMessage::WindowPointerUnlock => { + dispatcher.respond(DesktopFrontendMessage::PointerUnlock); + } FrontendMessage::WindowClose => { dispatcher.respond(DesktopFrontendMessage::WindowClose); } diff --git a/desktop/wrapper/src/messages.rs b/desktop/wrapper/src/messages.rs index 37abceb9ab..bb174929d8 100644 --- a/desktop/wrapper/src/messages.rs +++ b/desktop/wrapper/src/messages.rs @@ -67,6 +67,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 1a68fdc938..2f4b70f4b7 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, PointerLockMove { x: f64, y: f64 }, Restart, Close, diff --git a/editor/src/messages/app_window/app_window_message_handler.rs b/editor/src/messages/app_window/app_window_message_handler.rs index 0477bed6e7..9406b0e2bd 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) }); + // Keep G/R/S dragging moving while the pointer is locked + responses.add(InputPreprocessorMessage::RelativePointerMove { delta: glam::DVec2::new(x, y) }); } AppWindowMessage::Close => { #[cfg(not(target_family = "wasm"))] diff --git a/editor/src/messages/frontend/frontend_message.rs b/editor/src/messages/frontend/frontend_message.rs index d7d537857a..d5e059ecd0 100644 --- a/editor/src/messages/frontend/frontend_message.rs +++ b/editor/src/messages/frontend/frontend_message.rs @@ -344,6 +344,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"))] WindowClose, #[cfg(not(target_family = "wasm"))] WindowMinimize, diff --git a/editor/src/messages/input_preprocessor/input_preprocessor_message.rs b/editor/src/messages/input_preprocessor/input_preprocessor_message.rs index 91663a8548..9afd9fdabb 100644 --- a/editor/src/messages/input_preprocessor/input_preprocessor_message.rs +++ b/editor/src/messages/input_preprocessor/input_preprocessor_message.rs @@ -1,6 +1,7 @@ use crate::messages::input_mapper::utility_types::keyboard::{Key, ModifierKeys}; use crate::messages::input_mapper::utility_types::pointer::EditorPointerState; use crate::messages::prelude::*; +use glam::DVec2; #[impl_message(Message, InputPreprocessor)] #[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)] @@ -14,4 +15,5 @@ pub enum InputPreprocessorMessage { PointerShake { editor_mouse_state: EditorPointerState, modifier_keys: ModifierKeys }, CurrentTime { timestamp: u64 }, WheelScroll { editor_mouse_state: EditorPointerState, modifier_keys: ModifierKeys }, + RelativePointerMove { delta: DVec2 }, } 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 8e29e813f9..d9d61e7989 100644 --- a/editor/src/messages/input_preprocessor/input_preprocessor_message_handler.rs +++ b/editor/src/messages/input_preprocessor/input_preprocessor_message_handler.rs @@ -107,6 +107,11 @@ impl<'a> MessageHandler { + self.mouse.position += delta; + + responses.add(InputMapperMessage::PointerMove); + } }; } 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 88b2c8fb2f..7759dc8150 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}; @@ -12,6 +13,7 @@ use crate::messages::tool::common_functionality::shapes::shape_utility::format_r use crate::messages::tool::tool_messages::select_tool; use crate::messages::tool::tool_messages::tool_prelude::Key; use crate::messages::tool::utility_types::{ToolData, ToolType}; +use crate::messages::viewport::Position; use glam::{DAffine2, DVec2}; use graphene_std::renderer::Quad; use graphene_std::vector::click_target::ClickTargetType; @@ -95,6 +97,10 @@ pub struct TransformLayerMessageHandler { // Path tool (ghost outlines showing pre-transform geometry) ghost_outline: Vec<(Vec, DAffine2)>, + + // Software cursor for wrap-around (visible fake cursor on Wayland/Web, OS warp on X11) + software_cursor_active: bool, + software_cursor_pos: ViewportPosition, } #[message_handler_data] @@ -339,6 +345,7 @@ impl MessageHandler> for responses.add(OverlaysMessage::RemoveProvider { provider: TRANSFORM_GRS_OVERLAY_PROVIDER, }); + self.disable_software_cursor(responses); } } TransformLayerMessage::BeginTransformOperation { operation } => { @@ -384,6 +391,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 +479,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 +519,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); @@ -581,6 +591,26 @@ impl MessageHandler> for }; } + if self.software_cursor_active { + let delta = input.mouse.position - self.mouse_position; + self.software_cursor_pos += delta; + + // Wrap around the viewport edges + let size = viewport.size(); + if size.x() > 0. && size.y() > 0. { + self.software_cursor_pos = DVec2::new( + ((self.software_cursor_pos.x % size.x()) + size.x()) % size.x(), + ((self.software_cursor_pos.y % size.y()) + size.y()) % size.y(), + ); + } + + responses.add(FrontendMessage::UpdateSoftwareCursor { + visible: true, + x: self.software_cursor_pos.x, + y: self.software_cursor_pos.y, + }); + } + self.mouse_position = input.mouse.position; } TransformLayerMessage::SelectionChanged => { @@ -651,6 +681,27 @@ 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; + 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; + responses.add(FrontendMessage::UpdateSoftwareCursor { visible: false, x: 0., y: 0. }); + responses.add(FrontendMessage::UpdateMouseCursor { cursor: MouseCursorIcon::Default }); + 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() { diff --git a/frontend/src/components/panels/Document.svelte b/frontend/src/components/panels/Document.svelte index 9680c2c81f..68a55c7134 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"; @@ -74,6 +75,22 @@ let cursorEyedropperPreviewColorPrimary = ""; let cursorEyedropperPreviewColorSecondary = ""; + let softwareCursorVisible = false; + let softwareCursorX = 0; + let softwareCursorY = 0; + + function handleSoftwareCursorWebMove(e: PointerEvent) { + if (!softwareCursorVisible || !isWeb) 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 + } + } + // Gradient stop color picker let gradientStopPickerColor: SRGBA8 | undefined = undefined; let gradientStopPickerPosition: { x: number; y: number } | undefined = undefined; @@ -509,6 +526,7 @@ await tick(); const { origin, spacing, interval, visible, tilt, flip, selectionQuad } = data; + updateDocumentRulers(origin, spacing, interval, visible, tilt, flip, selectionQuad || undefined); }); @@ -519,6 +537,30 @@ updateMouseCursor(data.cursor); }); + // Software cursor + 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 keep retrying 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); + // Text entry subscriptions.subscribeFrontendMessage("TriggerTextCommit", async () => { await tick(); @@ -566,6 +608,7 @@ viewportResizeObserver?.disconnect(); removeUpdatePixelRatio?.(); addedFontFaces.forEach((face) => window.document.fonts.delete(face)); + window.removeEventListener("pointermove", handleSoftwareCursorWebMove); subscriptions.unsubscribeFrontendMessage("UpdateDocumentArtwork"); subscriptions.unsubscribeFrontendMessage("UpdateEyedropperSamplingState"); @@ -573,6 +616,7 @@ subscriptions.unsubscribeFrontendMessage("UpdateDocumentScrollbars"); subscriptions.unsubscribeFrontendMessage("UpdateDocumentRulers"); subscriptions.unsubscribeFrontendMessage("UpdateMouseCursor"); + subscriptions.unsubscribeFrontendMessage("UpdateSoftwareCursor"); subscriptions.unsubscribeFrontendMessage("TriggerTextCommit"); subscriptions.unsubscribeFrontendMessage("DisplayEditableTextbox"); subscriptions.unsubscribeFrontendMessage("DisplayEditableTextboxUpdateFontData"); @@ -657,6 +701,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 aaea004d20..0e503ccde1 100644 --- a/frontend/src/utility-functions/input.ts +++ b/frontend/src/utility-functions/input.ts @@ -109,6 +109,7 @@ export async function onKeyUp(e: KeyboardEvent, editor: EditorWrapper, dialogSto // 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 4ba3dc9529..250f6f763d 100644 --- a/frontend/wrapper/src/editor_commands.rs +++ b/frontend/wrapper/src/editor_commands.rs @@ -84,6 +84,14 @@ mod editor_commands { AppWindowMessage::PointerLock.into() } + fn app_window_pointer_unlock() -> Message { + AppWindowMessage::PointerUnlock.into() + } + + 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 7f8c35fb5cc50bb21173f9b6f1c7f2812098c15c Mon Sep 17 00:00:00 2001 From: he mansu <100087133+Himanshu121865@users.noreply.github.com> Date: Sun, 30 Aug 2026 21:25:58 +0530 Subject: [PATCH 2/5] Update frontend/src/components/panels/Document.svelte Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --- frontend/src/components/panels/Document.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/panels/Document.svelte b/frontend/src/components/panels/Document.svelte index 68a55c7134..8748fa6067 100644 --- a/frontend/src/components/panels/Document.svelte +++ b/frontend/src/components/panels/Document.svelte @@ -80,7 +80,7 @@ let softwareCursorY = 0; function handleSoftwareCursorWebMove(e: PointerEvent) { - if (!softwareCursorVisible || !isWeb) return; + if (!softwareCursorVisible || !isWeb || window.document.pointerLockElement !== viewport) return; const dx = e.movementX; const dy = e.movementY; if (dx === 0 && dy === 0) return; From dacb9ba1a9dd5a5fb0bcd5430cbb6013932e6b14 Mon Sep 17 00:00:00 2001 From: he mansu <100087133+Himanshu121865@users.noreply.github.com> Date: Sun, 30 Aug 2026 21:27:53 +0530 Subject: [PATCH 3/5] Update frontend/src/components/panels/SoftwareCursor.svelte Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --- frontend/src/components/panels/SoftwareCursor.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/panels/SoftwareCursor.svelte b/frontend/src/components/panels/SoftwareCursor.svelte index bacae08d30..e710a864f6 100644 --- a/frontend/src/components/panels/SoftwareCursor.svelte +++ b/frontend/src/components/panels/SoftwareCursor.svelte @@ -5,7 +5,7 @@ {#if visible} - + {/if} From aa846b888c9b921f07971d3680866627a082cfdb Mon Sep 17 00:00:00 2001 From: he mansu <100087133+Himanshu121865@users.noreply.github.com> Date: Sun, 30 Aug 2026 21:31:33 +0530 Subject: [PATCH 4/5] Update frontend/src/components/panels/SoftwareCursor.svelte Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --- frontend/src/components/panels/SoftwareCursor.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/panels/SoftwareCursor.svelte b/frontend/src/components/panels/SoftwareCursor.svelte index e710a864f6..a4348549bd 100644 --- a/frontend/src/components/panels/SoftwareCursor.svelte +++ b/frontend/src/components/panels/SoftwareCursor.svelte @@ -5,7 +5,7 @@ {#if visible} -