From aa7a2dfd931e9fdb66297591866a311d4eb7ba47 Mon Sep 17 00:00:00 2001 From: Sophie Herold Date: Tue, 31 Jan 2023 21:56:44 +0100 Subject: [PATCH] window: Add image deletion Add Flatpak access to GVfs for trash:/// access for being able to recover files (undo trash operation). Closes #26 --- build-aux/flatpak/org.gnome.Loupe.Devel.json | 5 +- data/gtk/window.ui | 7 ++ src/application.rs | 8 +- src/util.rs | 60 +++++++++++- src/window.rs | 96 +++++++++++++++++++- 5 files changed, 169 insertions(+), 7 deletions(-) diff --git a/build-aux/flatpak/org.gnome.Loupe.Devel.json b/build-aux/flatpak/org.gnome.Loupe.Devel.json index 0db1f54..821a901 100644 --- a/build-aux/flatpak/org.gnome.Loupe.Devel.json +++ b/build-aux/flatpak/org.gnome.Loupe.Devel.json @@ -16,7 +16,10 @@ "--device=dri", "--filesystem=host", "--env=RUST_BACKTRACE=1", - "--env=RUST_LOG=loupe=debug" + "--env=RUST_LOG=loupe=debug", + "--talk-name=org.gtk.vfs.*", + "--filesystem=xdg-run/gvfs", + "--filesystem=xdg-run/gvfsd" ], "build-options" : { "append-path" : "/usr/lib/sdk/rust-stable/bin", diff --git a/data/gtk/window.ui b/data/gtk/window.ui index f03a25e..28479a7 100644 --- a/data/gtk/window.ui +++ b/data/gtk/window.ui @@ -94,6 +94,13 @@ Copy to Clipboard + + + user-trash-symbolic + win.trash + Move to Trash + + open-menu-symbolic diff --git a/src/application.rs b/src/application.rs index 1bb6a78..164481f 100644 --- a/src/application.rs +++ b/src/application.rs @@ -75,8 +75,10 @@ mod imp { fn open(&self, files: &[gio::File], _hint: &str) { let application = self.instance(); for file in files { + let Some(path) = file.path() else { log::error!("File has no path {}", file.uri()); return; }; + let win = LpWindow::new(&*application); - win.set_image_from_path(&file.path().unwrap()); + win.set_image_from_path(&path); // show window when image size is known let watch = win @@ -168,6 +170,10 @@ impl LpApplication { self.set_accels_for_action("win.open", &["O"]); self.set_accels_for_action("win.print", &["P"]); self.set_accels_for_action("win.copy", &["C"]); + self.set_accels_for_action( + "win.trash", + &["X", "Delete", "KP_Delete", "BackSpace"], + ); self.set_accels_for_action("win.show-help-overlay", &["question"]); self.set_accels_for_action("win.leave-fullscreen", &["Escape"]); self.set_accels_for_action("win.toggle-properties", &["F9", "Return"]); diff --git a/src/util.rs b/src/util.rs index 7c66269..22a9ec3 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,10 +1,12 @@ use crate::deps::*; +use crate::i18n::*; -use anyhow::Context; +use anyhow::{anyhow, Context}; use gio::prelude::*; +use once_cell::sync::Lazy; use std::fmt::{Debug, Write}; -use std::path::Path; +use std::path::{Path, PathBuf}; pub fn get_file_display_name(file: &gio::File) -> Option { let info = query_attributes(file, vec![&gio::FILE_ATTRIBUTE_STANDARD_DISPLAY_NAME]).ok()?; @@ -56,6 +58,60 @@ pub fn compare_by_name(file_a: &Path, file_b: &Path) -> std::cmp::Ordering { key_a.cmp(&key_b) } +static FILE_ATTRIBUTE_TRASH: Lazy = Lazy::new(|| { + [ + *gio::FILE_ATTRIBUTE_STANDARD_NAME, + *gio::FILE_ATTRIBUTE_TRASH_ORIG_PATH, + ] + .join(",") +}); + +/// Recover file from trash +/// +/// This is based on Nautilus' implementation +/// +pub async fn untrash(path: &Path) -> anyhow::Result<()> { + let trash = gio::File::for_uri("trash:///"); + + let enumerator = trash + .enumerate_children_future( + &FILE_ATTRIBUTE_TRASH, + gio::FileQueryInfoFlags::NOFOLLOW_SYMLINKS, + glib::Priority::default(), + ) + .await?; + + let mut error = Err(anyhow!("Image not found in trash")); + + while let Ok(info) = enumerator + .next_files_future(1, glib::Priority::default()) + .await + { + let Some(file_info) = info.first() + else { break; }; + + let Some(original_path) = file_info.attribute_byte_string(&gio::FILE_ATTRIBUTE_TRASH_ORIG_PATH).as_ref().map(PathBuf::from) + else { break; }; + + if original_path == path { + let trash_file = trash.child(file_info.name()); + let original_file = gio::File::for_path(original_path); + error = trash_file + .move_future( + &original_file, + gio::FileCopyFlags::NOFOLLOW_SYMLINKS, + glib::Priority::default(), + ) + .0 + .await + .context(i18n("Failed to restore image from trash")); + break; + } + } + + error +} + pub async fn spawn( name: &str, f: impl Fn() -> T + Send + 'static, diff --git a/src/window.rs b/src/window.rs index 26ff70b..f0a7911 100644 --- a/src/window.rs +++ b/src/window.rs @@ -20,13 +20,14 @@ use crate::deps::*; use crate::i18n::*; +use adw::prelude::*; use adw::subclass::prelude::*; use glib::clone; -use gtk::prelude::*; use gtk::CompositeTemplate; +use gtk_macros::spawn; use std::cell::RefCell; -use std::path::Path; +use std::path::{Path, PathBuf}; use crate::config; use crate::util; @@ -175,6 +176,10 @@ mod imp { win.copy(); }); + klass.install_action_async("win.trash", None, |win, _, _| async move { + win.trash().await; + }); + klass.install_action("win.show-toast", Some("(si)"), move |win, _, var| { if let Some((ref toast, i)) = var.and_then(|v| v.get::<(String, i32)>()) { win.show_toast(toast, adw::ToastPriority::__Unknown(i)); @@ -197,6 +202,10 @@ mod imp { obj.add_css_class("devel"); } + // Limit effect of modal dialogs to this window + // and keeps the others usable + gtk::WindowGroup::new().add_window(&*obj); + obj.set_actions_enabled(false); self.image_view .property_expression("current-page") @@ -456,6 +465,85 @@ impl LpWindow { } } + async fn trash(&self) { + let image_view = self.image_view(); + let (Some(file), Some(path)) = (image_view.current_file(), image_view.current_path()) + else { log::error!("No file to trash"); return; }; + + let result = file.trash_future(glib::Priority::default()).await; + + match result { + Ok(()) => { + let toast = adw::Toast::builder() + .title(&i18n("Image moved to trash")) + .button_label(&i18n("Undo")) + .build(); + toast.connect_button_clicked(glib::clone!(@weak self as win => move |_| { + let path = path.clone(); + spawn!(async move { + let result = crate::util::untrash(&path).await; + match result { + Ok(()) => win.image_view().set_image_from_path(&path), + Err(err) => { + log::error!("Failed to untrash {path:?}: {err}"); + win.show_toast( + i18n("Failed to restore image from trash"), + adw::ToastPriority::High, + ); + } + } + }); + })); + self.imp().toast_overlay.add_toast(&toast); + } + Err(err) => { + if Some(gio::IOErrorEnum::NotSupported) == err.kind::() { + self.delete(&path).await; + } else { + log::error!("Failed to delete file {path:?}: {err}"); + self.show_toast( + i18n("Failed to move image to trash"), + adw::ToastPriority::Normal, + ); + } + } + } + } + + /// Permanently delete image + /// + /// Fallback for when trash no available + async fn delete(&self, path: &Path) { + let dialog = adw::MessageDialog::builder() + .modal(true) + .transient_for(self) + .heading(&i18n("Permanently Delete Image?")) + .body(&i18n_f( + "The image “{}” can only be deleted permanently.", + &[&PathBuf::from(&path.file_name().unwrap_or_default()) + .display() + .to_string()], + )) + .build(); + + dialog.add_responses(&[("cancel", &i18n("Cancel")), ("delete", &i18n("Delete"))]); + dialog.set_response_appearance("delete", adw::ResponseAppearance::Destructive); + + if "delete" == dialog.run_future().await { + let file = gio::File::for_path(path); + let result = file.delete_future(glib::Priority::default()).await; + + if let Err(err) = result { + log::error!("Failed to delete file {path:?}: {err}"); + self.show_toast(i18n("Failed to delete image"), adw::ToastPriority::Normal); + } + } + } + + fn image_view(&self) -> LpImageView { + self.imp().image_view.clone() + } + fn show_toast(&self, text: impl AsRef, priority: adw::ToastPriority) { let imp = self.imp(); @@ -480,12 +568,14 @@ impl LpWindow { self.action_set_enabled("win.print", enabled); self.action_set_enabled("win.rotate", enabled); self.action_set_enabled("win.copy", enabled); + self.action_set_enabled("win.trash", enabled); self.action_set_enabled("win.zoom-best-fit", enabled); self.action_set_enabled("win.zoom-to", enabled); self.action_set_enabled("win.toggle-properties", enabled); } - pub fn images_available(&self) { + /// Handles change in availablilty of images + fn images_available(&self) { let imp = self.imp(); if imp.image_view.current_page().is_some() {