// Copyright (c) 2020-2024 Christopher Davis // Copyright (c) 2022-2024 Sophie Herold // Copyright (c) 2022 Elton A Rodrigues // Copyright (c) 2022 Maximiliano Sandoval R // Copyright (c) 2023 Matteo Nardi // Copyright (c) 2023 FineFindus // Copyright (c) 2023 qwel // Copyright (c) 2023 Huan Thieu Nguyen // Copyright (c) 2024 Fina Wilke // Copyright (c) 2024 DaPigGuy // Copyright (c) 2024 James Frost // // 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 mod actions; mod controls; use std::cell::{Cell, OnceCell, RefCell}; use std::marker::PhantomData; use std::path::{Path, PathBuf}; use actions::*; use adw::prelude::*; use adw::subclass::prelude::*; use adw::{glib, gtk}; use gtk::Widget; use crate::application::LpApplication; use crate::config; use crate::deps::*; use crate::util::gettext::*; use crate::util::root::ParentWindow; use crate::util::Direction; use crate::widgets::window::ActionPartGlobal; use crate::widgets::{LpDragOverlay, LpImage, LpImageView, LpPropertiesView}; /// Animation duration for showing overlay buttons in milliseconds const SHOW_CONTROLS_ANIMATION_DURATION: u32 = 200; /// Animation duration for hiding overlay buttons in milliseconds const HIDE_CONTROLS_ANIMATION_DURATION: u32 = 1000; /// Time of inactivity after which controls will be hidden in milliseconds const HIDE_CONTROLS_IDLE_TIMEOUT: u64 = 3000; mod imp { use super::*; #[derive(Debug, Default, gtk::CompositeTemplate, glib::Properties)] #[properties(wrapper_type = super::LpImageWindow)] #[template(file = "image_window.ui")] pub struct LpImageWindow { #[template_child] pub(super) shortcut_controller: TemplateChild, #[template_child] pub(super) headerbar: TemplateChild, #[template_child] pub(super) headerbar_events: TemplateChild, #[template_child] pub(super) fullscreen_button: TemplateChild, #[template_child] pub(super) properties_button: TemplateChild, #[template_child] pub(super) primary_menu: TemplateChild, #[template_child] pub(super) toolbar_view: TemplateChild, #[template_child] pub(super) stack: TemplateChild, #[template_child] pub(super) status_page: TemplateChild, #[template_child] pub(super) image_view: TemplateChild, #[template_child] pub(super) properties_view: TemplateChild, #[template_child] pub(super) drag_overlay: TemplateChild, #[template_child] pub(super) drop_target: TemplateChild, #[template_child] pub(super) forward_click_gesture: TemplateChild, #[template_child] pub(super) backward_click_gesture: TemplateChild, #[property(get = Self::is_showing_image)] pub(super) is_showing_image: PhantomData, /// Motion controller for complete window pub(super) motion_controller: gtk::EventControllerMotion, pub(super) pointer_position: Cell<(f64, f64)>, pub(super) show_controls_animation: OnceCell, pub(super) hide_controls_animation: OnceCell, pub(super) hide_controls_timeout: RefCell>, } #[glib::object_subclass] impl ObjectSubclass for LpImageWindow { const NAME: &'static str = "LpImageWindow"; type Type = super::LpImageWindow; type ParentType = adw::Bin; fn instance_init(obj: &glib::subclass::InitializingObject) { obj.init_template(); } fn class_init(klass: &mut Self::Class) { // bind_template() is a function generated by the // CompositeTemplate macro to bind all children at once. Self::bind_template(klass); klass.add_binding(gdk::Key::c, gdk::ModifierType::CONTROL_MASK, |window| { if window.has_metadata_selected() { // Pass on to normal copy handler to copy selected metadata glib::Propagation::Proceed } else { window.copy_image(); glib::Propagation::Stop } }); klass.add_binding_action( gdk::Key::question, gdk::ModifierType::CONTROL_MASK, "win.show-help-overlay", ); // Set up actions Action::init_actions(klass); } } #[glib::derived_properties] impl ObjectImpl for LpImageWindow {} impl WidgetImpl for LpImageWindow { fn root(&self) { self.parent_root(); let obj = self.obj(); Action::add_bindings(&obj); self.forward_click_gesture.connect_pressed(glib::clone!( #[weak] obj, move |_, _, _, _| { obj.image_view().navigate(Direction::Forward, false); } )); self.backward_click_gesture.connect_pressed(glib::clone!( #[weak] obj, move |_, _, _, _| { obj.image_view().navigate(Direction::Back, false); } )); self.properties_button.connect_toggled(glib::clone!( #[weak] obj, move |_| { obj.on_properties_button_toggled(); } )); obj.on_current_page_changed(); obj.on_fullscreen_changed(); self.image_view.connect_current_page_notify(glib::clone!( #[weak] obj, move |_| obj.on_current_page_changed() )); obj.window().connect_fullscreened_notify(glib::clone!( #[weak] obj, move |_| obj.on_fullscreen_changed() )); let gesture_click = gtk::GestureClick::new(); gesture_click.connect_pressed(glib::clone!( #[weak] obj, move |_, _, _, _| obj.on_click() )); obj.add_controller(gesture_click); self.motion_controller.connect_motion(glib::clone!( #[weak] obj, move |_, x, y| obj.on_motion((x, y)) )); self.motion_controller.connect_enter(glib::clone!( #[weak] obj, move |_, x, y| obj.on_motion((x, y)) )); obj.add_controller(self.motion_controller.clone()); let current_image_signals = self.image_view.current_image_signals(); current_image_signals.connect_local( "metadata-changed", true, glib::clone!( #[weak] obj, #[upgrade_or_default] move |_| { obj.update_title(); None } ), ); current_image_signals.connect_closure( "notify::best-fit", false, // `closure_local!` is similar to `clone`, but you use `@watch` instead of clone. // `@watch` means that this signal will be disconnected when the watched object // is dropped. glib::closure_local!( #[watch] obj, move |_: &LpImage, _: &glib::ParamSpec| { obj.update_image_actions_status(); } ), ); current_image_signals.connect_closure( "notify::is-max-zoom", false, glib::closure_local!( #[watch] obj, move |_: &LpImage, _: &glib::ParamSpec| { obj.update_image_actions_status(); } ), ); current_image_signals.connect_closure( "notify::image-size-available", false, glib::closure_local!( #[watch] obj, move |_: &LpImage, _: &glib::ParamSpec| { obj.image_size_available(); } ), ); current_image_signals.connect_closure( "notify::is-loaded", false, glib::closure_local!( #[watch] obj, move |_: &LpImage, _: &glib::ParamSpec| { obj.update_image_actions_status(); } ), ); current_image_signals.connect_closure( "notify::error", false, glib::closure_local!( #[watch] obj, move |_: &LpImage, _: &glib::ParamSpec| { obj.image_error(); obj.update_image_actions_status(); } ), ); obj.connect_is_showing_image_notify(|obj| { obj.update_headerbar_style(); }); self.properties_button.connect_active_notify(glib::clone!( #[weak] obj, move |_| { obj.update_headerbar_style(); } )); // action win.previous status self.image_view .connect_is_previous_available_notify(glib::clone!( #[weak] obj, move |_| { obj.action_set_enabled( &Action::Previous, obj.imp().image_view.is_previous_available(), ); } )); // action win.next status self.image_view .connect_is_next_available_notify(glib::clone!( #[weak] obj, move |_| { obj.action_set_enabled( &Action::Next, obj.imp().image_view.is_next_available(), ); } )); // Make widgets visible when the focus moves obj.connect_move_focus(|obj, _| { obj.show_controls(); obj.schedule_hide_controls(); }); obj.image_view() .zoom_menu_button() .connect_active_notify(glib::clone!( #[weak] obj, move |button| { if button.is_active() { // Show overlay controls when zoom popover in them gets opened obj.show_controls(); } else { // Schedule hide overlay controls when zoom popover gets closed obj.schedule_hide_controls(); } } )); // Activate global shortcuts only if no dialog is open obj.window().connect_visible_dialog_notify(glib::clone!( #[weak] obj, move |_| obj.update_accel_status() )); obj.window().connect_is_active_notify(glib::clone!( #[weak] obj, move |_| obj.update_accel_status() )); obj.connect_unmap(|win| win.update_accel_status()); obj.connect_map(|win| win.update_accel_status()); self.status_page .set_icon_name(Some(&format!("{}-symbolic", config::APP_ID))); self.drop_target.set_types(&[gdk::FileList::static_type()]); self.drop_target.connect_accept(glib::clone!( #[weak] obj, #[upgrade_or] false, move |_drop_target, drop| { // Only accept drops from external sources or different windows let different_source = drop.drag().is_none() || drop.drag() != obj.image_view().drag_source().drag(); // We have to do this manually since we are overwriting the default handler let correct_format = drop.formats().contains_type(gdk::FileList::static_type()); different_source && correct_format } )); // For callbacks, you will want to reference the GTK docs on // the relevant signal to see which parameters you need. // In this case, we need only need the GValue, // so we name it `value` then use `_` for the other spots. self.drop_target.connect_drop(glib::clone!( #[weak] obj, #[upgrade_or] false, move |_, value, _, _| { // Here we use a GValue, which is a dynamic object that can hold different // types, e.g. strings, numbers, or in this case objects. In // order to get the GdkFileList from the GValue, we need to // use the `get()` method. // // We've added type annotations here, and written it as `let list: gdk::FileList // = ...`, but you might also see places where type // arguments are used. This line could have been written as // `let list = value.get::().unwrap()`. let files = match value.get::() { Ok(list) => list.files(), Err(err) => { log::error!("Issue with drop value: {err}"); return false; } }; if !files.is_empty() { obj.image_view().set_images_from_files(files); } else { log::error!("Dropped FileList was empty"); return false; } // Maybe one day this will actually work log::debug!("Trying to focus window after drag and drop"); obj.window().present(); true } )); } } impl BinImpl for LpImageWindow {} impl LpImageWindow { fn is_showing_image(&self) -> bool { self.image_view.try_get().is_some_and(|image_view| { image_view .current_page() .is_some_and(|page| page.image().error().is_none()) }) } } } glib::wrapper! { pub struct LpImageWindow(ObjectSubclass) @extends gtk::Widget, adw::Bin; } impl LpImageWindow { fn zoom_out_cursor(&self) { if let Some(image) = self.imp().image_view.current_image() { image.zoom_out_cursor(); } } fn zoom_out_center(&self) { if let Some(image) = self.imp().image_view.current_image() { image.zoom_out_center(); } } fn zoom_in_cursor(&self) { if let Some(image) = self.imp().image_view.current_image() { image.zoom_in_cursor(); } } fn zoom_in_center(&self) { if let Some(image) = self.imp().image_view.current_image() { image.zoom_in_center(); } } fn zoom_to_exact(&self, level: f64) { if let Some(image) = self.image_view().current_image() { image.zoom_to_exact(level); } } fn zoom_best_fit(&self) { if let Some(page) = self.imp().image_view.current_page() { page.image().zoom_best_fit(); } } pub fn pan(&self, direction: >k::PanDirection) { if let Some(image) = self.imp().image_view.current_image() { image.pan(direction); } } async fn pick_file(&self) { let filter_list = gio::ListStore::new::(); let filter_supported_formats = gtk::FileFilter::new(); filter_supported_formats.set_name(Some(&gettext("Supported image formats"))); for mime_type in glycin::Loader::supported_mime_types().await { filter_supported_formats.add_mime_type(mime_type.as_str()); } let filter_all_files = gtk::FileFilter::new(); filter_all_files.set_name(Some(&gettext("All files"))); filter_all_files.add_pattern("*"); filter_list.append(&filter_supported_formats); filter_list.append(&filter_all_files); let chooser = gtk::FileDialog::builder() .title(gettext("Open Image")) .filters(&filter_list) .default_filter(&filter_supported_formats) .modal(true) .build(); chooser.set_initial_folder( self.image_view() .current_image() .and_then(|x| x.file()) .and_then(|x| x.parent()) .as_ref(), ); if let Ok(selected) = chooser.open_multiple_future(Some(&self.window())).await { let images: Vec<_> = selected .into_iter() .filter_map(|files| { files .ok() .and_then(|object| object.downcast::().ok()) }) .collect(); self.image_view().set_images_from_files(images); } else { log::debug!("File dialog canceled or file not readable"); } } async fn open_with(&self) { let imp = self.imp(); if let Some(ref file) = imp.image_view.current_file() { let launcher = gtk::FileLauncher::new(Some(file)); launcher.set_always_ask(true); if let Err(e) = launcher.launch_future(Some(&self.window())).await { if !e.matches(gtk::DialogError::Dismissed) { log::error!("Could not open image in external program: {}", e); } } } else { log::error!("Could not load a path for the current image.") } } fn rotate_image(&self, angle: f64) { self.imp().image_view.rotate_image(angle) } async fn set_background(&self) { let imp = self.imp(); if let Err(e) = imp.image_view.set_background().await { log::error!("Failed to set background: {}", e); } } fn print(&self) { let imp = self.imp(); if let Err(e) = imp.image_view.print() { log::error!("Failed to print file: {}", e); } } /// Returns true if some text in metadata is currently selected fn has_metadata_selected(&self) -> bool { if let Some(focus_widget) = GtkWindowExt::focus(&self.window()) { if focus_widget.is_ancestor(&*self.imp().properties_view) { if let Ok(label) = focus_widget.downcast::() { return label.selection_bounds().is_some(); } } } false } /// Copy image to clipboard or metadata text if selected fn copy_image(&self) { let imp = self.imp(); if let Err(e) = imp.image_view.copy() { log::error!("Failed to copy to clipboard: {}", e); } else { self.window().show_toast( &gettext("Image copied to clipboard"), adw::ToastPriority::High, ); } } async fn trash(&self) { let image_view = self.image_view(); let (Some(file), Some(path)) = ( image_view.current_file(), image_view.current_file().and_then(|x| x.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(gettext("Image moved to trash")) .button_label(gettext("Undo")) .priority(adw::ToastPriority::High) .build(); toast.connect_button_clicked(glib::clone!( #[weak(rename_to = obj)] self, move |_| { let path = path.clone(); glib::spawn_future_local(async move { obj.image_view() .set_trash_restore(Some(gio::File::for_path(&path))); let result = crate::util::untrash(&path).await; if let Err(err) = result { log::error!("Failed to untrash {path:?}: {err}"); obj.window().show_toast( &gettext("Failed to restore image from trash"), adw::ToastPriority::High, ); } }); } )); self.window().add_toast(toast); } Err(err) => { if Some(gio::IOErrorEnum::NotSupported) == err.kind::() { self.delete_future(&path).await; } else { log::error!("Failed to delete file {path:?}: {err}"); self.window().show_toast( &gettext("Failed to move image to trash"), adw::ToastPriority::Normal, ); } } } } /// Delete action called async fn delete(&self) { let image_view = self.image_view(); let Some(path) = image_view.current_file().and_then(|x| x.path()) else { log::error!("No file to delete"); return; }; self.delete_future(&path).await; } /// Permanently delete image /// /// Fallback for when trash not available or explicit call with shortcut async fn delete_future(&self, path: &Path) { let dialog = adw::AlertDialog::builder() .heading(gettext("Permanently Delete Image?")) .body(gettext_f( "After deleting the image “{}” it will be permanently lost.", [PathBuf::from(&path.file_name().unwrap_or_default()) .display() .to_string()], )) .build(); dialog.add_responses(&[ ("cancel", &gettext("Cancel")), ("delete", &gettext("Delete")), ]); dialog.set_response_appearance("delete", adw::ResponseAppearance::Destructive); if "delete" == dialog.choose_future(self).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.window().show_toast( &gettext("Failed to delete image"), adw::ToastPriority::Normal, ); } } } pub fn image_view(&self) -> LpImageView { self.imp().image_view.clone() } pub fn headerbar(&self) -> adw::HeaderBar { self.imp().headerbar.clone() } /// Set status for actions that are related to the active image fn update_image_actions_status(&self) { // Actions that are enabled if the image is shown const ACTIONS_SHOWN: &[Action] = &[ Action::SetBackground, Action::Print, Action::RotateCw, Action::RotateCcw, Action::ZoomBestFit, Action::ZoomToExact100, Action::ZoomToExact200, Action::ZoomToExact300, ]; let enabled_shown = self .imp() .image_view .current_page() .is_some_and(|x| x.image().is_loaded() && x.image().error().is_none()); for action in ACTIONS_SHOWN { self.action_set_enabled(action, enabled_shown); } // Zoom specific actions let can_zoom_out = self .image_view() .current_image() .is_some_and(|image| !image.is_best_fit()); let can_zoom_in = self .image_view() .current_image() .is_some_and(|image| !image.is_max_zoom()); self.action_set_enabled(&Action::ZoomOutCursor, can_zoom_out && enabled_shown); self.action_set_enabled(&Action::ZoomOutCenter, can_zoom_out && enabled_shown); self.action_set_enabled(&Action::ZoomInCursor, can_zoom_in && enabled_shown); self.action_set_enabled(&Action::ZoomInCenter, can_zoom_in && enabled_shown); self.image_view().set_zoom_toggle_state( self.image_view() .current_image() .is_some_and(|image| !image.is_best_fit()), ); // Actions that are available if there is an current image, even if it's not // shown const ACTIONS_CURRENT: &[Action] = &[ Action::OpenWith, Action::CopyImage, Action::ToggleProperties, Action::Reload, Action::ToggleFullscreen, ]; let enabled_current = self.imp().image_view.current_page().is_some(); for action in ACTIONS_CURRENT { self.action_set_enabled(action, enabled_current); } // Editing related actions let enable_editing = self .imp() .image_view .current_image() .is_some_and(|x| x.editable()); self.action_set_enabled(&Action::Edit, enable_editing); } /// Handles change in image and availability of images fn on_current_page_changed(&self) { let imp = self.imp(); let was_showing_image = imp.stack.visible_child().as_ref() == Some(imp.image_view.upcast_ref::()); let current_page = imp.image_view.current_page(); // HeaderBar style self.notify_is_showing_image(); // Window title self.update_title(); // Properties view let current_image: Option = current_page.as_ref().map(|x| x.image()); imp.properties_view.set_image(current_image.as_ref()); let has_image = current_page.is_some(); self.update_image_actions_status(); self.action_set_enabled( &Action::Trash, imp.image_view .current_file() .is_some_and(|file| file.path().is_some()), ); if has_image { // Properties buttons was not sensitive before if !imp.properties_button.is_sensitive() { let settings = LpApplication::default().settings(); // Pickup config for setting it's state imp.properties_button .set_active(settings.boolean("show-properties")); } if !was_showing_image { imp.stack.set_visible_child(&*imp.image_view); imp.image_view.grab_focus(); self.show_controls(); self.schedule_hide_controls(); } } else { imp.properties_button.set_active(false); imp.stack.set_visible_child(&*imp.status_page); imp.status_page.grab_focus(); // Leave fullscreen since status page has no controls to leave it self.window().set_fullscreened(false); } imp.properties_button.set_sensitive(has_image); } /// When the image-properties sidebar is displayed or hidden, we should /// update the "show-properties" setting. fn on_properties_button_toggled(&self) { let imp = self.imp(); // When no image is shown, we skip this update as the sidebar should always be // hidden. This can happen when deleting a picture. if imp.image_view.current_page().is_none() { return; } let settings = LpApplication::default().settings(); let result = settings.set_boolean("show-properties", imp.properties_button.is_active()); if let Err(err) = result { log::warn!("Failed to save show-properties state, {}", err); } } pub fn update_title(&self) { let title = self .imp() .image_view .current_image() .and_then(|x| x.metadata().file_name()) .unwrap_or_else(|| gettext("Image Viewer")); self.window().set_title(Some(&title)); } pub fn image_size_available(&self) { // if visible for whatever reason, don't do any resize if self.is_visible() { return; } let image = self .imp() .image_view .current_page() .map(|page| page.image()); if image.is_some_and(|img| img.image_size_available()) { log::debug!("Showing window because image size is ready"); self.window().present(); } } pub fn image_error(&self) { self.notify_is_showing_image(); if self.is_visible() { return; } let current_page = self.imp().image_view.current_page(); if current_page.is_some_and(|page| page.image().error().is_some()) { log::debug!("Showing window because loading image failed"); self.window().present(); } } fn on_fullscreen_changed(&self) { let icon = if self.window().is_fullscreen() { "view-restore-symbolic" } else { "view-fullscreen-symbolic" }; self.imp().fullscreen_button.set_icon_name(icon); self.update_headerbar_style(); if !self.window().is_fullscreen() { self.set_cursor(None); self.show_controls(); } self.schedule_hide_controls(); } fn is_headerbar_flat(&self) -> bool { matches!( self.imp().toolbar_view.top_bar_style(), adw::ToolbarStyle::Flat ) } fn is_content_extended_to_top(&self) -> bool { self.window().is_fullscreen() && !self.imp().properties_button.is_active() } fn update_accel_status(&self) { let Some(application) = self.window().application() else { log::error!("No application for window found"); return; }; // Only change status if active window if self.window().is_active() { if self.window().visible_dialog().is_some() || !self.is_mapped() { // If AdwDialog is visible, remove global accels that are for the main window // Same for this not being visible, so potentially editing view is open ActionPartGlobal::remove_accels(&application); } else { // Add accels if viewing main window ActionPartGlobal::add_accels(&application); } } } pub fn properties_button(&self) -> gtk::ToggleButton { self.imp().properties_button.clone() } fn update_header_opacity(&self) { let imp = self.imp(); if self.is_headerbar_flat() && self.is_showing_image() { // Bring headerbar opacity in sync with controls imp.headerbar.set_opacity(self.headerbar_opacity()); } else { imp.headerbar.set_opacity(1.); } } fn update_headerbar_style(&self) { let imp = self.imp(); imp.toolbar_view .set_extend_content_to_top_edge(self.is_content_extended_to_top()); let style = if !self.is_showing_image() || self.is_content_extended_to_top() { // Flat headerbar for empty state and fullscreen without properties enabled adw::ToolbarStyle::Flat } else { // Use the border variant of raised to avoid shadows over images adw::ToolbarStyle::RaisedBorder }; if style != imp.toolbar_view.top_bar_style() { imp.toolbar_view.set_top_bar_style(style); self.update_header_opacity(); } } fn headerbar_opacity(&self) -> f64 { self.imp().headerbar.opacity() } }