From 9de0c9423b43923e623b217345a079bd2adfc20b Mon Sep 17 00:00:00 2001 From: Arnab Chakraborty <11457760+Rocky43007@users.noreply.github.com> Date: Mon, 30 Dec 2024 21:47:28 +0300 Subject: [PATCH] Better Drag & Drop into OS Now it doesn't freeze the entire app and UI, and the drag & drop into OS system is only called when the mouse leaves the app's boundaries! However, the event/system doesn't cancel when you re-enter the application, and is something I'm still working on. --- .zed/settings.json | 66 ++++++ Cargo.lock | Bin 342323 -> 342350 bytes apps/desktop/src-tauri/Cargo.toml | 4 +- apps/desktop/src-tauri/src/drag.rs | 223 ++++++++++++++++++ apps/desktop/src-tauri/src/main.rs | 4 +- apps/desktop/src/App.tsx | 134 ++++++++++- apps/desktop/src/commands.ts | 28 +++ .../Explorer/View/GridView/Item/index.tsx | 32 --- interface/app/$libraryId/Explorer/index.tsx | 20 +- 9 files changed, 461 insertions(+), 50 deletions(-) create mode 100644 .zed/settings.json create mode 100644 apps/desktop/src-tauri/src/drag.rs diff --git a/.zed/settings.json b/.zed/settings.json new file mode 100644 index 000000000..fc6bb453b --- /dev/null +++ b/.zed/settings.json @@ -0,0 +1,66 @@ +// Folder-specific settings +// +// For a full list of overridable settings, and general information on folder-specific settings, +// see the documentation: https://zed.dev/docs/configuring-zed#settings-files +{ + "inlay_hints": { + "enabled": false + }, + "languages": { + "Rust": { + "enable_language_server": true, + "formatter": "language_server", + "inlay_hints": { + "enabled": true, + "show_type_hints": true, + "show_parameter_hints": true, + "show_other_hints": true, + "show_background": false, + "edit_debounce_ms": 700, + "scroll_debounce_ms": 50 + } + }, + "TOML": { + "formatter": "language_server" + } + }, + "lsp": { + "rust-analyzer": { + "initialization_options": { + "procMacro": { + "enable": true + }, + "diagnostics": { + "experimental": { + "enable": false + } + }, + "showUnlinkedFileNotification": false + } + } + }, + "file_scan_exclusions": [ + "node_modules", + "**/node_modules", + "**/bower_components", + "**/*.code-search", + "**/*.contentlayer", + "**/*.next", + "**/dist", + "apps/mobile/ios/Pods", + "apps/mobile/android", + "apps/mobile/ios", + "**/.git", + "**/.svn", + "**/.hg", + "**/CVS", + "**/.DS_Store" + ], + "format_on_save": "on", + "ensure_final_newline_on_save": true, + "remove_trailing_whitespace_on_save": true, + "tab_size": 4, + "hard_tabs": false, + "show_whitespaces": "selection", + "show_completion_documentation": true +} diff --git a/Cargo.lock b/Cargo.lock index 433b56b90d55008acfc815b74b27a14de1a2ed47..74acb0b8230923f312656942cd18b0fb82d8917e 100644 GIT binary patch delta 54 zcmV-60LlNe@)XYU6o7;QgaWh!aif=|k^>SAWMXx5A}k6ZB4l!5XP4ip0U?)YnE?-n MrK1D4rK1Fnm0GzKn*aa+ delta 30 mcmX^2No4aUk%kt=7N#xCg^O8Il1hu)CoE>(K4CG-zNrA!*A0{a diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 0041164aa..e65b09eab 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -32,6 +32,7 @@ thiserror = { workspace = true } tokio = { workspace = true, features = ["sync"] } tracing = { workspace = true } uuid = { workspace = true, features = ["serde"] } +base64 = { workspace = true } # Specific Desktop dependencies # WARNING: Do NOT enable default features, as that vendors dbus (see below) @@ -45,10 +46,11 @@ tauri-plugin-http = "=2.0.3" tauri-plugin-os = "=2.0.1" tauri-plugin-shell = "=2.0.2" tauri-plugin-updater = "=2.0.2" +tauri-plugin-drag = "2.0.0" +drag = "2.0.0" # memory allocator mimalloc = { workspace = true } -tauri-plugin-drag = "2.0.0" [dependencies.tauri] features = ["linux-libxdo", "macos-private-api", "native-tls-vendored", "unstable"] diff --git a/apps/desktop/src-tauri/src/drag.rs b/apps/desktop/src-tauri/src/drag.rs new file mode 100644 index 000000000..e4f09b849 --- /dev/null +++ b/apps/desktop/src-tauri/src/drag.rs @@ -0,0 +1,223 @@ +// Import required dependencies for drag and drop operations, serialization, and async functionality +use drag::{DragItem, Image, Options}; +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; +use tauri::{ipc::Channel, Manager, PhysicalPosition, State, WebviewWindow}; + +// DragState wraps a thread-safe boolean flag to track drag operation status +#[derive(Clone)] +pub struct DragState(pub Arc>); + +// Default implementation for DragState initializes with false +impl Default for DragState { + fn default() -> Self { + Self(Arc::new(Mutex::new(false))) + } +} + +// Enum to represent the result of a drag operation (serializable for IPC) +#[derive(Serialize, Deserialize, Type, Clone)] +pub enum WrappedDragResult { + Dropped, + Cancel, +} + +// Structure to hold cursor position coordinates (serializable for IPC) +#[derive(Serialize, Deserialize, Type, Clone)] +pub struct WrappedCursorPosition { + x: i32, + y: i32, +} + +// Combined structure for drag operation results (serializable for IPC) +#[derive(Serialize, Deserialize, Type, Clone)] +pub struct CallbackResult { + result: WrappedDragResult, + #[serde(rename = "cursorPos")] + cursor_pos: WrappedCursorPosition, +} + +// Conversion implementations for drag-rs types to our wrapped types +impl From for WrappedDragResult { + fn from(result: drag::DragResult) -> Self { + match result { + drag::DragResult::Dropped => WrappedDragResult::Dropped, + drag::DragResult::Cancel => WrappedDragResult::Cancel, + } + } +} + +impl From for WrappedCursorPosition { + fn from(pos: drag::CursorPosition) -> Self { + WrappedCursorPosition { x: pos.x, y: pos.y } + } +} + +// Global flag to track if position tracking is active +static TRACKING: AtomicBool = AtomicBool::new(false); + +#[tauri::command(async)] +/// Initiates a drag and drop operation with cursor position tracking +/// +/// # Arguments +/// * `window` - The Tauri window instance +/// * `_state` - Current drag state (unused) +/// * `files` - Vector of file paths to be dragged +/// * `icon_path` - Path to the preview icon for the drag operation +/// * `on_event` - Channel for communicating drag operation events back to the frontend +#[specta::specta] +pub async fn start_drag( + window: WebviewWindow, + _state: State<'_, DragState>, + files: Vec, + icon_path: String, + on_event: Channel, +) -> Result<(), String> { + // Fast atomic swap for tracking state + match TRACKING.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) { + Ok(_) => { + println!("Starting position tracking"); + } + Err(_) => { + // If already tracking, stop previous instance quickly + TRACKING.store(false, Ordering::SeqCst); + tokio::time::sleep(tokio::time::Duration::from_millis(16)).await; + TRACKING.store(true, Ordering::SeqCst); + println!("Restarting position tracking"); + } + } + + // Pre-allocate resources before spawning task + let window_handle = Arc::new(window); + let app_handle = window_handle.app_handle(); + + // Initialize control flags + let cancel_flag = Arc::new(AtomicBool::new(false)); + let is_completed = Arc::new(AtomicBool::new(false)); + + // Prepare resources once with minimal cloning + let tracking_resources = Arc::new((files.clone(), icon_path.clone(), Arc::new(on_event))); + + println!("Starting position tracking"); + + // Get handles for window and app management + let window_clone = window_handle.clone(); + let app_handle_owned = app_handle.to_owned(); + let window_owned = window_clone.to_owned(); + + // Control flags for operation state + let is_completed_clone = is_completed.clone(); + + // Spawn background task for cursor tracking + tokio::spawn(async move { + // Initialize tracking state + let mut last_position = (0.0, 0.0); + let mut last_message_time = Instant::now(); + let threshold = 1.0; // Minimum movement threshold + let message_debounce = Duration::from_millis(32); // State update interval + let mut was_inside = false; + + // Main tracking loop + while TRACKING.load(Ordering::SeqCst) && !is_completed.load(Ordering::SeqCst) { + let window_for_check = window_owned.clone(); + // Skip if window is not focused + if !window_for_check.is_focused().unwrap_or(false) { + tokio::time::sleep(tokio::time::Duration::from_millis(8)).await; + continue; + } + + // Get current cursor and window positions + if let (Ok(cursor_position), Ok(window_position), Ok(window_size)) = ( + window_for_check.cursor_position(), + window_for_check.outer_position(), + window_for_check.inner_size(), + ) { + // Calculate cursor position relative to window + let relative_position = PhysicalPosition::new( + cursor_position.x - window_position.x as f64, + cursor_position.y - window_position.y as f64, + ); + + // Check if cursor is inside window boundaries + let is_inside = relative_position.x >= 0.0 + && relative_position.y >= 0.0 + && relative_position.x <= window_size.width as f64 + && relative_position.y <= window_size.height as f64; + + // Process state changes if cursor moved enough + if is_inside != was_inside + && ((relative_position.x - last_position.0).abs() > threshold + || (relative_position.y - last_position.1).abs() > threshold) + { + let now = Instant::now(); + if now.duration_since(last_message_time) >= message_debounce { + // Prepare resources for drag operation + let files_for_drag = tracking_resources.0.clone(); + let icon_path_for_drag = tracking_resources.1.clone(); + let on_event_for_drag = tracking_resources.2.clone(); + let is_completed = is_completed_clone.clone(); + let cancel_flag_clone = cancel_flag.clone(); + let window_for_drag = window_owned.clone(); + + // Execute drag operation on main thread + app_handle_owned + .run_on_main_thread(move || { + if !is_inside { + println!("Starting drag operation"); + // Create drag items + let paths: Vec = + files_for_drag.iter().map(PathBuf::from).collect(); + let item = DragItem::Files(paths); + let preview_icon = + Image::File(PathBuf::from(&icon_path_for_drag)); + + // Start the drag operation + if let Ok(_) = drag::start_drag( + &window_for_drag, + item, + preview_icon, + move |result, cursor_pos| { + // Send result back to frontend + let _ = on_event_for_drag.send(CallbackResult { + result: result.into(), + cursor_pos: cursor_pos.into(), + }); + // Mark operation as completed + is_completed.store(true, Ordering::SeqCst); + TRACKING.store(false, Ordering::SeqCst); + }, + Options::default(), + ) { + println!("Drag operation started"); + } + } else { + println!("Cursor returned to window"); + cancel_flag_clone.store(true, Ordering::SeqCst); + // We have this for now, but technically, it doesn't do anything. + // I'm still trying to figure out how to cancel mid-drag without the user having to cancel the dragging on the frontend too. + // - @Rocky43007 + } + }) + .unwrap_or_default(); + + // Update tracking state + last_message_time = now; + was_inside = is_inside; + last_position = (relative_position.x, relative_position.y); + } + } + } + + // Prevent excessive CPU usage + tokio::time::sleep(tokio::time::Duration::from_millis(8)).await; + } + + println!("Tracking instance stopped"); + }); + + Ok(()) +} diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index c4d88ed45..6a821d260 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -22,6 +22,7 @@ use tokio::task::block_in_place; use tokio::time::sleep; use tracing::{debug, error}; +mod drag; mod file; mod menu; mod tauri_plugins; @@ -200,6 +201,7 @@ async fn main() -> tauri::Result<()> { set_menu_bar_item_state, request_fda_macos, open_trash_in_os_explorer, + drag::start_drag, file::open_file_paths, file::open_ephemeral_files, file::get_file_path_open_with_apps, @@ -358,11 +360,11 @@ async fn main() -> tauri::Result<()> { .plugin(tauri_plugin_os::init()) .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_http::init()) - .plugin(tauri_plugin_drag::init()) // TODO: Bring back Tauri Plugin Window State - it was buggy so we removed it. .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(updater::plugin()) .manage(updater::State::default()) + .manage(drag::DragState::default()) .build(tauri::generate_context!())? .run(|_, _| {}); diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 880932f5a..d11921d79 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -3,7 +3,13 @@ import { QueryClientProvider } from '@tanstack/react-query'; import { listen } from '@tauri-apps/api/event'; import { PropsWithChildren, startTransition, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; -import { RspcProvider, useBridgeMutation } from '@sd/client'; +import { + getItemFilePath, + libraryClient, + RspcProvider, + useBridgeMutation, + useSelector +} from '@sd/client'; import { createRoutes, DeeplinkEvent, @@ -18,12 +24,13 @@ import { RouteTitleContext } from '@sd/interface/hooks/useRouteTitle'; import '@sd/ui/style'; -import { startDrag } from '@crabnebula/tauri-plugin-drag'; +import { Channel, invoke } from '@tauri-apps/api/core'; import SuperTokens from 'supertokens-web-js'; import EmailPassword from 'supertokens-web-js/recipe/emailpassword'; import Passwordless from 'supertokens-web-js/recipe/passwordless'; import Session from 'supertokens-web-js/recipe/session'; import ThirdParty from 'supertokens-web-js/recipe/thirdparty'; +import { explorerStore } from '@sd/interface/app/$libraryId/Explorer/store'; // TODO: Bring this back once upstream is fixed up. // const client = hooks.createClient({ // links: [ @@ -38,6 +45,7 @@ import getWindowHandler from '@sd/interface/app/$libraryId/settings/client/accou import { useLocale } from '@sd/interface/hooks'; import { AUTH_SERVER_URL, getTokens } from '@sd/interface/util'; +import { Transparent } from '../../../packages/assets/images'; import { commands } from './commands'; import { platform } from './platform'; import { queryClient } from './query'; @@ -47,7 +55,7 @@ import { createUpdater } from './updater'; declare global { interface Window { enableCORSFetch: (enable: boolean) => void; - startDrag: typeof startDrag; + useDragAndDrop: () => void; } } @@ -69,12 +77,89 @@ SuperTokens.init({ const startupError = (window as any).__SD_ERROR__ as string | undefined; +function useDragAndDrop() { + const dragState = useSelector(explorerStore, (s) => s.drag); + + useEffect(() => { + console.log('Drag effect triggered:', { + dragStateType: dragState?.type, + itemCount: dragState?.type === 'dragging' ? dragState?.items?.length : undefined + }); + + (async () => { + if (dragState?.type === 'dragging' && dragState.items.length > 0) { + console.log('Starting drag operation with items:', dragState.items); + + const items = await Promise.all( + dragState.items.map(async (item) => { + const data = getItemFilePath(item); + if (!data) { + console.log('No file path data for item:', item); + return; + } + + const file_path = + 'path' in data ? data.path : await libraryClient.query(['files.getPath', data.id]); + + console.log('Resolved file path:', file_path); + return { + type: 'explorer-item', + file_path: file_path + }; + }) + ); + + const image = Transparent.split('/@fs')[1]!; + console.log('Using preview image:', image); + + const validFiles = items.filter(Boolean).map((item) => item?.file_path); + console.log('Invoking start_drag with files:', validFiles); + + try { + const channel = new Channel<{ + result: 'Dropped' | 'Cancelled'; + cursorPos: { x: number; y: number }; + }>(); + + channel.onmessage = (payload) => { + console.log('Drag completed:', { + result: payload.result, + position: payload.cursorPos, + timestamp: new Date().toISOString() + }); + + if (payload.result === 'Dropped') { + console.log('Drop location:', { + x: payload.cursorPos.x, + y: payload.cursorPos.y, + screen: window.screen + }); + } + + explorerStore.drag = null; + }; + + await invoke('start_drag', { + files: validFiles, + iconPath: image, + onEvent: channel + }); + console.log('start_drag invoked successfully'); + } catch (error) { + console.error('Failed to start drag:', error); + explorerStore.drag = null; + } + } + })(); + }, [dragState]); +} + export default function App() { useEffect(() => { // This tells Tauri to show the current window because it's finished loading commands.appReady(); window.enableCORSFetch(true); - window.startDrag = startDrag; + window.useDragAndDrop = useDragAndDrop; // .then(() => { // if (import.meta.env.PROD) window.fetch = fetch; // }); @@ -209,6 +294,43 @@ function AppInner() { }; }, [selectedTab.element]); + const SizeDisplay = () => { + const [size, setSize] = useState({ + width: window.innerWidth, + height: window.innerHeight + }); + + useEffect(() => { + const handleResize = () => { + setSize({ + width: window.innerWidth, + height: window.innerHeight + }); + }; + + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, []); + + return ( +
+ {size.width} x {size.height} +
+ ); + }; + return ( { startTransition(() => { setTabs((tabs) => { - const { pathname, search } = - selectedTab.router.state.location; + const { pathname, search } = selectedTab.router.state.location; const newTab = createTab({ pathname, search }); const newTabs = [...tabs, newTab]; @@ -308,6 +429,7 @@ function AppInner() { tab.element ) )} +
diff --git a/apps/desktop/src/commands.ts b/apps/desktop/src/commands.ts index 81581cf81..cd3b7005f 100644 --- a/apps/desktop/src/commands.ts +++ b/apps/desktop/src/commands.ts @@ -49,6 +49,31 @@ export const commands = { else return { status: 'error', error: e as any }; } }, + /** + * Initiates a drag and drop operation with cursor position tracking + * + * # Arguments + * * `window` - The Tauri window instance + * * `_state` - Current drag state (unused) + * * `files` - Vector of file paths to be dragged + * * `icon_path` - Path to the preview icon for the drag operation + * * `on_event` - Channel for communicating drag operation events back to the frontend + */ + async startDrag( + files: string[], + iconPath: string, + onEvent: TAURI_CHANNEL + ): Promise> { + try { + return { + status: 'ok', + data: await TAURI_INVOKE('start_drag', { files, iconPath, onEvent }) + }; + } catch (e) { + if (e instanceof Error) throw e; + else return { status: 'error', error: e as any }; + } + }, async openFilePaths( library: string, ids: number[] @@ -162,6 +187,7 @@ export const events = __makeEvents__<{ /** user-defined types **/ export type AppThemeType = 'Auto' | 'Light' | 'Dark'; +export type CallbackResult = { result: WrappedDragResult; cursorPos: WrappedCursorPosition }; export type DragAndDropEvent = | { type: 'Hovered'; paths: string[]; x: number; y: number } | { type: 'Dropped'; paths: string[]; x: number; y: number } @@ -199,6 +225,8 @@ export type RevealItem = | { FilePath: { id: number } } | { Ephemeral: { path: string } }; export type Update = { version: string }; +export type WrappedCursorPosition = { x: number; y: number }; +export type WrappedDragResult = 'Dropped' | 'Cancel'; type __EventObj__ = { listen: (cb: TAURI_API_EVENT.EventCallback) => ReturnType>; diff --git a/interface/app/$libraryId/Explorer/View/GridView/Item/index.tsx b/interface/app/$libraryId/Explorer/View/GridView/Item/index.tsx index 81e2e788d..dc3dc3d12 100644 --- a/interface/app/$libraryId/Explorer/View/GridView/Item/index.tsx +++ b/interface/app/$libraryId/Explorer/View/GridView/Item/index.tsx @@ -111,38 +111,6 @@ const ItemMetadata = memo(() => { const item = useGridViewItemContext(); const { isDroppable } = useExplorerDroppableContext(); const explorerLayout = useExplorerLayoutStore(); - const dragState = useSelector(explorerStore, (s) => s.drag); - - useEffect(() => { - (async () => { - if (dragState?.type === 'dragging' && dragState.items.length > 0) { - const items = await Promise.all( - dragState.items.map(async (item) => { - const data = getItemFilePath(item); - if (!data) return; - - const file_path = - 'path' in data - ? data.path - : await libraryClient.query(['files.getPath', data.id]); - - return { - type: 'explorer-item', - file_path: file_path - }; - }) - ); - - // get image src from Transparent - const image = Transparent.split('/@fs')[1]; - - (window as any).startDrag({ - item: items.filter(Boolean).map((item) => item?.file_path), - icon: image - }); - } - })(); - }, [dragState]); const isRenaming = useSelector(explorerStore, (s) => s.isRenaming && item.selected); diff --git a/interface/app/$libraryId/Explorer/index.tsx b/interface/app/$libraryId/Explorer/index.tsx index 2aa5971c0..2bddff35d 100644 --- a/interface/app/$libraryId/Explorer/index.tsx +++ b/interface/app/$libraryId/Explorer/index.tsx @@ -34,6 +34,11 @@ interface Props { contextMenu?: () => ReactNode; } +declare global { + interface Window { + useDragAndDrop: () => void; + } +} /** * This component is used in a few routes and acts as the reference demonstration of how to combine * all the elements of the explorer except for the context, which must be used in the parent component. @@ -82,6 +87,8 @@ export default function Explorer(props: PropsWithChildren) { explorer.settingsStore.showHiddenFiles = !explorer.settingsStore.showHiddenFiles; }); + window.useDragAndDrop(); + useKeyRevealFinder(); useExplorerDnd(); @@ -111,18 +118,13 @@ export default function Explorer(props: PropsWithChildren) { contextMenu={props.contextMenu ? props.contextMenu() : } emptyNotice={ props.emptyNotice ?? ( - + ) } listViewOptions={{ hideHeaderBorder: true }} scrollPadding={{ top: topBar.topBarHeight, - bottom: showPathBar - ? PATH_BAR_HEIGHT + (showTagBar ? TAG_BAR_HEIGHT : 0) - : undefined + bottom: showPathBar ? PATH_BAR_HEIGHT + (showTagBar ? TAG_BAR_HEIGHT : 0) : undefined }} />
@@ -142,9 +144,7 @@ export default function Explorer(props: PropsWithChildren) { )} style={{ paddingTop: topBar.topBarHeight + 12, - bottom: showPathBar - ? PATH_BAR_HEIGHT + (showTagBar ? TAG_BAR_HEIGHT : 0) - : 0 + bottom: showPathBar ? PATH_BAR_HEIGHT + (showTagBar ? TAG_BAR_HEIGHT : 0) : 0 }} /> )}