diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index 37cca13acb..1ce553011d 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -674,6 +674,18 @@ impl GpuiMode { } } +struct PlatformOwnedDrag { + source_window: WindowId, + state: PlatformOwnedDragState, +} + +enum PlatformOwnedDragState { + Suspended(AnyDrag), + // A source-window drop consumes `active_drag` before AppKit ends the dragging session, so this + // marker can outlive the active drag and is cleaned up by `FileDropEvent::Ended`. + RestoredInSourceWindow, +} + /// Contains the state of the full application, and passed as a reference to a variety of callbacks. /// Other [Context] derefs to this type. /// You need a reference to an `App` to access the state of a [Entity]. @@ -684,6 +696,7 @@ pub struct App { pub(crate) actions: Rc, pub(crate) active_drag: Option, + platform_owned_drag: Option, pub(crate) background_executor: BackgroundExecutor, pub(crate) foreground_executor: ForegroundExecutor, pub(crate) entities: EntityMap, @@ -798,6 +811,7 @@ impl App { flushing_effects: false, pending_updates: 0, active_drag: None, + platform_owned_drag: None, background_executor, foreground_executor, svg_renderer: SvgRenderer::new(asset_source.clone()), @@ -1799,6 +1813,7 @@ impl App { cx.window_update_stack.pop(); if window.removed { + cx.end_platform_drag(id); cx.window_handles.remove(&id); cx.windows.remove(id); if let Some(tracked) = cx.tracked_entities.remove(&id) { @@ -2440,6 +2455,12 @@ impl App { pub fn stop_active_drag(&mut self, window: &mut Window) -> bool { if self.active_drag.is_some() { self.active_drag = None; + if self.platform_owned_drag.as_ref().is_some_and(|drag| { + drag.source_window == window.window_handle().window_id() + && matches!(&drag.state, PlatformOwnedDragState::RestoredInSourceWindow) + }) { + self.platform_owned_drag = None; + } window.refresh(); true } else { @@ -2447,6 +2468,63 @@ impl App { } } + pub(crate) fn hand_active_drag_to_platform(&mut self, source_window: WindowId) -> bool { + let Some(drag) = self.active_drag.take() else { + return false; + }; + self.platform_owned_drag = Some(PlatformOwnedDrag { + source_window, + state: PlatformOwnedDragState::Suspended(drag), + }); + true + } + + pub(crate) fn restore_platform_drag(&mut self, source_window: WindowId) -> bool { + let Some(platform_drag) = self + .platform_owned_drag + .as_mut() + .filter(|drag| drag.source_window == source_window) + else { + return false; + }; + let state = std::mem::replace( + &mut platform_drag.state, + PlatformOwnedDragState::RestoredInSourceWindow, + ); + let PlatformOwnedDragState::Suspended(drag) = state else { + return false; + }; + self.active_drag = Some(drag); + true + } + + pub(crate) fn hand_restored_drag_to_platform(&mut self, source_window: WindowId) -> bool { + let Some(platform_drag) = self.platform_owned_drag.as_mut().filter(|drag| { + drag.source_window == source_window + && matches!(&drag.state, PlatformOwnedDragState::RestoredInSourceWindow) + }) else { + return false; + }; + let Some(drag) = self.active_drag.take() else { + return false; + }; + platform_drag.state = PlatformOwnedDragState::Suspended(drag); + true + } + + pub(crate) fn end_platform_drag(&mut self, source_window: WindowId) -> bool { + if !self + .platform_owned_drag + .as_ref() + .is_some_and(|drag| drag.source_window == source_window) + { + return false; + } + self.platform_owned_drag = None; + self.active_drag = None; + true + } + /// Sets the cursor style for the currently active drag operation. pub fn set_active_drag_cursor_style( &mut self, diff --git a/crates/gpui/src/interactive.rs b/crates/gpui/src/interactive.rs index 94bf4ed9ae..872bf91c60 100644 --- a/crates/gpui/src/interactive.rs +++ b/crates/gpui/src/interactive.rs @@ -745,6 +745,8 @@ pub enum FileDropEvent { }, /// The user has stopped dragging the files over the window. Exited, + /// The platform-owned drag session has ended. + Ended, } impl Sealed for FileDropEvent {} diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index de525d4545..c5087d771a 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -4956,7 +4956,8 @@ impl Window { PlatformInput::FileDrop(file_drop) => match file_drop { FileDropEvent::Entered { position, paths } => { self.mouse_position = position; - if cx.active_drag.is_none() { + let source_window = self.handle.window_id(); + if !cx.restore_platform_drag(source_window) && cx.active_drag.is_none() { cx.active_drag = Some(AnyDrag { value: Arc::new(paths.clone()), view: cx.new(|_| paths).into(), @@ -4990,9 +4991,17 @@ impl Window { }) } FileDropEvent::Exited => { - cx.active_drag.take(); + if !cx.hand_restored_drag_to_platform(self.handle.window_id()) { + cx.active_drag.take(); + } + self.refresh(); PlatformInput::FileDrop(FileDropEvent::Exited) } + FileDropEvent::Ended => { + cx.end_platform_drag(self.handle.window_id()); + self.refresh(); + PlatformInput::FileDrop(FileDropEvent::Ended) + } }, PlatformInput::Touch(touch) => PlatformInput::Touch(touch), PlatformInput::KeyDown(_) | PlatformInput::KeyUp(_) => event, @@ -5047,8 +5056,10 @@ impl Window { let Some(payload) = payload_source(self, cx) else { return; }; - if self.platform_window.start_external_drag(&payload) { - cx.stop_active_drag(self); + if self.platform_window.start_external_drag(&payload) + && cx.hand_active_drag_to_platform(self.handle.window_id()) + { + self.refresh(); } } @@ -6741,10 +6752,11 @@ mod tests { use crate::{ AnyWindowHandle, AppContext as _, Bounds, Context, DragMoveEvent, Empty, - ExternalDragPayload, FileDragPaths, FocusHandle, InputEvent as _, InteractiveElement as _, - IntoElement, MouseButton, MouseDownEvent, MouseMoveEvent, ParentElement, Pixels, Point, - Render, StatefulInteractiveElement as _, Styled, TestAppContext, Window, WindowAppearance, - WindowOptions, canvas, div, point, px, size, + ExternalDragPayload, ExternalPaths, FileDragPaths, FileDropEvent, FocusHandle, + InputEvent as _, InteractiveElement as _, IntoElement, MouseButton, MouseDownEvent, + MouseMoveEvent, ParentElement, Pixels, Point, Render, StatefulInteractiveElement as _, + Styled, TestAppContext, Window, WindowAppearance, WindowOptions, canvas, div, point, px, + size, }; struct EmptyView; @@ -6898,6 +6910,7 @@ mod tests { struct FileDragView { path: PathBuf, observed_drag_moves: Rc>>>, + observed_drops: Rc>>, } impl Render for FileDragView { @@ -6918,24 +6931,32 @@ mod tests { observed_drag_moves.borrow_mut().push(event.event.position); } }) + .on_drop({ + let observed_drops = self.observed_drops.clone(); + move |path: &PathBuf, _, _| observed_drops.borrow_mut().push(path.clone()) + }) } } #[gpui::test] - fn file_drag_is_promoted_once_after_leaving_the_viewport(cx: &mut TestAppContext) { + fn file_drag_is_promoted_once_and_restored_in_source_window(cx: &mut TestAppContext) { struct Drag { window: AnyWindowHandle, observed_drag_moves: Rc>>>, + observed_drops: Rc>>, } fn start_drag(cx: &mut TestAppContext, path: PathBuf, platform_result: bool) -> Drag { let observed_drag_moves = Rc::new(RefCell::new(Vec::new())); + let observed_drops = Rc::new(RefCell::new(Vec::new())); let window: AnyWindowHandle = cx .add_window({ let observed_drag_moves = observed_drag_moves.clone(); + let observed_drops = observed_drops.clone(); move |_, _| FileDragView { path, observed_drag_moves, + observed_drops, } }) .into(); @@ -6975,6 +6996,7 @@ mod tests { Drag { window, observed_drag_moves, + observed_drops, } } @@ -6999,7 +7021,7 @@ mod tests { ); assert_eq!( cx.test_window(successful.window).external_drag_files(), - [(successful_path, true)] + [(successful_path.clone(), true)] ); // Views must still see the move that leaves the window, otherwise they never learn to tear // down the drag state they built up while the pointer was inside. @@ -7008,6 +7030,163 @@ mod tests { Some(&outside_position) ); + let destination: AnyWindowHandle = cx.add_window(|_, _| EmptyView).into(); + let reentry_position = point(px(30.), px(30.)); + let external_paths = || ExternalPaths([successful_path.clone()].into_iter().collect()); + let update_result = cx.update_window(destination, |_, window, cx| { + window.dispatch_event( + FileDropEvent::Entered { + position: reentry_position, + paths: external_paths(), + } + .to_platform_input(), + cx, + ); + assert!( + cx.active_drag + .as_ref() + .is_some_and(|drag| drag.value.downcast_ref::().is_some()) + ); + window.dispatch_event(FileDropEvent::Exited.to_platform_input(), cx); + assert!(cx.active_drag.is_none()); + }); + assert!( + update_result.is_ok(), + "failed to handle drag in destination window: {update_result:?}" + ); + + let update_result = cx.update_window(successful.window, |_, window, cx| { + window.dispatch_event( + FileDropEvent::Entered { + position: reentry_position, + paths: external_paths(), + } + .to_platform_input(), + cx, + ); + assert!( + cx.active_drag + .as_ref() + .is_some_and(|drag| drag.value.downcast_ref::().is_some()) + ); + assert_eq!( + successful.observed_drag_moves.borrow().last(), + Some(&reentry_position) + ); + + window.dispatch_event(FileDropEvent::Exited.to_platform_input(), cx); + assert!(cx.active_drag.is_none()); + + window.dispatch_event( + FileDropEvent::Entered { + position: reentry_position, + paths: external_paths(), + } + .to_platform_input(), + cx, + ); + assert!( + cx.active_drag + .as_ref() + .is_some_and(|drag| drag.value.downcast_ref::().is_some()) + ); + + window.dispatch_event( + FileDropEvent::Submit { + position: reentry_position, + } + .to_platform_input(), + cx, + ); + assert_eq!( + successful.observed_drops.borrow().as_slice(), + std::slice::from_ref(&successful_path) + ); + assert!(cx.active_drag.is_none()); + + window.dispatch_event(FileDropEvent::Exited.to_platform_input(), cx); + assert!(cx.active_drag.is_none()); + window.dispatch_event(FileDropEvent::Ended.to_platform_input(), cx); + assert!(cx.active_drag.is_none()); + + window.dispatch_event( + FileDropEvent::Entered { + position: reentry_position, + paths: external_paths(), + } + .to_platform_input(), + cx, + ); + assert!( + cx.active_drag + .as_ref() + .is_some_and(|drag| drag.value.downcast_ref::().is_some()) + ); + window.dispatch_event(FileDropEvent::Exited.to_platform_input(), cx); + }); + assert!( + update_result.is_ok(), + "failed to restore drag in source window: {update_result:?}" + ); + + let cancelled_path = PathBuf::from("/tmp/cancelled-drag"); + let cancelled = start_drag(cx, cancelled_path.clone(), true); + let update_result = cx.update_window(cancelled.window, |_, window, cx| { + window.dispatch_event( + MouseMoveEvent { + position: outside_position, + pressed_button: Some(MouseButton::Left), + modifiers: Default::default(), + } + .to_platform_input(), + cx, + ); + assert!(cx.active_drag.is_none()); + + window.dispatch_event( + FileDropEvent::Entered { + position: reentry_position, + paths: ExternalPaths([cancelled_path].into_iter().collect()), + } + .to_platform_input(), + cx, + ); + assert!( + cx.active_drag + .as_ref() + .is_some_and(|drag| drag.value.downcast_ref::().is_some()) + ); + assert!(cx.stop_active_drag(window)); + assert!(cx.active_drag.is_none()); + }); + assert!( + update_result.is_ok(), + "failed to cancel restored drag: {update_result:?}" + ); + assert!(!cx.update(|cx| cx.end_platform_drag(cancelled.window.window_id()))); + + let removed_path = PathBuf::from("/tmp/removed-window-drag"); + let removed = start_drag(cx, removed_path, true); + let removed_window_id = removed.window.window_id(); + let update_result = cx.update_window(removed.window, |_, window, cx| { + window.dispatch_event( + MouseMoveEvent { + position: outside_position, + pressed_button: Some(MouseButton::Left), + modifiers: Default::default(), + } + .to_platform_input(), + cx, + ); + assert!(cx.active_drag.is_none()); + window.remove_window(); + }); + assert!( + update_result.is_ok(), + "failed to remove drag source window: {update_result:?}" + ); + assert!(!cx.update(|cx| cx.end_platform_drag(removed_window_id))); + let failed_path = PathBuf::from("/tmp/failed-drag"); let failed = start_drag(cx, failed_path.clone(), false); let update_result = cx.update_window(failed.window, |_, window, cx| { diff --git a/crates/gpui_macos/src/window.rs b/crates/gpui_macos/src/window.rs index cb4e3cb752..6b8a8a6be3 100644 --- a/crates/gpui_macos/src/window.rs +++ b/crates/gpui_macos/src/window.rs @@ -3070,45 +3070,42 @@ fn is_drag_from_this_window(this: &Object, dragging_info: id) -> bool { } extern "C" fn dragging_entered(this: &Object, _: Sel, dragging_info: id) -> NSDragOperation { - if is_drag_from_this_window(this, dragging_info) { - return NSDragOperationNone; - } + let is_source_window = is_drag_from_this_window(this, dragging_info); let window_state = unsafe { get_window_state(this) }; let position = drag_event_position(&window_state, dragging_info); let paths = external_paths_from_event(dragging_info); if let Some(event) = paths.map(|paths| FileDropEvent::Entered { position, paths }) && send_file_drop_event(window_state, event) { + if is_source_window { + return NSDragOperationMove; + } return NSDragOperationCopy; } NSDragOperationNone } extern "C" fn dragging_updated(this: &Object, _: Sel, dragging_info: id) -> NSDragOperation { - if is_drag_from_this_window(this, dragging_info) { - return NSDragOperationNone; - } + let is_source_window = is_drag_from_this_window(this, dragging_info); let window_state = unsafe { get_window_state(this) }; let position = drag_event_position(&window_state, dragging_info); if send_file_drop_event(window_state, FileDropEvent::Pending { position }) { - NSDragOperationCopy + if is_source_window { + NSDragOperationMove + } else { + NSDragOperationCopy + } } else { NSDragOperationNone } } -extern "C" fn dragging_exited(this: &Object, _: Sel, dragging_info: id) { - if is_drag_from_this_window(this, dragging_info) { - return; - } +extern "C" fn dragging_exited(this: &Object, _: Sel, _: id) { let window_state = unsafe { get_window_state(this) }; send_file_drop_event(window_state, FileDropEvent::Exited); } extern "C" fn perform_drag_operation(this: &Object, _: Sel, dragging_info: id) -> BOOL { - if is_drag_from_this_window(this, dragging_info) { - return NO; - } let window_state = unsafe { get_window_state(this) }; let position = drag_event_position(&window_state, dragging_info); send_file_drop_event(window_state, FileDropEvent::Submit { position }).to_objc() @@ -3166,9 +3163,12 @@ extern "C" fn dragging_session_ended( // SAFETY: AppKit invokes this selector on the GPUIWindow instance registered in build_classes, // which always has WINDOW_STATE_IVAR initialized to the owning MacWindowState. let window_state = unsafe { get_window_state(this) }; - let mut lock = window_state.lock(); - lock.synthetic_drag_counter += 1; - lock.last_left_mouse_down_event = None; + { + let mut lock = window_state.lock(); + lock.synthetic_drag_counter += 1; + lock.last_left_mouse_down_event = None; + } + send_file_drop_event(window_state, FileDropEvent::Ended); } async fn synthetic_drag( @@ -3202,7 +3202,7 @@ fn send_file_drop_event( ) -> bool { let external_files_dragged = match file_drop_event { FileDropEvent::Entered { .. } => Some(true), - FileDropEvent::Exited => Some(false), + FileDropEvent::Exited | FileDropEvent::Ended => Some(false), _ => None, };