diff --git a/editor/src/consts.rs b/editor/src/consts.rs index bfe61f48f3..4f9e37e3de 100644 --- a/editor/src/consts.rs +++ b/editor/src/consts.rs @@ -31,6 +31,12 @@ pub const VIEWPORT_ZOOM_TO_FIT_PADDING_SCALE_FACTOR: f64 = 0.95; pub const DRAG_BEYOND_VIEWPORT_MAX_OVEREXTENSION_PIXELS: f64 = 50.; pub const DRAG_BEYOND_VIEWPORT_SPEED_FACTOR: f64 = 20.; +// FLICK PANNING +pub const FLICK_VELOCITY_SAMPLES: usize = 5; +pub const FLICK_DECAY_RATE: f64 = 0.92; +pub const FLICK_MIN_VELOCITY: f64 = 0.5; +pub const FLICK_MAX_VELOCITY: f64 = 50.; + // SNAPPING POINT pub const SNAP_POINT_TOLERANCE: f64 = 5.; /// These are layers whose bounding boxes are used for alignment. diff --git a/editor/src/messages/dialog/preferences_dialog/preferences_dialog_message_handler.rs b/editor/src/messages/dialog/preferences_dialog/preferences_dialog_message_handler.rs index 1955986fc6..43d0694261 100644 --- a/editor/src/messages/dialog/preferences_dialog/preferences_dialog_message_handler.rs +++ b/editor/src/messages/dialog/preferences_dialog/preferences_dialog_message_handler.rs @@ -94,7 +94,25 @@ impl PreferencesDialogMessageHandler { .widget_instance(), ]; - rows.extend_from_slice(&[header, zoom_rate_label, zoom_rate, zoom_with_scroll]); + let checkbox_id = CheckboxId::new(); + let flick_panning_description = "Continue gliding after releasing pan controls (similar to Photoshop)."; + let flick_panning = vec![ + Separator::new(SeparatorStyle::Unrelated).widget_instance(), + Separator::new(SeparatorStyle::Unrelated).widget_instance(), + CheckboxInput::new(preferences.flick_panning) + .tooltip_label("Flick Panning") + .tooltip_description(flick_panning_description) + .on_update(|checkbox_input: &CheckboxInput| PreferencesMessage::FlickPanning { enabled: checkbox_input.checked }.into()) + .for_label(checkbox_id) + .widget_instance(), + TextLabel::new("Flick Panning") + .tooltip_label("Flick Panning") + .tooltip_description(flick_panning_description) + .for_checkbox(checkbox_id) + .widget_instance(), + ]; + + rows.extend_from_slice(&[header, zoom_rate_label, zoom_rate, zoom_with_scroll, flick_panning]); } // ======= diff --git a/editor/src/messages/portfolio/document/navigation/navigation_message.rs b/editor/src/messages/portfolio/document/navigation/navigation_message.rs index a55f8dbef4..6aa0c00156 100644 --- a/editor/src/messages/portfolio/document/navigation/navigation_message.rs +++ b/editor/src/messages/portfolio/document/navigation/navigation_message.rs @@ -24,6 +24,7 @@ pub enum NavigationMessage { EndCanvasPTZ { abort_transform: bool }, EndCanvasPTZWithClick { commit_key: Key }, FitViewportToBounds { bounds: [DVec2; 2], prevent_zoom_past_100: bool }, + FlickPanUpdate, FitViewportToSelection, PointerMove { snap: Key }, } diff --git a/editor/src/messages/portfolio/document/navigation/navigation_message_handler.rs b/editor/src/messages/portfolio/document/navigation/navigation_message_handler.rs index 3806a1e245..c9c9515ceb 100644 --- a/editor/src/messages/portfolio/document/navigation/navigation_message_handler.rs +++ b/editor/src/messages/portfolio/document/navigation/navigation_message_handler.rs @@ -1,7 +1,7 @@ use crate::application::Editor; use crate::consts::{ - VIEWPORT_ROTATE_SNAP_INTERVAL, VIEWPORT_SCROLL_RATE, VIEWPORT_ZOOM_LEVELS, VIEWPORT_ZOOM_MIN_FRACTION_COVER, VIEWPORT_ZOOM_MOUSE_RATE, VIEWPORT_ZOOM_SCALE_MAX, VIEWPORT_ZOOM_SCALE_MIN, - VIEWPORT_ZOOM_TO_FIT_PADDING_SCALE_FACTOR, + FLICK_DECAY_RATE, FLICK_MAX_VELOCITY, FLICK_MIN_VELOCITY, FLICK_VELOCITY_SAMPLES, VIEWPORT_ROTATE_SNAP_INTERVAL, VIEWPORT_SCROLL_RATE, VIEWPORT_ZOOM_LEVELS, VIEWPORT_ZOOM_MIN_FRACTION_COVER, + VIEWPORT_ZOOM_MOUSE_RATE, VIEWPORT_ZOOM_SCALE_MAX, VIEWPORT_ZOOM_SCALE_MIN, VIEWPORT_ZOOM_TO_FIT_PADDING_SCALE_FACTOR, }; use crate::messages::frontend::utility_types::MouseCursorIcon; use crate::messages::input_mapper::utility_types::input_keyboard::{Key, MouseMotion}; @@ -31,6 +31,8 @@ pub struct NavigationMessageHandler { mouse_position: ViewportPosition, finish_operation_with_click: bool, abortable_pan_start: Option, + flick_position_history: VecDeque<(ViewportPosition, f64)>, + flick_velocity: DVec2, } #[message_handler_data] @@ -79,6 +81,15 @@ impl MessageHandler> for Navigat HintData(vec![HintGroup(vec![HintInfo::mouse(MouseMotion::Rmb, ""), HintInfo::keys([Key::Escape], "Cancel").prepend_slash()])]).send_layout(responses); + self.flick_position_history.clear(); + if self.flick_velocity != DVec2::ZERO { + self.flick_velocity = DVec2::ZERO; + responses.add(BroadcastMessage::UnsubscribeEvent { + on: EventMessage::AnimationFrame, + send: Box::new(NavigationMessage::FlickPanUpdate.into()), + }); + } + self.mouse_position = ipp.mouse.position; let Some(ptz) = get_ptz(document_ptz, network_interface, graph_view_overlay_open, breadcrumb_network_path) else { return; @@ -321,9 +332,36 @@ impl MessageHandler> for Navigat } else { responses.add(PortfolioMessage::UpdateDocumentWidgets); } + + let was_panning = matches!(self.navigation_operation, NavigationOperation::Pan { .. }); + // Reset the navigation operation now that it's done self.navigation_operation = NavigationOperation::None; + if was_panning && !abort_transform && preferences.flick_panning && self.flick_position_history.len() >= 2 { + let (first_pos, first_time) = self.flick_position_history.front().unwrap(); + let (last_pos, last_time) = self.flick_position_history.back().unwrap(); + + let delta_time = last_time - first_time; + if delta_time >= 0.001 { + let delta_pos = DVec2::new(last_pos.x - first_pos.x, last_pos.y - first_pos.y); + let velocity_per_second = delta_pos / delta_time; + self.flick_velocity = velocity_per_second / 60.0; + + let speed = self.flick_velocity.length(); + if speed > FLICK_MAX_VELOCITY { + self.flick_velocity = self.flick_velocity.normalize() * FLICK_MAX_VELOCITY; + } + + if speed >= FLICK_MIN_VELOCITY { + responses.add(BroadcastMessage::SubscribeEvent { + on: EventMessage::AnimationFrame, + send: Box::new(NavigationMessage::FlickPanUpdate.into()), + }); + } + } + } + // Send the final messages to close out the operation responses.add(EventMessage::CanvasTransformed); responses.add(ToolMessage::UpdateCursor); @@ -408,6 +446,12 @@ impl MessageHandler> for Navigat match self.navigation_operation { NavigationOperation::None => {} NavigationOperation::Pan { .. } => { + let now = ipp.time as f64 / 1000.0; + self.flick_position_history.push_back((ipp.mouse.position, now)); + while self.flick_position_history.len() > FLICK_VELOCITY_SAMPLES { + self.flick_position_history.pop_front(); + } + let delta = ipp.mouse.position - self.mouse_position; responses.add(NavigationMessage::CanvasPan { delta }); } @@ -481,6 +525,28 @@ impl MessageHandler> for Navigat self.mouse_position = ipp.mouse.position; } + NavigationMessage::FlickPanUpdate => { + if self.flick_velocity == DVec2::ZERO { + return; + } + + let delta_seconds = ipp.frame_time.frame_duration().map(|d| d.as_secs_f64()).unwrap_or(1.0 / 60.0); + let safe_delta = delta_seconds.min(0.1); + self.flick_velocity *= FLICK_DECAY_RATE.powf(safe_delta * 60.0); + + if self.flick_velocity.length() < FLICK_MIN_VELOCITY { + self.flick_position_history.clear(); + self.flick_velocity = DVec2::ZERO; + responses.add(BroadcastMessage::UnsubscribeEvent { + on: EventMessage::AnimationFrame, + send: Box::new(NavigationMessage::FlickPanUpdate.into()), + }); + return; + } + + let delta = self.flick_velocity * (safe_delta * 60.0); + responses.add(NavigationMessage::CanvasPan { delta }); + } } } diff --git a/editor/src/messages/preferences/preferences_message.rs b/editor/src/messages/preferences/preferences_message.rs index 6408cd5d2f..83e5f69c36 100644 --- a/editor/src/messages/preferences/preferences_message.rs +++ b/editor/src/messages/preferences/preferences_message.rs @@ -14,6 +14,7 @@ pub enum PreferencesMessage { SelectionMode { selection_mode: SelectionMode }, BrushTool { enabled: bool }, ModifyLayout { zoom_with_scroll: bool }, + FlickPanning { enabled: bool }, GraphWireStyle { style: GraphWireStyle }, ViewportZoomWheelRate { rate: f64 }, UIScale { scale: f64 }, diff --git a/editor/src/messages/preferences/preferences_message_handler.rs b/editor/src/messages/preferences/preferences_message_handler.rs index 6321119c5a..a524eef505 100644 --- a/editor/src/messages/preferences/preferences_message_handler.rs +++ b/editor/src/messages/preferences/preferences_message_handler.rs @@ -16,6 +16,7 @@ pub struct PreferencesMessageContext<'a> { pub struct PreferencesMessageHandler { pub selection_mode: SelectionMode, pub zoom_with_scroll: bool, + pub flick_panning: bool, pub use_vello: bool, pub brush_tool: bool, pub graph_wire_style: GraphWireStyle, @@ -44,6 +45,7 @@ impl Default for PreferencesMessageHandler { Self { selection_mode: SelectionMode::Touched, zoom_with_scroll: matches!(MappingVariant::default(), MappingVariant::ZoomWithScroll), + flick_panning: false, use_vello: EditorPreferences::default().use_vello, brush_tool: false, graph_wire_style: GraphWireStyle::default(), @@ -100,6 +102,9 @@ impl MessageHandler> for Prefe let variant = if zoom_with_scroll { MappingVariant::ZoomWithScroll } else { MappingVariant::Default }; responses.add(KeyMappingMessage::ModifyMapping { mapping: variant }); } + PreferencesMessage::FlickPanning { enabled } => { + self.flick_panning = enabled; + } PreferencesMessage::SelectionMode { selection_mode } => { self.selection_mode = selection_mode; }