From bcc53a0dc1084e66b52f207d8fe78f5e80ddac66 Mon Sep 17 00:00:00 2001 From: lashman Date: Sun, 8 Mar 2026 16:53:30 +0200 Subject: [PATCH] Fix drag-and-drop and file manager integration Drag-and-drop from Nautilus on Wayland was broken by two issues: - DropTarget only accepted COPY action, but Wayland compositor pre-selects MOVE, causing GTK4 to silently reject all drops - Two competing DropTarget controllers on the same widget caused the gchararray target to match Nautilus formats first, swallowing the drop before the FileList target could receive it Merged both drop targets into a single controller that tries FileList first, then File, then falls back to URI text parsing. File manager integration was broken by wrong command syntax (non-existent --files flag) and wrong binary path resolution inside AppImage. Split into separate GTK/CLI binary resolution with APPIMAGE env var detection. --- pixstrip-core/src/fm_integration.rs | 75 +++++++----- pixstrip-gtk/src/steps/step_images.rs | 158 +++++++++++++------------- 2 files changed, 124 insertions(+), 109 deletions(-) diff --git a/pixstrip-core/src/fm_integration.rs b/pixstrip-core/src/fm_integration.rs index 2956057..86f12b6 100644 --- a/pixstrip-core/src/fm_integration.rs +++ b/pixstrip-core/src/fm_integration.rs @@ -84,15 +84,14 @@ fn shell_safe(s: &str) -> String { .collect() } -fn pixstrip_bin() -> String { - // Try to find the pixstrip binary path +/// Path to the GTK binary for "Open in Pixstrip" actions. +fn pixstrip_gtk_bin() -> String { + // When running from AppImage, use the AppImage path directly + if let Ok(appimage) = std::env::var("APPIMAGE") { + return appimage; + } if let Ok(exe) = std::env::current_exe() { - // If running from the GTK app, find the CLI sibling let dir = exe.parent().unwrap_or(Path::new("/usr/bin")); - let cli_path = dir.join("pixstrip"); - if cli_path.exists() { - return cli_path.display().to_string(); - } let gtk_path = dir.join("pixstrip-gtk"); if gtk_path.exists() { return gtk_path.display().to_string(); @@ -102,6 +101,22 @@ fn pixstrip_bin() -> String { "pixstrip-gtk".into() } +/// Path to the CLI binary for preset processing actions. +fn pixstrip_cli_bin() -> String { + // When running from AppImage, use the AppImage path directly + if let Ok(appimage) = std::env::var("APPIMAGE") { + return appimage; + } + if let Ok(exe) = std::env::current_exe() { + let dir = exe.parent().unwrap_or(Path::new("/usr/bin")); + let cli_path = dir.join("pixstrip"); + if cli_path.exists() { + return cli_path.display().to_string(); + } + } + "pixstrip".into() +} + fn get_preset_names() -> Vec { let mut names: Vec = Preset::all_builtins() .into_iter() @@ -138,7 +153,8 @@ fn install_nautilus() -> Result<()> { let dir = nautilus_extension_dir(); std::fs::create_dir_all(&dir)?; - let bin = pixstrip_bin(); + let gtk_bin = pixstrip_gtk_bin(); + let cli_bin = pixstrip_cli_bin(); let presets = get_preset_names(); let mut preset_items = String::new(); @@ -156,7 +172,8 @@ fn install_nautilus() -> Result<()> { )); } - let escaped_bin = bin.replace('\\', "\\\\").replace('\'', "\\'"); + let escaped_gtk_bin = gtk_bin.replace('\\', "\\\\").replace('\'', "\\'"); + let escaped_cli_bin = cli_bin.replace('\\', "\\\\").replace('\'', "\\'"); let script = format!( r#"import subprocess from gi.repository import Nautilus, GObject @@ -204,14 +221,15 @@ class PixstripExtension(GObject.GObject, Nautilus.MenuProvider): def _on_open(self, menu, files): paths = [f.get_location().get_path() for f in files if f.get_location()] - subprocess.Popen(['{bin}', '--files'] + paths) + subprocess.Popen(['{gtk_bin}'] + paths) def _on_preset(self, menu, preset_name, files): paths = [f.get_location().get_path() for f in files if f.get_location()] - subprocess.Popen(['{bin}', '--preset', preset_name, '--files'] + paths) + subprocess.Popen(['{cli_bin}', 'process', '--preset', preset_name] + paths) "#, preset_items = preset_items, - bin = escaped_bin, + gtk_bin = escaped_gtk_bin, + cli_bin = escaped_cli_bin, ); atomic_write(&nautilus_extension_path(), &script)?; @@ -245,19 +263,20 @@ fn install_nemo() -> Result<()> { let dir = nemo_action_dir(); std::fs::create_dir_all(&dir)?; - let bin = pixstrip_bin(); + let gtk_bin = pixstrip_gtk_bin(); + let cli_bin = pixstrip_cli_bin(); // Main "Open in Pixstrip" action let open_action = format!( "[Nemo Action]\n\ Name=Open in Pixstrip...\n\ Comment=Process images with Pixstrip\n\ - Exec={bin} --files %F\n\ + Exec={gtk_bin} %F\n\ Icon-Name=applications-graphics-symbolic\n\ Selection=Any\n\ Extensions=jpg;jpeg;png;webp;gif;tiff;tif;avif;bmp;\n\ Mimetypes=image/*;\n", - bin = bin, + gtk_bin = gtk_bin, ); atomic_write(&nemo_action_path(), &open_action)?; @@ -270,14 +289,14 @@ fn install_nemo() -> Result<()> { "[Nemo Action]\n\ Name=Pixstrip: {name}\n\ Comment=Process with {name} preset\n\ - Exec={bin} --preset \"{safe_label}\" --files %F\n\ + Exec={cli_bin} process --preset \"{safe_label}\" %F\n\ Icon-Name=applications-graphics-symbolic\n\ Selection=Any\n\ Extensions=jpg;jpeg;png;webp;gif;tiff;tif;avif;bmp;\n\ Mimetypes=image/*;\n", name = name, safe_label = shell_safe(name), - bin = bin, + cli_bin = cli_bin, ); atomic_write(&action_path, &action)?; } @@ -324,7 +343,8 @@ fn install_thunar() -> Result<()> { let dir = thunar_action_dir(); std::fs::create_dir_all(&dir)?; - let bin = pixstrip_bin(); + let gtk_bin = pixstrip_gtk_bin(); + let cli_bin = pixstrip_cli_bin(); let presets = get_preset_names(); let mut actions = String::from("\n\n"); @@ -334,13 +354,13 @@ fn install_thunar() -> Result<()> { " \n\ \x20 applications-graphics-symbolic\n\ \x20 Open in Pixstrip...\n\ - \x20 {bin} --files %F\n\ + \x20 {gtk_bin} %F\n\ \x20 Process images with Pixstrip\n\ \x20 *.jpg;*.jpeg;*.png;*.webp;*.gif;*.tiff;*.tif;*.avif;*.bmp\n\ \x20 \n\ \x20 \n\ \n", - bin = bin, + gtk_bin = gtk_bin, )); for name in &presets { @@ -348,7 +368,7 @@ fn install_thunar() -> Result<()> { " \n\ \x20 applications-graphics-symbolic\n\ \x20 Pixstrip: {xml_name}\n\ - \x20 {bin} --preset \"{safe_label}\" --files %F\n\ + \x20 {cli_bin} process --preset \"{safe_label}\" %F\n\ \x20 Process with {xml_name} preset\n\ \x20 *.jpg;*.jpeg;*.png;*.webp;*.gif;*.tiff;*.tif;*.avif;*.bmp\n\ \x20 \n\ @@ -356,7 +376,7 @@ fn install_thunar() -> Result<()> { \n", xml_name = name.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """), safe_label = shell_safe(name), - bin = bin, + cli_bin = cli_bin, )); } @@ -392,7 +412,8 @@ fn install_dolphin() -> Result<()> { let dir = dolphin_service_dir(); std::fs::create_dir_all(&dir)?; - let bin = pixstrip_bin(); + let gtk_bin = pixstrip_gtk_bin(); + let cli_bin = pixstrip_cli_bin(); let presets = get_preset_names(); let mut desktop = format!( @@ -406,8 +427,8 @@ fn install_dolphin() -> Result<()> { [Desktop Action Open]\n\ Name=Open in Pixstrip...\n\ Icon=applications-graphics-symbolic\n\ - Exec={bin} --files %F\n\n", - bin = bin, + Exec={gtk_bin} %F\n\n", + gtk_bin = gtk_bin, preset_actions = presets .iter() .enumerate() @@ -421,11 +442,11 @@ fn install_dolphin() -> Result<()> { "[Desktop Action Preset{i}]\n\ Name={name}\n\ Icon=applications-graphics-symbolic\n\ - Exec={bin} --preset \"{safe_label}\" --files %F\n\n", + Exec={cli_bin} process --preset \"{safe_label}\" %F\n\n", i = i, name = name, safe_label = shell_safe(name), - bin = bin, + cli_bin = cli_bin, )); } diff --git a/pixstrip-gtk/src/steps/step_images.rs b/pixstrip-gtk/src/steps/step_images.rs index 8faf466..c6564e4 100644 --- a/pixstrip-gtk/src/steps/step_images.rs +++ b/pixstrip-gtk/src/steps/step_images.rs @@ -30,8 +30,10 @@ pub fn build_images_page(state: &AppState) -> adw::NavigationPage { let subfolder_choice: Rc>> = Rc::new(RefCell::new(None)); // Set up drag-and-drop on the entire page - let drop_target = gtk::DropTarget::new(gtk::gio::File::static_type(), gtk::gdk::DragAction::COPY); - drop_target.set_types(&[gtk::gio::File::static_type()]); + // Accept both FileList (from file managers) and single File + let drop_target = gtk::DropTarget::new(gtk::gdk::FileList::static_type(), gtk::gdk::DragAction::COPY | gtk::gdk::DragAction::MOVE); + drop_target.set_types(&[gtk::gdk::FileList::static_type(), gtk::gio::File::static_type(), glib::GString::static_type()]); + drop_target.set_preload(true); { let loaded_files = state.loaded_files.clone(); @@ -40,40 +42,97 @@ pub fn build_images_page(state: &AppState) -> adw::NavigationPage { let stack_ref = stack.clone(); let subfolder_choice = subfolder_choice.clone(); drop_target.connect_drop(move |target, value, _x, _y| { - if let Ok(file) = value.get::() - && let Some(path) = file.path() - { + // Collect paths from FileList, single File, or URI text + let mut paths: Vec = Vec::new(); + if let Ok(file_list) = value.get::() { + for file in file_list.files() { + if let Some(path) = file.path() { + paths.push(path); + } + } + } else if let Ok(file) = value.get::() { + if let Some(path) = file.path() { + paths.push(path); + } + } else if let Ok(text) = value.get::() { + // Handle URI text drops (from web browsers) + let text = text.trim().to_string(); + let lower = text.to_lowercase(); + let is_image_url = (lower.starts_with("http://") || lower.starts_with("https://")) + && (lower.ends_with(".jpg") + || lower.ends_with(".jpeg") + || lower.ends_with(".png") + || lower.ends_with(".webp") + || lower.ends_with(".gif") + || lower.ends_with(".avif") + || lower.ends_with(".tiff") + || lower.ends_with(".bmp") + || lower.contains(".jpg?") + || lower.contains(".jpeg?") + || lower.contains(".png?") + || lower.contains(".webp?")); + + if is_image_url { + let loaded = loaded_files.clone(); + let excl = excluded.clone(); + let sz = sizes.clone(); + let sr = stack_ref.clone(); + let (tx, rx) = std::sync::mpsc::channel::>(); + let url = text.clone(); + std::thread::spawn(move || { + let result = download_image_url(&url); + let _ = tx.send(result); + }); + glib::timeout_add_local(std::time::Duration::from_millis(100), move || { + match rx.try_recv() { + Ok(Some(path)) => { + let mut files = loaded.borrow_mut(); + if !files.contains(&path) { + files.push(path); + } + let count = files.len(); + drop(files); + refresh_grid(&sr, &loaded, &excl, &sz, count); + glib::ControlFlow::Break + } + Ok(None) => glib::ControlFlow::Break, + Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue, + Err(_) => glib::ControlFlow::Break, + } + }); + return true; + } + return false; + } + + if paths.is_empty() { + return false; + } + + for path in paths { if path.is_dir() { let has_subdirs = has_subfolders(&path); if !has_subdirs { let mut files = loaded_files.borrow_mut(); add_images_flat(&path, &mut files); - let count = files.len(); drop(files); - refresh_grid(&stack_ref, &loaded_files, &excluded, &sizes, count); } else { let choice = *subfolder_choice.borrow(); match choice { Some(true) => { let mut files = loaded_files.borrow_mut(); add_images_from_dir(&path, &mut files); - let count = files.len(); drop(files); - refresh_grid(&stack_ref, &loaded_files, &excluded, &sizes, count); } Some(false) => { let mut files = loaded_files.borrow_mut(); add_images_flat(&path, &mut files); - let count = files.len(); drop(files); - refresh_grid(&stack_ref, &loaded_files, &excluded, &sizes, count); } None => { let mut files = loaded_files.borrow_mut(); add_images_flat(&path, &mut files); - let count = files.len(); drop(files); - refresh_grid(&stack_ref, &loaded_files, &excluded, &sizes, count); let loaded_files = loaded_files.clone(); let excluded = excluded.clone(); @@ -98,88 +157,23 @@ pub fn build_images_page(state: &AppState) -> adw::NavigationPage { } } } - return true; } else if is_image_file(&path) { let mut files = loaded_files.borrow_mut(); if !files.contains(&path) { files.push(path); } - let count = files.len(); drop(files); - refresh_grid(&stack_ref, &loaded_files, &excluded, &sizes, count); - return true; } } - false + + let count = loaded_files.borrow().len(); + refresh_grid(&stack_ref, &loaded_files, &excluded, &sizes, count); + true }); } stack.add_controller(drop_target); - // Also accept URI text drops (from web browsers) - let uri_drop = gtk::DropTarget::new(glib::GString::static_type(), gtk::gdk::DragAction::COPY); - { - let loaded_files = state.loaded_files.clone(); - let excluded = state.excluded_files.clone(); - let sizes = state.file_sizes.clone(); - let stack_ref = stack.clone(); - uri_drop.connect_drop(move |_target, value, _x, _y| { - if let Ok(text) = value.get::() { - let text = text.trim().to_string(); - // Check if it looks like an image URL - let lower = text.to_lowercase(); - let is_image_url = (lower.starts_with("http://") || lower.starts_with("https://")) - && (lower.ends_with(".jpg") - || lower.ends_with(".jpeg") - || lower.ends_with(".png") - || lower.ends_with(".webp") - || lower.ends_with(".gif") - || lower.ends_with(".avif") - || lower.ends_with(".tiff") - || lower.ends_with(".bmp") - || lower.contains(".jpg?") - || lower.contains(".jpeg?") - || lower.contains(".png?") - || lower.contains(".webp?")); - - if is_image_url { - let loaded = loaded_files.clone(); - let excl = excluded.clone(); - let sz = sizes.clone(); - let sr = stack_ref.clone(); - // Download in background thread - let (tx, rx) = std::sync::mpsc::channel::>(); - let url = text.clone(); - std::thread::spawn(move || { - let result = download_image_url(&url); - let _ = tx.send(result); - }); - - glib::timeout_add_local(std::time::Duration::from_millis(100), move || { - match rx.try_recv() { - Ok(Some(path)) => { - let mut files = loaded.borrow_mut(); - if !files.contains(&path) { - files.push(path); - } - let count = files.len(); - drop(files); - refresh_grid(&sr, &loaded, &excl, &sz, count); - glib::ControlFlow::Break - } - Ok(None) => glib::ControlFlow::Break, - Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue, - Err(_) => glib::ControlFlow::Break, - } - }); - return true; - } - } - false - }); - } - stack.add_controller(uri_drop); - adw::NavigationPage::builder() .title("Add Images") .tag("step-images")