Desktop: Implement pointer lock for NumberInput (#3638)

* Desktop: Implement pointer lock for NumberInput

* add shift and ctrl modifiers

* fixup
This commit is contained in:
Timon 2026-01-15 12:27:31 +01:00 committed by GitHub
parent 6616d1bfb1
commit 73682b482b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 150 additions and 26 deletions

View File

@ -5,7 +5,7 @@ use std::sync::mpsc::{Receiver, Sender, SyncSender};
use std::thread; use std::thread;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use winit::application::ApplicationHandler; use winit::application::ApplicationHandler;
use winit::dpi::PhysicalSize; use winit::dpi::{PhysicalPosition, PhysicalSize};
use winit::event::{ButtonSource, ElementState, MouseButton, WindowEvent}; use winit::event::{ButtonSource, ElementState, MouseButton, WindowEvent};
use winit::event_loop::{ActiveEventLoop, ControlFlow}; use winit::event_loop::{ActiveEventLoop, ControlFlow};
use winit::window::WindowId; use winit::window::WindowId;
@ -27,6 +27,8 @@ pub(crate) struct App {
window_size: PhysicalSize<u32>, window_size: PhysicalSize<u32>,
window_maximized: bool, window_maximized: bool,
window_fullscreen: bool, window_fullscreen: bool,
pointer_position: PhysicalPosition<f64>,
pointer_lock_position: Option<PhysicalPosition<f64>>,
ui_scale: f64, ui_scale: f64,
app_event_receiver: Receiver<AppEvent>, app_event_receiver: Receiver<AppEvent>,
app_event_scheduler: AppEventScheduler, app_event_scheduler: AppEventScheduler,
@ -84,6 +86,8 @@ impl App {
window_size: PhysicalSize { width: 0, height: 0 }, window_size: PhysicalSize { width: 0, height: 0 },
window_maximized: false, window_maximized: false,
window_fullscreen: false, window_fullscreen: false,
pointer_position: Default::default(),
pointer_lock_position: Default::default(),
ui_scale: 1., ui_scale: 1.,
app_event_receiver, app_event_receiver,
app_event_scheduler, app_event_scheduler,
@ -329,6 +333,12 @@ impl App {
window.clipboard_write(content); window.clipboard_write(content);
} }
} }
DesktopFrontendMessage::PointerLock => {
self.pointer_lock_position = Some(self.pointer_position);
if let Some(window) = &self.window {
window.start_pointer_lock();
}
}
DesktopFrontendMessage::WindowClose => { DesktopFrontendMessage::WindowClose => {
self.app_event_scheduler.schedule(AppEvent::CloseWindow); self.app_event_scheduler.schedule(AppEvent::CloseWindow);
} }
@ -480,6 +490,26 @@ impl ApplicationHandler for App {
} }
fn window_event(&mut self, event_loop: &dyn ActiveEventLoop, _window_id: WindowId, event: WindowEvent) { fn window_event(&mut self, event_loop: &dyn ActiveEventLoop, _window_id: WindowId, event: WindowEvent) {
// Handle pointer lock release
if let Some(pointer_lock_position) = self.pointer_lock_position
&& let WindowEvent::PointerButton {
state: ElementState::Released,
button: ButtonSource::Mouse(MouseButton::Left),
..
} = event
{
self.pointer_lock_position = None;
if let Some(window) = &self.window {
window.end_pointer_lock();
}
self.cef_context.handle_window_event(&WindowEvent::PointerMoved {
device_id: None,
position: pointer_lock_position,
primary: true,
source: winit::event::PointerSource::Mouse,
});
}
self.cef_context.handle_window_event(&event); self.cef_context.handle_window_event(&event);
match event { match event {
@ -556,6 +586,13 @@ impl ApplicationHandler for App {
self.app_event_scheduler.schedule(AppEvent::DesktopWrapperMessage(message)); self.app_event_scheduler.schedule(AppEvent::DesktopWrapperMessage(message));
} }
} }
WindowEvent::PointerMoved { position, .. } | WindowEvent::PointerLeft { position: Some(position), .. } | WindowEvent::PointerEntered { position, .. }
if self.pointer_lock_position.is_none() =>
{
self.pointer_position = position;
}
_ => {} _ => {}
} }
@ -563,6 +600,15 @@ impl ApplicationHandler for App {
self.cef_context.work(); self.cef_context.work();
} }
fn device_event(&mut self, _event_loop: &dyn ActiveEventLoop, _device_id: Option<winit::event::DeviceId>, event: winit::event::DeviceEvent) {
if self.pointer_lock_position.is_some()
&& let winit::event::DeviceEvent::PointerMotion { delta: (x, y) } = event
{
let message = DesktopWrapperMessage::PointerLockMove { x, y };
self.app_event_scheduler.schedule(AppEvent::DesktopWrapperMessage(message));
}
}
fn about_to_wait(&mut self, event_loop: &dyn ActiveEventLoop) { fn about_to_wait(&mut self, event_loop: &dyn ActiveEventLoop) {
// Set a timeout in case we miss any cef schedule requests // Set a timeout in case we miss any cef schedule requests
let timeout = Instant::now() + Duration::from_millis(10); let timeout = Instant::now() + Duration::from_millis(10);

View File

@ -159,6 +159,16 @@ impl Window {
self.winit_window.set_cursor(cursor); self.winit_window.set_cursor(cursor);
} }
pub(crate) fn start_pointer_lock(&self) {
let _ = self.winit_window.set_cursor_grab(winit::window::CursorGrabMode::Locked);
self.winit_window.set_cursor_visible(false);
}
pub(crate) fn end_pointer_lock(&self) {
let _ = self.winit_window.set_cursor_grab(winit::window::CursorGrabMode::None);
self.winit_window.set_cursor_visible(true);
}
pub(crate) fn update_menu(&self, entries: Vec<MenuItem>) { pub(crate) fn update_menu(&self, entries: Vec<MenuItem>) {
self.native_handle.update_menu(entries); self.native_handle.update_menu(entries);
} }

View File

@ -172,5 +172,9 @@ pub(super) fn handle_desktop_wrapper_message(dispatcher: &mut DesktopWrapperMess
dispatcher.queue_editor_message(message); dispatcher.queue_editor_message(message);
} }
} }
DesktopWrapperMessage::PointerLockMove { x, y } => {
let message = AppWindowMessage::PointerLockMove { x, y };
dispatcher.queue_editor_message(message);
}
} }
} }

