window: Add image deletion
Add Flatpak access to GVfs for trash:/// access for being able to recover files (undo trash operation). Closes #26
This commit is contained in:
parent
04e8646daa
commit
aa7a2dfd93
5 changed files with 169 additions and 7 deletions
|
|
@ -16,7 +16,10 @@
|
||||||
"--device=dri",
|
"--device=dri",
|
||||||
"--filesystem=host",
|
"--filesystem=host",
|
||||||
"--env=RUST_BACKTRACE=1",
|
"--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" : {
|
"build-options" : {
|
||||||
"append-path" : "/usr/lib/sdk/rust-stable/bin",
|
"append-path" : "/usr/lib/sdk/rust-stable/bin",
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,13 @@
|
||||||
<property name="tooltip-text">Copy to Clipboard</property>
|
<property name="tooltip-text">Copy to Clipboard</property>
|
||||||
</object>
|
</object>
|
||||||
</child>
|
</child>
|
||||||
|
<child type="start">
|
||||||
|
<object class="GtkButton">
|
||||||
|
<property name="icon-name">user-trash-symbolic</property>
|
||||||
|
<property name="action-name">win.trash</property>
|
||||||
|
<property name="tooltip-text">Move to Trash</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
<child type="end">
|
<child type="end">
|
||||||
<object class="GtkMenuButton" id="menu_button">
|
<object class="GtkMenuButton" id="menu_button">
|
||||||
<property name="icon-name">open-menu-symbolic</property>
|
<property name="icon-name">open-menu-symbolic</property>
|
||||||
|
|
|
||||||
|
|
@ -75,8 +75,10 @@ mod imp {
|
||||||
fn open(&self, files: &[gio::File], _hint: &str) {
|
fn open(&self, files: &[gio::File], _hint: &str) {
|
||||||
let application = self.instance();
|
let application = self.instance();
|
||||||
for file in files {
|
for file in files {
|
||||||
|
let Some(path) = file.path() else { log::error!("File has no path {}", file.uri()); return; };
|
||||||
|
|
||||||
let win = LpWindow::new(&*application);
|
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
|
// show window when image size is known
|
||||||
let watch = win
|
let watch = win
|
||||||
|
|
@ -168,6 +170,10 @@ impl LpApplication {
|
||||||
self.set_accels_for_action("win.open", &["<Ctrl>O"]);
|
self.set_accels_for_action("win.open", &["<Ctrl>O"]);
|
||||||
self.set_accels_for_action("win.print", &["<Ctrl>P"]);
|
self.set_accels_for_action("win.print", &["<Ctrl>P"]);
|
||||||
self.set_accels_for_action("win.copy", &["<Ctrl>C"]);
|
self.set_accels_for_action("win.copy", &["<Ctrl>C"]);
|
||||||
|
self.set_accels_for_action(
|
||||||
|
"win.trash",
|
||||||
|
&["<Ctrl>X", "Delete", "KP_Delete", "BackSpace"],
|
||||||
|
);
|
||||||
self.set_accels_for_action("win.show-help-overlay", &["<Ctrl>question"]);
|
self.set_accels_for_action("win.show-help-overlay", &["<Ctrl>question"]);
|
||||||
self.set_accels_for_action("win.leave-fullscreen", &["Escape"]);
|
self.set_accels_for_action("win.leave-fullscreen", &["Escape"]);
|
||||||
self.set_accels_for_action("win.toggle-properties", &["F9", "<Alt>Return"]);
|
self.set_accels_for_action("win.toggle-properties", &["F9", "<Alt>Return"]);
|
||||||
|
|
|
||||||
60
src/util.rs
60
src/util.rs
|
|
@ -1,10 +1,12 @@
|
||||||
use crate::deps::*;
|
use crate::deps::*;
|
||||||
|
use crate::i18n::*;
|
||||||
|
|
||||||
use anyhow::Context;
|
use anyhow::{anyhow, Context};
|
||||||
use gio::prelude::*;
|
use gio::prelude::*;
|
||||||
|
use once_cell::sync::Lazy;
|
||||||
|
|
||||||
use std::fmt::{Debug, Write};
|
use std::fmt::{Debug, Write};
|
||||||
use std::path::Path;
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
pub fn get_file_display_name(file: &gio::File) -> Option<String> {
|
pub fn get_file_display_name(file: &gio::File) -> Option<String> {
|
||||||
let info = query_attributes(file, vec![&gio::FILE_ATTRIBUTE_STANDARD_DISPLAY_NAME]).ok()?;
|
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)
|
key_a.cmp(&key_b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static FILE_ATTRIBUTE_TRASH: Lazy<String> = Lazy::new(|| {
|
||||||
|
[
|
||||||
|
*gio::FILE_ATTRIBUTE_STANDARD_NAME,
|
||||||
|
*gio::FILE_ATTRIBUTE_TRASH_ORIG_PATH,
|
||||||
|
]
|
||||||
|
.join(",")
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Recover file from trash
|
||||||
|
///
|
||||||
|
/// This is based on Nautilus' implementation
|
||||||
|
/// <https://gitlab.gnome.org/GNOME/glib/-/issues/845>
|
||||||
|
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<T: Debug + Send + 'static>(
|
pub async fn spawn<T: Debug + Send + 'static>(
|
||||||
name: &str,
|
name: &str,
|
||||||
f: impl Fn() -> T + Send + 'static,
|
f: impl Fn() -> T + Send + 'static,
|
||||||
|
|
|
||||||
|
|
@ -20,13 +20,14 @@
|
||||||
use crate::deps::*;
|
use crate::deps::*;
|
||||||
use crate::i18n::*;
|
use crate::i18n::*;
|
||||||
|
|
||||||
|
use adw::prelude::*;
|
||||||
use adw::subclass::prelude::*;
|
use adw::subclass::prelude::*;
|
||||||
use glib::clone;
|
use glib::clone;
|
||||||
use gtk::prelude::*;
|
|
||||||
use gtk::CompositeTemplate;
|
use gtk::CompositeTemplate;
|
||||||
|
use gtk_macros::spawn;
|
||||||
|
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::path::Path;
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use crate::config;
|
use crate::config;
|
||||||
use crate::util;
|
use crate::util;
|
||||||
|
|
@ -175,6 +176,10 @@ mod imp {
|
||||||
win.copy();
|
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| {
|
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)>()) {
|
if let Some((ref toast, i)) = var.and_then(|v| v.get::<(String, i32)>()) {
|
||||||
win.show_toast(toast, adw::ToastPriority::__Unknown(i));
|
win.show_toast(toast, adw::ToastPriority::__Unknown(i));
|
||||||
|
|
@ -197,6 +202,10 @@ mod imp {
|
||||||
obj.add_css_class("devel");
|
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);
|
obj.set_actions_enabled(false);
|
||||||
self.image_view
|
self.image_view
|
||||||
.property_expression("current-page")
|
.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::<gio::IOErrorEnum>() {
|
||||||
|
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<str>, priority: adw::ToastPriority) {
|
fn show_toast(&self, text: impl AsRef<str>, priority: adw::ToastPriority) {
|
||||||
let imp = self.imp();
|
let imp = self.imp();
|
||||||
|
|
||||||
|
|
@ -480,12 +568,14 @@ impl LpWindow {
|
||||||
self.action_set_enabled("win.print", enabled);
|
self.action_set_enabled("win.print", enabled);
|
||||||
self.action_set_enabled("win.rotate", enabled);
|
self.action_set_enabled("win.rotate", enabled);
|
||||||
self.action_set_enabled("win.copy", 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-best-fit", enabled);
|
||||||
self.action_set_enabled("win.zoom-to", enabled);
|
self.action_set_enabled("win.zoom-to", enabled);
|
||||||
self.action_set_enabled("win.toggle-properties", 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();
|
let imp = self.imp();
|
||||||
|
|
||||||
if imp.image_view.current_page().is_some() {
|
if imp.image_view.current_page().is_some() {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue