diff --git a/desktop/src/app.rs b/desktop/src/app.rs index f87a09d439..5c3eb1771b 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::{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 { @@ -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,40 @@ 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(); + } + } + + /// Synthesize an Escape press/release so the editor cancels the active transform (same as a user pressing Escape) + 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) { @@ -536,23 +573,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(WindowEvent::PointerMoved { - device_id: None, - position: pointer_lock_position, - primary: true, - source: winit::event::PointerSource::Mouse, - })); + self.unlock_pointer(); + } + + // The editor can no longer track the pointer, so cancel any pointer-locked operation (G/R/S) and release the grab + if let WindowEvent::Focused(false) = &event + && self.input_state.pointer_locked() + { + self.unlock_pointer(); + self.send_cancel_escape(); } for action in self.input_state.process(&event) { @@ -642,6 +678,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..8748fa6067 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 || window.document.pointerLockElement !== viewport) return; + const dx = e.movementX; + const dy = e.movementY; + if (dx === 0 && dy === 0) return; + try { + editor.appWindowPointerLockMove(dx, dy); + } catch { + // The wrapper may not be ready yet + } + } + // 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()