View File

@ -136,6 +136,9 @@ pub(super) fn intercept_frontend_message(dispatcher: &mut DesktopWrapperMessageD
FrontendMessage::TriggerClipboardWrite { content } => { FrontendMessage::TriggerClipboardWrite { content } => {
dispatcher.respond(DesktopFrontendMessage::ClipboardWrite { content }); dispatcher.respond(DesktopFrontendMessage::ClipboardWrite { content });
} }
FrontendMessage::WindowPointerLock => {
dispatcher.respond(DesktopFrontendMessage::PointerLock);
}
FrontendMessage::WindowClose => { FrontendMessage::WindowClose => {
dispatcher.respond(DesktopFrontendMessage::WindowClose); dispatcher.respond(DesktopFrontendMessage::WindowClose);
} }

View File

@ -65,6 +65,7 @@ pub enum DesktopFrontendMessage {
ClipboardWrite { ClipboardWrite {
content: String, content: String,
}, },
PointerLock,
WindowClose, WindowClose,
WindowMinimize, WindowMinimize,
WindowMaximize, WindowMaximize,
@ -132,6 +133,10 @@ pub enum DesktopWrapperMessage {
ClipboardReadResult { ClipboardReadResult {
content: Option<String>, content: Option<String>,
}, },
PointerLockMove {
x: f64,
y: f64,
},
} }
#[derive(Clone, serde::Serialize, serde::Deserialize, Debug)] #[derive(Clone, serde::Serialize, serde::Deserialize, Debug)]

View File

@ -6,6 +6,8 @@ use super::app_window_message_handler::AppWindowPlatform;
#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)] #[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)]
pub enum AppWindowMessage { pub enum AppWindowMessage {
UpdatePlatform { platform: AppWindowPlatform }, UpdatePlatform { platform: AppWindowPlatform },
PointerLock,
PointerLockMove { x: f64, y: f64 },
Close, Close,
Minimize, Minimize,
Maximize, Maximize,

View File

@ -15,6 +15,12 @@ impl MessageHandler<AppWindowMessage, ()> for AppWindowMessageHandler {
self.platform = platform; self.platform = platform;
responses.add(FrontendMessage::UpdatePlatform { platform: self.platform }); responses.add(FrontendMessage::UpdatePlatform { platform: self.platform });
} }
AppWindowMessage::PointerLock => {
responses.add(FrontendMessage::WindowPointerLock);
}
AppWindowMessage::PointerLockMove { x, y } => {
responses.add(FrontendMessage::WindowPointerLockMove { x, y });
}
AppWindowMessage::Close => { AppWindowMessage::Close => {
responses.add(FrontendMessage::WindowClose); responses.add(FrontendMessage::WindowClose);
} }

View File

@ -364,6 +364,11 @@ pub enum FrontendMessage {
}, },
// Window prefix: cause the application window to do something // Window prefix: cause the application window to do something
WindowPointerLock,
WindowPointerLockMove {
x: f64,
y: f64,
},
WindowClose, WindowClose,
WindowMinimize, WindowMinimize,
WindowMaximize, WindowMaximize,

View File

@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import { createEventDispatcher, onMount, onDestroy } from "svelte"; import { createEventDispatcher, onMount, onDestroy, getContext } from "svelte";
import { evaluateMathExpression } from "@graphite/../wasm/pkg/graphite_wasm"; import { evaluateMathExpression } from "@graphite/../wasm/pkg/graphite_wasm";
import type { Editor } from "@graphite/editor";
import { PRESS_REPEAT_DELAY_MS, PRESS_REPEAT_INTERVAL_MS } from "@graphite/io-managers/input"; import { PRESS_REPEAT_DELAY_MS, PRESS_REPEAT_INTERVAL_MS } from "@graphite/io-managers/input";
import type { NumberInputMode, NumberInputIncrementBehavior, ActionShortcut } from "@graphite/messages"; import type { NumberInputMode, NumberInputIncrementBehavior, ActionShortcut } from "@graphite/messages";
import { browserVersion, isDesktop } from "@graphite/utility-functions/platform"; import { browserVersion, isDesktop } from "@graphite/utility-functions/platform";
@ -16,6 +17,8 @@
const dispatch = createEventDispatcher<{ value: number | undefined; startHistoryTransaction: undefined }>(); const dispatch = createEventDispatcher<{ value: number | undefined; startHistoryTransaction: undefined }>();
const editor = getContext<Editor>("editor");
// Content // Content
/// When `value` is not provided (i.e. it's `undefined`), a dash is displayed. /// When `value` is not provided (i.e. it's `undefined`), a dash is displayed.
export let value: number | undefined = undefined; export let value: number | undefined = undefined;
@ -80,6 +83,8 @@
let initialValueBeforeDragging: number | undefined = undefined; let initialValueBeforeDragging: number | undefined = undefined;
// Stores the total value change during the process of dragging the slider. Set to 0 when not dragging. // Stores the total value change during the process of dragging the slider. Set to 0 when not dragging.
let cumulativeDragDelta = 0; let cumulativeDragDelta = 0;
// Track whether the Shift key is currently held down.
let shiftKeyDown = false;
// Track whether the Ctrl key is currently held down. // Track whether the Ctrl key is currently held down.
let ctrlKeyDown = false; let ctrlKeyDown = false;
@ -91,17 +96,20 @@
...(mode === "Range" ? { "--progress-factor": Math.min(Math.max((rangeSliderValueAsRendered - rangeMin) / (rangeMax - rangeMin), 0), 1) } : {}), ...(mode === "Range" ? { "--progress-factor": Math.min(Math.max((rangeSliderValueAsRendered - rangeMin) / (rangeMax - rangeMin), 0), 1) } : {}),
}; };
// Keep track of the Ctrl key being held down. // Keep track of the Shift and Ctrl key being held down.
const trackCtrl = (e: KeyboardEvent | MouseEvent) => (ctrlKeyDown = e.ctrlKey); const trackShiftAndCtrl = (e: KeyboardEvent | MouseEvent) => {
shiftKeyDown = e.shiftKey;
ctrlKeyDown = e.ctrlKey;
};
onMount(() => { onMount(() => {
addEventListener("keydown", trackCtrl); addEventListener("keydown", trackShiftAndCtrl);
addEventListener("keyup", trackCtrl); addEventListener("keyup", trackShiftAndCtrl);
addEventListener("mousemove", trackCtrl); addEventListener("mousemove", trackShiftAndCtrl);
}); });
onDestroy(() => { onDestroy(() => {
removeEventListener("keydown", trackCtrl); removeEventListener("keydown", trackShiftAndCtrl);
removeEventListener("keyup", trackCtrl); removeEventListener("keyup", trackShiftAndCtrl);
removeEventListener("mousemove", trackCtrl); removeEventListener("mousemove", trackShiftAndCtrl);
clearTimeout(repeatTimeout); clearTimeout(repeatTimeout);
}); });
@ -369,6 +377,9 @@
// Enter dragging state // Enter dragging state
if (usePointerLock) target.requestPointerLock(); if (usePointerLock) target.requestPointerLock();
if (isDesktop()) {
editor.handle.appWindowPointerLock();
}
initialValueBeforeDragging = value; initialValueBeforeDragging = value;
cumulativeDragDelta = 0; cumulativeDragDelta = 0;
@ -412,19 +423,16 @@
// Calculate and then update the dragged value offset, slowed down by 10x when Shift is held. // Calculate and then update the dragged value offset, slowed down by 10x when Shift is held.
if (ignoredFirstMovement && initialValueBeforeDragging !== undefined) { if (ignoredFirstMovement && initialValueBeforeDragging !== undefined) {
const CHANGE_PER_DRAG_PX = 0.1; pointerLockMoveUpdate(e.movementX, e.shiftKey, e.ctrlKey, initialValueBeforeDragging);
const CHANGE_PER_DRAG_PX_SLOW = CHANGE_PER_DRAG_PX / 10; }
ignoredFirstMovement = true;
const dragDelta = e.movementX * (e.shiftKey ? CHANGE_PER_DRAG_PX_SLOW : CHANGE_PER_DRAG_PX); };
cumulativeDragDelta += dragDelta; // On desktop we don't get `pointermove` events while in pointer lock (cef doesn't support pointer lock).
// We have to listen for our custom `pointerlockmove` events instead.
const combined = initialValueBeforeDragging + cumulativeDragDelta; const pointerLockMove = (e: Event) => {
const combineSnapped = e.ctrlKey ? Math.round(combined) : combined; if (ignoredFirstMovement && initialValueBeforeDragging !== undefined && e instanceof CustomEvent) {
const delta = (e.detail as { x: number }).x;
const newValue = updateValue(combineSnapped); pointerLockMoveUpdate(delta, shiftKeyDown, ctrlKeyDown, initialValueBeforeDragging);
// If the value was altered within the `updateValue()` call, we need to rectify the cumulative drag delta to account for the change.
if (newValue !== undefined) cumulativeDragDelta -= combineSnapped - newValue;
} }
ignoredFirstMovement = true; ignoredFirstMovement = true;
}; };
@ -443,14 +451,32 @@
// Clean up the event listeners. // Clean up the event listeners.
removeEventListener("pointerup", pointerUp); removeEventListener("pointerup", pointerUp);
removeEventListener("pointermove", pointerMove); removeEventListener("pointermove", pointerMove);
removeEventListener("pointerlockmove", pointerLockMove);
if (usePointerLock) document.removeEventListener("pointerlockchange", pointerLockChange); if (usePointerLock) document.removeEventListener("pointerlockchange", pointerLockChange);
}; };
addEventListener("pointerup", pointerUp); addEventListener("pointerup", pointerUp);
addEventListener("pointermove", pointerMove); addEventListener("pointermove", pointerMove);
addEventListener("pointerlockmove", pointerLockMove);
if (usePointerLock) document.addEventListener("pointerlockchange", pointerLockChange); if (usePointerLock) document.addEventListener("pointerlockchange", pointerLockChange);
} }
function pointerLockMoveUpdate(delta: number, slow: boolean, snapping: boolean, initialValue: number) {
const CHANGE_PER_DRAG_PX = 0.1;
const CHANGE_PER_DRAG_PX_SLOW = CHANGE_PER_DRAG_PX / 10;
const dragDelta = delta * (slow ? CHANGE_PER_DRAG_PX_SLOW : CHANGE_PER_DRAG_PX);
cumulativeDragDelta += dragDelta;
const combined = initialValue + cumulativeDragDelta;
const combineSnapped = snapping ? Math.round(combined) : combined;
const newValue = updateValue(combineSnapped);
// If the value was altered within the `updateValue()` call, we need to rectify the cumulative drag delta to account for the change.
if (newValue !== undefined) cumulativeDragDelta -= combineSnapped - newValue;
}
// =============================== // ===============================
// RANGE MODE: DRAGGING THE SLIDER // RANGE MODE: DRAGGING THE SLIDER
// =============================== // ===============================

