Skip to content

Commit c776eed

Browse files
committed
Drag: swap OS image dynamically on self-drags
- Inside window: transparent OS image, DOM overlay takes over - Outside window: rich canvas PNG restored by draggingExited: swizzle - Swizzles draggingExited: to swap items back to rich image on window exit - prepareSelfDragOverlay / clearSelfDragOverlay Tauri commands manage the state - State survives leave/re-enter cycles; only cleared on drop - Temp file kept alive for the full drag session (startDrag resolves early on macOS) This is actually quite magical ✨ that we managed this! It was absolutely not trivial!
1 parent 521ab5e commit c776eed

20 files changed

Lines changed: 553 additions & 65 deletions

apps/desktop/coverage-allowlist.json

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@
77
"updates/UpdateNotification.svelte": { "reason": "UI component, needs component testing" },
88
"app-status-store.ts": { "reason": "Depends on Tauri APIs" },
99
"benchmark.ts": { "reason": "Dev tooling, not critical path" },
10-
"file-explorer/drag-drop.ts": { "reason": "Needs integration testing with Tauri" },
11-
"file-explorer/drag-image-renderer.ts": { "reason": "Canvas API dependent, pure logic tested separately" },
12-
"file-explorer/DragOverlay.svelte": { "reason": "UI overlay component, state tested in drag-overlay tests" },
13-
"file-explorer/drag-overlay.svelte.ts": {
10+
"file-explorer/drag/drag-drop.ts": { "reason": "Needs integration testing with Tauri" },
11+
"file-explorer/drag/drag-image-renderer.ts": { "reason": "Canvas API dependent, pure logic tested separately" },
12+
"file-explorer/drag/DragOverlay.svelte": {
13+
"reason": "UI overlay component, state tested in drag-overlay tests"
14+
},
15+
"file-explorer/drag/drag-overlay.svelte.ts": {
1416
"reason": "Reactive Svelte state, pure logic tested in drag-overlay tests"
1517
},
1618
"file-explorer/modifier-key-tracker.svelte.ts": {

apps/desktop/src-tauri/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ objc2-foundation = { version = "0.3", features = [
8787
"NSFileManager",
8888
] }
8989
mdns-sd = { version = "0.17", features = ["logging"] }
90-
objc2-app-kit = { version = "0.3", features = ["NSDragging", "NSDraggingItem"] }
90+
objc2-app-kit = { version = "0.3", features = ["NSDragging", "NSDraggingItem", "NSImage"] }
9191
block2 = "0.6"
9292
smb = "0.11.1"
9393
smb-rpc = "=0.11.1"

apps/desktop/src-tauri/src/commands/file_system.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -533,6 +533,36 @@ pub struct SourceItemInput {
533533
pub modified: Option<i64>,
534534
}
535535

536+
// ============================================================================
537+
// Self-drag overlay (dynamic drag image swapping)
538+
// ============================================================================
539+
540+
/// Marks a self-drag as active and stores the rich image path so the native swizzle can:
541+
/// - Hide the OS drag image over our window (swap to transparent in `draggingEntered:`)
542+
/// - Show the rich image outside the window (swap back in `draggingExited:`)
543+
#[cfg(target_os = "macos")]
544+
#[tauri::command]
545+
pub fn prepare_self_drag_overlay(rich_image_path: String) {
546+
crate::drag_image_swap::set_self_drag_active(rich_image_path);
547+
}
548+
549+
/// No-op on non-macOS platforms.
550+
#[cfg(not(target_os = "macos"))]
551+
#[tauri::command]
552+
pub fn prepare_self_drag_overlay(_rich_image_path: String) {}
553+
554+
/// Clears self-drag state after drop or cancellation.
555+
#[cfg(target_os = "macos")]
556+
#[tauri::command]
557+
pub fn clear_self_drag_overlay() {
558+
crate::drag_image_swap::clear_self_drag_state();
559+
}
560+
561+
/// No-op on non-macOS platforms.
562+
#[cfg(not(target_os = "macos"))]
563+
#[tauri::command]
564+
pub fn clear_self_drag_overlay() {}
565+
536566
/// Expands tilde (~) to the user's home directory.
537567
fn expand_tilde(path: &str) -> String {
538568
if (path.starts_with("~/") || path == "~")

apps/desktop/src-tauri/src/drag_image_detection.rs

Lines changed: 87 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
//! Native drag interception for macOS via method swizzling on WryWebView.
22
//!
3-
//! Swizzles `draggingEntered:` and `draggingUpdated:` to:
3+
//! Swizzles `draggingEntered:`, `draggingUpdated:`, and `draggingExited:` to:
44
//! 1. Read drag image dimensions via `enumerateDraggingItems` (for overlay suppression)
55
//! 2. Read modifier key state via `[NSEvent modifierFlags]` (for copy/move detection)
6+
//! 3. Swap the OS drag image for self-drags (delegated to `drag_image_swap`)
67
//!
78
//! Events emitted:
89
//! - `drag-image-size` `{ width, height }` — on drag enter
@@ -14,6 +15,7 @@
1415
//! internal webview class or macOS deprecates APIs we rely on, the swizzle degrades gracefully:
1516
//! - Drag image detection disabled → the DOM overlay is always shown (redundant but functional)
1617
//! - Modifier key detection disabled → falls back to JS keydown/keyup (works when webview has focus)
18+
//! - Image swapping disabled → self-drags show the OS drag image over the window (functional)
1719
//!
1820
//! Rust panics inside swizzled functions are caught via `catch_unwind` to prevent crashes
1921
//! across the FFI boundary. Warning messages include actionable guidance for future maintainers.
@@ -26,10 +28,12 @@ use std::sync::atomic::{AtomicBool, Ordering};
2628
use objc2::runtime::{AnyClass, AnyObject, Bool, Imp, Sel};
2729
use objc2::{msg_send, sel};
2830
use objc2_app_kit::{NSDragOperation, NSDraggingItem, NSDraggingItemEnumerationOptions};
29-
use objc2_foundation::{NSDictionary, NSInteger, NSRect};
31+
use objc2_foundation::{NSDictionary, NSInteger, NSRect, NSSize};
3032
use serde::Serialize;
3133
use tauri::{AppHandle, Emitter};
3234

35+
use crate::drag_image_swap;
36+
3337
/// NSEventModifierFlagOption = 1 << 19 (Option/Alt key)
3438
const NS_EVENT_MODIFIER_FLAG_OPTION: usize = 1 << 19;
3539

@@ -47,6 +51,7 @@ struct DragModifiers {
4751

4852
static ORIGINAL_ENTERED_IMP: OnceLock<Imp> = OnceLock::new();
4953
static ORIGINAL_UPDATED_IMP: OnceLock<Imp> = OnceLock::new();
54+
static ORIGINAL_EXITED_IMP: OnceLock<Imp> = OnceLock::new();
5055
static APP_HANDLE: OnceLock<AppHandle> = OnceLock::new();
5156

5257
/// Tracks previous alt state so we only emit `drag-modifiers` when it changes.
@@ -56,11 +61,12 @@ static LAST_ALT_HELD: AtomicBool = AtomicBool::new(false);
5661
static WARNED_NSEVENT_MISSING: AtomicBool = AtomicBool::new(false);
5762
static WARNED_ENTERED_PANIC: AtomicBool = AtomicBool::new(false);
5863
static WARNED_UPDATED_PANIC: AtomicBool = AtomicBool::new(false);
64+
static WARNED_EXITED_PANIC: AtomicBool = AtomicBool::new(false);
5965
static WARNED_NSARRAY_MISSING: AtomicBool = AtomicBool::new(false);
6066
static WARNED_DRAGGED_IMAGE_REMOVED: AtomicBool = AtomicBool::new(false);
6167

6268
/// Logs a warning message at most once per app session.
63-
fn warn_once(flag: &AtomicBool, msg: &str) {
69+
pub(crate) fn warn_once(flag: &AtomicBool, msg: &str) {
6470
if !flag.swap(true, Ordering::Relaxed) {
6571
log::warn!("{msg}");
6672
}
@@ -112,10 +118,27 @@ pub fn install(app_handle: AppHandle) {
112118
);
113119
}
114120

121+
// Swizzle draggingExited: for self-drag image swapping (transparent → rich on window exit)
122+
if let Some(method) = cls.instance_method(sel!(draggingExited:)) {
123+
ORIGINAL_EXITED_IMP.set(method.implementation()).ok();
124+
method.set_implementation(std::mem::transmute::<*const (), Imp>(
125+
swizzled_dragging_exited as *const (),
126+
));
127+
} else {
128+
log::warn!(
129+
"drag_image_detection: draggingExited: not found on WryWebView — \
130+
drag image swapping on window exit is disabled. \
131+
Wry may have changed how it implements NSDraggingDestination; \
132+
check wry's drag-and-drop event handling in its ObjC layer."
133+
);
134+
}
135+
115136
log::info!("drag_image_detection: swizzles installed on WryWebView");
116137
}
117138
}
118139

140+
// --- Modifier key detection ---
141+
119142
/// Reads the current Option/Alt key state from `[NSEvent modifierFlags]`.
120143
/// This is a class method that reads hardware state — works even when the webview isn't focused.
121144
/// Returns `false` if NSEvent can't be found (graceful degradation).
@@ -158,29 +181,46 @@ fn emit_modifiers_forced() {
158181

159182
/// Forwards to wry's original `draggingEntered:`. Always safe to call — returns
160183
/// `NSDragOperation::Copy` if the original wasn't saved (shouldn't happen in practice).
161-
unsafe fn call_original_entered(this: &AnyObject, cmd: Sel, drag_info: &AnyObject) -> usize { unsafe {
162-
if let Some(&original) = ORIGINAL_ENTERED_IMP.get() {
163-
let f = std::mem::transmute::<Imp, unsafe extern "C-unwind" fn(&AnyObject, Sel, &AnyObject) -> usize>(original);
164-
f(this, cmd, drag_info)
165-
} else {
166-
NSDragOperation::Copy.0
184+
unsafe fn call_original_entered(this: &AnyObject, cmd: Sel, drag_info: &AnyObject) -> usize {
185+
unsafe {
186+
if let Some(&original) = ORIGINAL_ENTERED_IMP.get() {
187+
let f =
188+
std::mem::transmute::<Imp, unsafe extern "C-unwind" fn(&AnyObject, Sel, &AnyObject) -> usize>(original);
189+
f(this, cmd, drag_info)
190+
} else {
191+
NSDragOperation::Copy.0
192+
}
167193
}
168-
}}
194+
}
169195

170196
/// Forwards to wry's original `draggingUpdated:`. Same fallback as above.
171-
unsafe fn call_original_updated(this: &AnyObject, cmd: Sel, drag_info: &AnyObject) -> usize { unsafe {
172-
if let Some(&original) = ORIGINAL_UPDATED_IMP.get() {
173-
let f = std::mem::transmute::<Imp, unsafe extern "C-unwind" fn(&AnyObject, Sel, &AnyObject) -> usize>(original);
174-
f(this, cmd, drag_info)
175-
} else {
176-
NSDragOperation::Copy.0
197+
unsafe fn call_original_updated(this: &AnyObject, cmd: Sel, drag_info: &AnyObject) -> usize {
198+
unsafe {
199+
if let Some(&original) = ORIGINAL_UPDATED_IMP.get() {
200+
let f =
201+
std::mem::transmute::<Imp, unsafe extern "C-unwind" fn(&AnyObject, Sel, &AnyObject) -> usize>(original);
202+
f(this, cmd, drag_info)
203+
} else {
204+
NSDragOperation::Copy.0
205+
}
177206
}
178-
}}
207+
}
208+
209+
/// Forwards to wry's original `draggingExited:`. Returns void.
210+
/// If the original wasn't saved, this is a no-op (drag exit still works, wry just won't fire its handler).
211+
unsafe fn call_original_exited(this: &AnyObject, cmd: Sel, drag_info: &AnyObject) {
212+
unsafe {
213+
if let Some(&original) = ORIGINAL_EXITED_IMP.get() {
214+
let f = std::mem::transmute::<Imp, unsafe extern "C-unwind" fn(&AnyObject, Sel, &AnyObject)>(original);
215+
f(this, cmd, drag_info)
216+
}
217+
}
218+
}
179219

180220
// --- draggingEntered: swizzle ---
181221

182222
unsafe extern "C-unwind" fn swizzled_dragging_entered(this: &AnyObject, cmd: Sel, drag_info: &AnyObject) -> usize {
183-
// Our custom logic: read drag image size and emit modifier events.
223+
// Our custom logic: read drag image size, emit modifier events, and swap image for self-drags.
184224
// Wrapped in catch_unwind to prevent any unexpected Rust panic from crossing the FFI boundary
185225
// and crashing the app mid-drag.
186226
let result = catch_unwind(AssertUnwindSafe(|| {
@@ -198,6 +238,11 @@ unsafe extern "C-unwind" fn swizzled_dragging_entered(this: &AnyObject, cmd: Sel
198238

199239
// Always emit modifiers on enter (initial state for this drag session)
200240
emit_modifiers_forced();
241+
242+
// For self-drags, swap the OS drag image to transparent so it's invisible inside the window.
243+
// The rich PNG (set at drag start) remains as the session image shown outside the window.
244+
// The DOM overlay handles the visual feedback inside.
245+
unsafe { drag_image_swap::on_drag_entered(drag_info) };
201246
}));
202247

203248
if result.is_err() {
@@ -236,6 +281,29 @@ unsafe extern "C-unwind" fn swizzled_dragging_updated(this: &AnyObject, cmd: Sel
236281
unsafe { call_original_updated(this, cmd, drag_info) }
237282
}
238283

284+
// --- draggingExited: swizzle ---
285+
286+
unsafe extern "C-unwind" fn swizzled_dragging_exited(this: &AnyObject, cmd: Sel, drag_info: &AnyObject) {
287+
// Swap back to the rich image so it's visible outside the window.
288+
// setDraggingFrame:contents: modifications persist globally, so the transparent image
289+
// from draggingEntered: would remain visible outside without this swap-back.
290+
let result = catch_unwind(AssertUnwindSafe(|| {
291+
unsafe { drag_image_swap::on_drag_exited(drag_info) };
292+
}));
293+
294+
if result.is_err() {
295+
warn_once(
296+
&WARNED_EXITED_PANIC,
297+
"drag_image_detection: panic in draggingExited swizzle — drag image swap-back \
298+
to rich preview is disabled for this session. \
299+
Check NSImage and NSDraggingItem usage in drag_image_detection.rs.",
300+
);
301+
}
302+
303+
// Always forward to wry's original implementation.
304+
unsafe { call_original_exited(this, cmd, drag_info) }
305+
}
306+
239307
// --- Drag image size reading ---
240308

241309
unsafe fn read_drag_image_size(drag_info: &AnyObject) -> (f64, f64) {
@@ -262,7 +330,7 @@ unsafe fn read_drag_image_size(drag_info: &AnyObject) -> (f64, f64) {
262330

263331
let image: *const AnyObject = unsafe { msg_send![drag_info, draggedImage] };
264332
if !image.is_null() {
265-
let ns_size: objc2_foundation::NSSize = unsafe { msg_send![image, size] };
333+
let ns_size: NSSize = unsafe { msg_send![image, size] };
266334
if ns_size.width > 0.0 || ns_size.height > 0.0 {
267335
return (ns_size.width, ns_size.height);
268336
}

0 commit comments

Comments
 (0)