// Copyright (c) 2024-2026 Sophie Herold // Copyright (c) 2026 Maximiliano Sandoval // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. If not, see . // // SPDX-License-Identifier: GPL-3.0-or-later use std::cell::{Cell, OnceCell, RefCell}; use std::sync::Arc; use adw::prelude::*; use adw::subclass::prelude::*; use glycin::Operations; use super::LpImage; use super::edit::LpEditCrop; use crate::deps::*; use crate::util::ErrorType; use crate::util::gettext::*; use crate::util::root::ParentWindow; mod imp { use super::*; #[derive(Debug, Default, gtk::CompositeTemplate, glib::Properties)] #[properties(wrapper_type = super::LpEditWindow)] #[template(file = "edit_window.ui")] pub struct LpEditWindow { #[template_child] toolbar_view: TemplateChild, #[template_child] cancel: TemplateChild, #[template_child] pub(crate) save: TemplateChild, #[template_child] saving_revealer: TemplateChild, #[template_child] saving_status: TemplateChild, #[template_child] saving_info: TemplateChild, #[template_child] shortcut_controller: TemplateChild, #[property(get, construct_only)] original_image: OnceCell, #[property(get, construct_only)] pub edit_crop: OnceCell, /// Whether the original file can be trashed pub(super) can_trash: Cell, pub(super) operations: RefCell>>, save_cancellable: RefCell>, } #[glib::object_subclass] impl ObjectSubclass for LpEditWindow { const NAME: &'static str = "LpEditWindow"; type Type = super::LpEditWindow; type ParentType = adw::Bin; fn class_init(klass: &mut Self::Class) { Self::bind_template(klass); klass.install_action("edit.save-copy", None, |obj, _, _| { glib::spawn_future_local(glib::clone!( #[weak] obj, async move { obj.imp().save_copy().await } )); }); klass.install_action("edit.save-overwrite", None, |obj, _, _| { glib::spawn_future_local(glib::clone!( #[weak] obj, async move { obj.imp().save_overwrite().await } )); }); klass.install_action("edit.cancel", None, move |obj, _, _| { obj.imp().cancel(); }); } fn instance_init(obj: &glib::subclass::InitializingObject) { obj.init_template(); } } #[glib::derived_properties] impl ObjectImpl for LpEditWindow { fn constructed(&self) { self.parent_constructed(); let obj = self.obj(); glib::spawn_future_local(glib::clone!( #[weak] obj, async move { let can_trash = obj.original_image().can_trash().await; obj.imp().can_trash.replace(can_trash); obj.action_set_enabled("edit.save-overwrite", can_trash); } )); self.shortcut_controller.add_shortcut(gtk::Shortcut::new( Some(gtk::KeyvalTrigger::new( gdk::Key::S, gdk::ModifierType::CONTROL_MASK, )), Some(gtk::NamedAction::new("edit.save-overwrite")), )); self.shortcut_controller.add_shortcut(gtk::Shortcut::new( Some(gtk::KeyvalTrigger::new( gdk::Key::S, gdk::ModifierType::CONTROL_MASK.union(gdk::ModifierType::SHIFT_MASK), )), Some(gtk::NamedAction::new("edit.save-copy")), )); self.shortcut_controller.add_shortcut(gtk::Shortcut::new( Some(gtk::KeyvalTrigger::new( gdk::Key::Escape, gdk::ModifierType::NO_MODIFIER_MASK, )), Some(gtk::NamedAction::new("edit.cancel")), )); } } impl WidgetImpl for LpEditWindow { fn root(&self) { self.parent_root(); let obj = self.obj(); obj.edit_crop() .selection() .connect_cropped_notify(glib::clone!( #[weak] obj, move |_| obj.imp().update_save_state(false) )); self.update_save_state(false); } } impl BinImpl for LpEditWindow {} impl LpEditWindow { fn cancel(&self) { let obj = self.obj(); if let Some(cancellable) = obj.imp().save_cancellable.replace(None) { cancellable.cancel(); } obj.window_inspect(|w| w.show_image()); } async fn save_copy(&self) { let obj = self.obj(); self.save.popdown(); if let Some(current_file) = obj.original_image().file() { let cancellable = gio::Cancellable::new(); if let Some(old_cancellable) = self.save_cancellable.replace(Some(cancellable.clone())) { old_cancellable.cancel(); } let file_dialog = gtk::FileDialog::new(); let suggested_file = if let Some(path) = current_file.path() { if let Some(basename) = path.file_stem() { let mut suggested_file = None; for i in 1..20 { let mut new_path = path.clone(); let mut new_filename = basename.to_os_string(); // Translators: Filename suffix, {} is replaced by a number new_filename.push(ngettext(" (Edited)", " (Edited {})", i)); if let Some(ext) = path.extension() { new_filename.push("."); new_filename.push(ext); } new_path.set_file_name(new_filename); let file = gio::File::for_path(&new_path); let file_exists = file .query_info_future( gio::FILE_ATTRIBUTE_STANDARD_NAME, gio::FileQueryInfoFlags::NONE, glib::Priority::DEFAULT, ) .await .is_ok(); if !file_exists { suggested_file = Some(file); break; } } suggested_file.unwrap_or_else(|| current_file.clone()) } else { current_file.clone() } } else { current_file.clone() }; file_dialog.set_initial_file(Some(&suggested_file)); match file_dialog.save_future(obj.try_window().as_ref()).await { Err(err) => { tracing::error!("{}", err); } Ok(new_file) => { if self .save(current_file, new_file.clone(), cancellable.clone()) .await { obj.window_inspect(|w| w.show_specific_image(new_file)); } } } } } pub(super) async fn save_overwrite(&self) { let obj = self.obj(); self.save.popdown(); if let Some(original_file) = obj.original_image().file() && let Some(current_path) = original_file.path() && let Some(mut file_stem) = current_path.file_stem().map(|x| x.to_os_string()) { let mut tmp_path = current_path.clone(); file_stem.push(".tmp"); if let Some(ext) = current_path.extension() { file_stem.push("."); file_stem.push(ext); } tmp_path.set_file_name(file_stem); let tmp_file = gio::File::for_path(&tmp_path); let cancellable = gio::Cancellable::new(); if let Some(old_cancellable) = self.save_cancellable.replace(Some(cancellable.clone())) { old_cancellable.cancel(); } let written = self .save(original_file.clone(), tmp_file.clone(), cancellable.clone()) .await; if written { tracing::debug!("Moving image to trash '{}'", original_file.uri()); let trash_result = gio::CancellableFuture::new( original_file.trash_future(glib::Priority::DEFAULT), cancellable.clone(), ) .await; if trash_result.is_err() { // Canceled tracing::debug!("Trashing image canceled"); return; } else if let Ok(Err(err)) = trash_result { obj.window_show_error( &gettext("Failed to save image."), &format!("Failed to move image {current_path:?} to trash and therefore couldn't save image: {err}"), ErrorType::General, ); return; } tracing::debug!("Moving '{}' to '{}'", tmp_file.uri(), original_file.uri()); let move_result = gio::CancellableFuture::new( tmp_file .move_future( &original_file, gio::FileCopyFlags::NONE, glib::Priority::DEFAULT, ) .0, cancellable.clone(), ) .await; if move_result.is_err() { // Canceled tracing::debug!("Moving image canceled"); return; } else if let Ok(Err(err)) = move_result { obj.window_show_error( &gettext("Failed to save image."), &format!( "Failed to move image image to {current_path:?}: {err} move {:?} to {:?}", tmp_file.path().unwrap(), original_file.path().unwrap() ), ErrorType::General, ); return; } obj.window_inspect(|w| w.show_specific_image(original_file)); } } } #[must_use] /// Saves image with operations applies /// /// Returns `true` if editing and saving was successful async fn save( &self, current_file: gio::File, new_file: gio::File, cancellable: gio::Cancellable, ) -> bool { let obj = self.obj(); obj.edit_crop().apply_crop(); self.set_saving_status(Some(gettext("Editing Image"))); let mut editor = glycin::Editor::new(current_file); editor.cancellable(cancellable.clone()); if let Some(operations) = obj.operations() { tracing::debug!("Computing edited image."); let result = match editor.edit().await { Ok(editable_image) => editable_image.apply_complete(&operations).await, Err(err) => Err(err), }; match result { Err(err) if matches!(err.error(), glycin::Error::Canceled(_)) => { tracing::debug!("Computing edited image canceled"); } Err(err) => { tracing::warn!("Failed to edit image: {err}"); obj.window_show_error( &gettext("Failed to edit image."), &format!("Failed to edit image:\n\n{err}\n\n{operations:#?}"), ErrorType::Loader, ) } Ok(edit) => { tracing::debug!("Saving edited image to '{}'", new_file.uri()); match edit.data().get() { Err(err) => { obj.window_show_error( &gettext("Failed to Save Image"), &format!("Failed to get binary data: {err}"), ErrorType::General, ); } Ok(data) => { self.set_saving_status(Some(gettext("Saving Image"))); let save_result = gio::CancellableFuture::new( new_file.replace_contents_future( data, None, true, gio::FileCreateFlags::NONE, ), cancellable, ) .await; if save_result.is_err() { tracing::debug!("Saving image canceled"); } else if let Ok(Err(err)) = save_result { obj.window_show_error( &gettext("Failed to Save Image"), &format!("Failed to write file:\n\n{err:?}"), ErrorType::General, ); } else { tracing::debug!("Image saved"); return true; } } } } } } self.set_saving_status(None); false } fn set_saving_status(&self, message: Option) { let is_saving = message.is_some(); self.saving_status.set_label(&message.unwrap_or_default()); self.saving_revealer.set_reveal_child(is_saving); self.update_save_state(is_saving); if is_saving { // Show text and spinner delayed glib::timeout_add_local_once( std::time::Duration::from_millis( self.saving_revealer.transition_duration().into(), ), glib::clone!( #[weak(rename_to=imp)] self, move || { imp.saving_info.set_reveal_child(true); } ), ); } else { self.saving_info.set_reveal_child(false); } } pub(super) fn is_save_sensitive(&self) -> bool { let obj = self.obj(); self.operations .borrow() .as_ref() .is_some_and(|x| !x.operations().is_empty()) || obj.edit_crop().selection().cropped() } pub fn update_save_state(&self, force_disabled: bool) { let obj = self.obj(); let enabled = self.is_save_sensitive() && !force_disabled; self.save.set_sensitive(enabled); obj.action_set_enabled("edit.save-overwrite", enabled & self.can_trash.get()); obj.action_set_enabled("edit.save-copy", enabled); } } } glib::wrapper! { pub struct LpEditWindow(ObjectSubclass) @extends gtk::Widget, adw::Bin, @implements gtk::Buildable, gtk::Accessible, gtk::ConstraintTarget; } impl LpEditWindow { pub const REQUIRED_OPERATIONS: &[glycin::OperationId] = &[ glycin::OperationId::Clip, glycin::OperationId::MirrorHorizontally, glycin::OperationId::Rotate, ]; pub fn new(image: LpImage) -> Self { glib::Object::builder() .property("original_image", image.clone()) .property("edit_crop", LpEditCrop::new(image)) .build() } pub fn operations(&self) -> Option> { self.imp().operations.borrow().clone() } pub fn set_operations(&self, operations: Option>) { let imp = self.imp(); imp.operations.replace(operations); imp.update_save_state(false); } pub fn has_unsaved_changes(&self) -> bool { self.imp().is_save_sensitive() } pub fn add_operation(&self, operation: glycin::Operation) { let imp = self.imp(); let mut operations = imp .operations .borrow() .as_ref() .map(|x| x.operations().to_vec()) .unwrap_or_default(); operations.push(operation); self.set_operations(Some(Arc::new(Operations::new(operations)))); } /// Returns if the window should proceed with closing pub async fn ensure_saved(&self) -> glib::Propagation { if self.has_unsaved_changes() { let dialog = adw::AlertDialog::new( Some(&gettext("Unsaved Changes")), Some(&gettext( "Changes which are not lost will be permanently lost", )), ); dialog.add_response("cancel", &gettext("Cancel")); dialog.add_response("discard", &gettext("Discard")); dialog.add_response("save", &gettext("Save")); dialog.set_response_appearance("discard", adw::ResponseAppearance::Destructive); let res = dialog.choose_future(Some(self)).await; match res.as_str() { "cancel" => glib::Propagation::Stop, "close" => glib::Propagation::Stop, "discard" => glib::Propagation::Proceed, "save" => { self.imp().save_overwrite().await; glib::Propagation::Proceed } res => { tracing::error!("Invalid dialog result: {res}"); glib::Propagation::Stop } } } else { glib::Propagation::Proceed } } }