View File

@ -1,7 +1,7 @@
import { get } from "svelte/store"; import { get } from "svelte/store";
import { type Editor } from "@graphite/editor"; import { type Editor } from "@graphite/editor";
import { TriggerClipboardRead } from "@graphite/messages"; import { TriggerClipboardRead, WindowPointerLockMove } from "@graphite/messages";
import { type DialogState } from "@graphite/state-providers/dialog"; import { type DialogState } from "@graphite/state-providers/dialog";
import { type DocumentState } from "@graphite/state-providers/document"; import { type DocumentState } from "@graphite/state-providers/document";
import { type FullscreenState } from "@graphite/state-providers/fullscreen"; import { type FullscreenState } from "@graphite/state-providers/fullscreen";
@ -500,6 +500,12 @@ export function createInputManager(editor: Editor, dialog: DialogState, portfoli
} }
}); });
// Pointer lock movement events on desktop
editor.subscriptions.subscribeJsMessage(WindowPointerLockMove, (data) => {
const event = new CustomEvent("pointerlockmove", { detail: data });
window.dispatchEvent(event);
});
// Helper functions // Helper functions
function potentiallyRestoreCanvasFocus(e: Event) { function potentiallyRestoreCanvasFocus(e: Event) {

View File

@ -315,8 +315,6 @@ export class UpdateFullscreen extends JsMessage {
readonly fullscreen!: boolean; readonly fullscreen!: boolean;
} }
export class CloseWindow extends JsMessage {}
export class UpdateViewportHolePunch extends JsMessage { export class UpdateViewportHolePunch extends JsMessage {
readonly active!: boolean; readonly active!: boolean;
} }
@ -332,6 +330,11 @@ export class UpdateUIScale extends JsMessage {
readonly scale!: number; readonly scale!: number;
} }
export class WindowPointerLockMove extends JsMessage {
readonly x!: number;
readonly y!: number;
}
// Rust enum `Key` // Rust enum `Key`
export type KeyRaw = string; export type KeyRaw = string;
// Serde converts a Rust `Key` enum variant into this format with both the `Key` variant name (called `RawKey` in TS) and the localized `label` for the key // Serde converts a Rust `Key` enum variant into this format with both the `Key` variant name (called `RawKey` in TS) and the localized `label` for the key
@ -1728,6 +1731,7 @@ export const messageMakers: Record<string, MessageMaker> = {
UpdatePlatform, UpdatePlatform,
UpdateMaximized, UpdateMaximized,
UpdateFullscreen, UpdateFullscreen,
WindowPointerLockMove,
UpdatePropertiesPanelLayout, UpdatePropertiesPanelLayout,
UpdatePropertiesPanelState, UpdatePropertiesPanelState,
UpdateStatusBarHintsLayout, UpdateStatusBarHintsLayout,

View File

@ -274,6 +274,13 @@ impl EditorHandle {
self.dispatch(NodeGraphMessage::AddSecondaryExport); self.dispatch(NodeGraphMessage::AddSecondaryExport);
} }
/// Start Pointer Lock
#[wasm_bindgen(js_name = appWindowPointerLock)]
pub fn app_window_pointer_lock(&self) {
let message = AppWindowMessage::PointerLock;
self.dispatch(message);
}
/// Minimizes the application window to the taskbar or dock /// Minimizes the application window to the taskbar or dock
#[wasm_bindgen(js_name = appWindowMinimize)] #[wasm_bindgen(js_name = appWindowMinimize)]
pub fn app_window_minimize(&self) { pub fn app_window_minimize(&self) {