Desktop: Make input routing aware of floating menus (#4498)

* Desktop: Make input routing aware of floating menus

* Desktop: Route mouse input like pen input

* Desktop: Model pointer input routing as a state machine
This commit is contained in:
Timon
2026-09-06 01:29:18 +00:00
committed by GitHub
parent 80746a7f55
commit 63725ea623
12 changed files with 188 additions and 59 deletions

View File

@@ -280,6 +280,9 @@ impl App {
render_state.set_viewport_scale([viewport_scale_x as f32, viewport_scale_y as f32]);
}
}
DesktopFrontendMessage::WindowUpdateDirectInput { enabled } => {
self.input_state.set_direct_input(enabled);
}
DesktopFrontendMessage::UpdateUIScale { scale } => {
self.ui_scale = scale;
self.resize();

View File

@@ -4,17 +4,16 @@ use winit::event::{ButtonSource, ElementState, MouseButton, MouseScrollDelta, Po
use winit::keyboard::ModifiersState;
use crate::ui::{InputEvent, MULTICLICK_ALLOWED_TRAVEL, MULTICLICK_TIMEOUT, PINCH_ZOOM_SPEED, SCROLL_LINE_HEIGHT, SCROLL_LINE_WIDTH, SCROLL_SPEED_X, SCROLL_SPEED_Y};
use crate::wrapper::messages::{InputMessage, ModifierKeys, MouseKeys, PointerState, ScrollDelta};
use crate::wrapper::messages::{EditorPointerState, InputMessage, ModifierKeys, MouseKeys, ScrollDelta};
pub(crate) struct InputState {
start: Instant,
viewport_info: Option<ViewportInfo>,
pointer_lock_position: Option<PhysicalPosition<f64>>,
modifiers: ModifiersState,
pointer_position: PhysicalPosition<f64>,
pointer_keys: MouseKeys,
ui_capture: bool,
pointer_state: PointerState,
click_tracker: ClickTracker,
direct_input: bool,
}
impl InputState {
@@ -22,12 +21,11 @@ impl InputState {
Self {
start: Instant::now(),
viewport_info: None,
pointer_lock_position: None,
modifiers: ModifiersState::default(),
pointer_position: PhysicalPosition::default(),
pointer_keys: MouseKeys::empty(),
ui_capture: true,
pointer_state: PointerState::Hover { route: Route::Ui },
click_tracker: ClickTracker::default(),
direct_input: false,
}
}
@@ -35,20 +33,44 @@ impl InputState {
self.viewport_info = Some(ViewportInfo { x, y, width, height, scale });
}
pub(crate) fn set_direct_input(&mut self, enabled: bool) {
self.direct_input = enabled;
}
pub(crate) fn lock_pointer(&mut self) {
self.pointer_lock_position = Some(self.pointer_position);
self.pointer_state = match self.pointer_state {
PointerState::Hover { route } => PointerState::Locked {
route,
keys: MouseKeys::empty(),
position: self.pointer_position,
},
PointerState::Stroke { route, keys } | PointerState::Locked { route, keys, .. } => PointerState::Locked {
route,
keys,
position: self.pointer_position,
},
};
}
pub(crate) fn unlock_pointer(&mut self) -> Option<PhysicalPosition<f64>> {
let position = self.pointer_lock_position.take();
if let Some(position) = position {
self.pointer_position = position;
}
position
let PointerState::Locked {
route: resume,
keys,
position: restore,
} = self.pointer_state
else {
return None;
};
self.pointer_position = restore;
self.pointer_state = match keys.is_empty() {
true => PointerState::Hover { route: Route::Ui },
false => PointerState::Stroke { route: resume, keys },
};
Some(restore)
}
pub(crate) fn pointer_locked(&self) -> bool {
self.pointer_lock_position.is_some()
matches!(self.pointer_state, PointerState::Locked { .. })
}
pub(crate) fn modifiers(&self) -> ModifiersState {
@@ -60,24 +82,28 @@ impl InputState {
WindowEvent::PointerMoved { position, source, .. } => {
self.pointer_position = *position;
let PointerSource::TabletTool { kind, data } = source else {
ui_callback(InputEvent::pointer().position(*position).moved().modifiers(self.modifiers).build());
return;
let route = match self.pointer_state {
PointerState::Hover { .. } => {
let next = self.route(*position);
self.pointer_state = PointerState::Hover { route: next };
next
}
PointerState::Stroke { route, .. } => route,
PointerState::Locked { keys, route: resume, .. } => match keys.is_empty() {
true => Route::Ui,
false => resume,
},
};
let ui_capture = if self.pointer_keys.is_empty() {
self.pointer_locked() || !self.in_viewport(*position)
} else {
self.ui_capture
};
if ui_capture {
ui_callback(InputEvent::pointer().position(*position).moved().modifiers(self.modifiers).build());
return;
match route {
Route::Ui => ui_callback(InputEvent::pointer().position(*position).moved().modifiers(self.modifiers).build()),
Route::Editor => editor_callback(InputMessage::PointerMove {
editor_mouse_state: match source {
PointerSource::TabletTool { kind, data } => self.tablet_pointer_state(kind, data),
_ => self.pointer_state(),
},
modifier_keys: self.modifier_keys(),
}),
}
editor_callback(InputMessage::PointerMove {
editor_mouse_state: self.tablet_pointer_state(kind, data),
modifier_keys: self.modifier_keys(),
});
}
WindowEvent::PointerEntered { position, .. } => {
self.pointer_position = *position;
@@ -91,13 +117,6 @@ impl InputState {
WindowEvent::PointerButton { state, button, position, .. } => {
self.pointer_position = *position;
let tablet = matches!(button, ButtonSource::TabletTool { .. });
// Stroke keeps capture decided from first button press until all buttons are released.
if state.is_pressed() && self.pointer_keys.is_empty() {
self.ui_capture = self.pointer_locked() || !tablet || !self.in_viewport(*position);
}
let mouse_button = button.clone().mouse_button();
let keys = match mouse_button {
Some(MouseButton::Left) => MouseKeys::LEFT,
@@ -107,15 +126,41 @@ impl InputState {
Some(MouseButton::Forward) => MouseKeys::FORWARD,
_ => MouseKeys::NONE,
};
match state {
ElementState::Pressed => self.pointer_keys.insert(keys),
ElementState::Released => self.pointer_keys.remove(keys),
}
let (pointer, route) = match self.pointer_state {
PointerState::Hover { route } => match (state.is_pressed(), keys.is_empty()) {
(true, false) => {
let route = self.route(*position);
(PointerState::Stroke { route, keys }, route)
}
(true, true) => (PointerState::Hover { route }, self.route(*position)),
(false, _) => (PointerState::Hover { route }, route),
},
PointerState::Stroke { route, keys: mut held } => {
match state.is_pressed() {
true => held.insert(keys),
false => held.remove(keys),
}
match held.is_empty() {
true => (PointerState::Hover { route }, route),
false => (PointerState::Stroke { route, keys: held }, route),
}
}
PointerState::Locked { route, keys: mut held, position } => {
let resume = if state.is_pressed() && held.is_empty() { Route::Ui } else { route };
match state.is_pressed() {
true => held.insert(keys),
false => held.remove(keys),
}
(PointerState::Locked { route: resume, keys: held, position }, Route::Ui)
}
};
self.pointer_state = pointer;
let count = mouse_button.map_or(1, |button| self.click_tracker.input(*position, button, *state));
let back_or_forward = matches!(mouse_button, Some(MouseButton::Back | MouseButton::Forward));
if self.pointer_locked() || !(back_or_forward || (tablet && !self.ui_capture)) {
if self.pointer_locked() || keys.is_empty() || !(back_or_forward || route == Route::Editor) {
let pointer = InputEvent::pointer().position(*position);
let input = match state {
ElementState::Pressed => pointer.pressed(button.clone(), count),
@@ -135,7 +180,7 @@ impl InputState {
ElementState::Released if count % 2 == 0 => {
editor_callback(InputMessage::PointerUp { editor_mouse_state, modifier_keys });
editor_callback(InputMessage::DoubleClick {
editor_mouse_state: PointerState {
editor_mouse_state: EditorPointerState {
mouse_keys: keys,
..editor_mouse_state
},
@@ -146,7 +191,7 @@ impl InputState {
}
}
WindowEvent::MouseWheel { delta, .. } => {
if self.pointer_locked() || !self.in_viewport(self.pointer_position) {
if self.pointer_locked() || self.ui_captures(self.pointer_position) {
let input = match delta {
MouseScrollDelta::LineDelta(x, y) => InputEvent::pointer().scrolled_lines(f64::from(*x), f64::from(*y)),
MouseScrollDelta::PixelDelta(position) => InputEvent::pointer().scrolled_pixels(position.x, position.y),
@@ -163,12 +208,12 @@ impl InputState {
let scroll_delta = ScrollDelta::new(-x * SCROLL_SPEED_X, -y * SCROLL_SPEED_Y, 0.);
editor_callback(InputMessage::WheelScroll {
editor_mouse_state: PointerState { scroll_delta, ..self.pointer_state() },
editor_mouse_state: EditorPointerState { scroll_delta, ..self.pointer_state() },
modifier_keys: self.modifier_keys(),
});
}
WindowEvent::PinchGesture { delta, .. } => {
if self.pointer_locked() || !self.in_viewport(self.pointer_position) || !delta.is_normal() {
if self.pointer_locked() || self.ui_captures(self.pointer_position) || !delta.is_normal() {
ui_callback(InputEvent::pointer().zoomed(*delta).modifiers(self.modifiers).build());
return;
}
@@ -176,7 +221,7 @@ impl InputState {
// TODO: This is a temporary solution to handle pinch gestures, we should handle pinch gestures editor-side instead.
let scroll_delta = ScrollDelta::new(0., -delta * PINCH_ZOOM_SPEED, 0.);
editor_callback(InputMessage::WheelScroll {
editor_mouse_state: PointerState { scroll_delta, ..self.pointer_state() },
editor_mouse_state: EditorPointerState { scroll_delta, ..self.pointer_state() },
modifier_keys: self.modifier_keys() | ModifierKeys::CONTROL,
});
}
@@ -192,21 +237,32 @@ impl InputState {
self.viewport_info.as_ref().map_or(1., |info| info.scale)
}
fn in_viewport(&self, position: PhysicalPosition<f64>) -> bool {
self.viewport_info.as_ref().is_some_and(|info| info.contains(position))
fn ui_captures(&self, position: PhysicalPosition<f64>) -> bool {
!self.direct_input || !self.viewport_info.as_ref().is_some_and(|info| info.contains(position))
}
fn pointer_state(&self) -> PointerState {
PointerState {
fn route(&self, position: PhysicalPosition<f64>) -> Route {
if self.ui_captures(position) { Route::Ui } else { Route::Editor }
}
fn pointer_keys(&self) -> MouseKeys {
match self.pointer_state {
PointerState::Hover { .. } => MouseKeys::empty(),
PointerState::Stroke { keys, .. } | PointerState::Locked { keys, .. } => keys,
}
}
fn pointer_state(&self) -> EditorPointerState {
EditorPointerState {
editor_position: (self.pointer_position.x / self.scale(), self.pointer_position.y / self.scale()).into(),
mouse_keys: self.pointer_keys,
mouse_keys: self.pointer_keys(),
time: Some(self.start.elapsed().as_secs_f64() * 1000.),
..Default::default()
}
}
fn tablet_pointer_state(&self, kind: &TabletToolKind, data: &TabletToolData) -> PointerState {
PointerState {
fn tablet_pointer_state(&self, kind: &TabletToolKind, data: &TabletToolData) -> EditorPointerState {
EditorPointerState {
pressure: data.force.map(|force| force.normalized(None)),
tilt: data.clone().tilt().map(|tilt| (f64::from(tilt.x), f64::from(tilt.y)).into()),
twist: data.twist.map(f64::from),
@@ -226,6 +282,19 @@ impl InputState {
}
}
#[derive(Clone, Copy)]
enum PointerState {
Hover { route: Route },
Stroke { route: Route, keys: MouseKeys },
Locked { route: Route, keys: MouseKeys, position: PhysicalPosition<f64> },
}
#[derive(Clone, Copy, PartialEq)]
enum Route {
Ui,
Editor,
}
struct ViewportInfo {
x: f64,
y: f64,

View File

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

View File

@@ -5,7 +5,7 @@ pub(crate) use graphite_editor::messages::prelude::Message as EditorMessage;
pub use graphite_editor::messages::frontend::utility_types::{DocumentInfo, FileFilter, PersistedState};
pub use graphite_editor::messages::input_mapper::utility_types::keyboard::{Key, ModifierKeys};
pub use graphite_editor::messages::input_mapper::utility_types::pointer::{EditorPointerState as PointerState, EditorPosition as Position, MouseKeys, ScrollDelta};
pub use graphite_editor::messages::input_mapper::utility_types::pointer::{EditorPointerState, MouseKeys, ScrollDelta};
pub use graphite_editor::messages::prelude::DocumentId;
pub use graphite_editor::messages::prelude::InputPreprocessorMessage as InputMessage;
pub use graphite_editor::messages::prelude::PreferencesMessageHandler as Preferences;
@@ -40,6 +40,9 @@ pub enum DesktopFrontendMessage {
UpdateUIScale {
scale: f64,
},
WindowUpdateDirectInput {
enabled: bool,
},
UpdateOverlays(vello::Scene),
PersistenceWriteDocument {
id: DocumentId,