diff --git a/Cargo.lock b/Cargo.lock index 752986d..2c441d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -987,6 +987,7 @@ dependencies = [ "proc-macro-crate", "proc-macro-error", "proc-macro2", + "quick-xml", "quote", "syn", ] @@ -1023,6 +1024,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "heck" version = "0.4.0" @@ -1063,6 +1070,16 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "indexmap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885e79c1fc4b10f0e172c475f458b7f7b93061064d98c3293e98c5ba0c8b399" +dependencies = [ + "autocfg", + "hashbrown", +] + [[package]] name = "instant" version = "0.1.12" @@ -1206,6 +1223,7 @@ dependencies = [ "gio 0.16.6", "gtk-macros", "gtk4", + "indexmap", "kamadak-exif", "libadwaita", "libgweather", @@ -1474,6 +1492,15 @@ version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" +[[package]] +name = "quick-xml" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffc053f057dd768a56f62cd7e434c42c831d296968997e9ac1f76ea7c2d14c41" +dependencies = [ + "memchr", +] + [[package]] name = "quote" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index e703e3f..3e7f932 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ futures = "0.3.25" svg_metadata = "0.4.3" url = "2.3.1" libgweather = "4.0.0" +indexmap = "1.9.2" [dependencies.ashpd] version = "0.4.0-alpha.1" @@ -26,7 +27,7 @@ features = ["gtk4"] [dependencies.gtk] package = "gtk4" version = "0.5.5" -features = ["v4_10"] +features = ["v4_10", "xml_validation"] [dependencies.gio] package = "gio" diff --git a/build-aux/flatpak/libadwaita-issue-597.patch b/build-aux/flatpak/libadwaita-issue-597.patch deleted file mode 100644 index ba6b804..0000000 --- a/build-aux/flatpak/libadwaita-issue-597.patch +++ /dev/null @@ -1,59 +0,0 @@ -From b2c52f258757e37a74a95c109431f6af8cda8f91 Mon Sep 17 00:00:00 2001 -From: Sophie Herold -Date: Wed, 7 Dec 2022 17:42:06 +0100 -Subject: [PATCH] carousel: give new child a snap_point immediately - -Fixes #457, #597 ---- - src/adw-carousel.c | 21 ++++++++++++++++++++- - 1 file changed, 20 insertions(+), 1 deletion(-) - -diff --git a/src/adw-carousel.c b/src/adw-carousel.c -index 47ea9475..5a9816f9 100644 ---- a/src/adw-carousel.c -+++ b/src/adw-carousel.c -@@ -1207,6 +1207,8 @@ adw_carousel_insert (AdwCarousel *self, - { - ChildInfo *info; - GList *next_link = NULL; -+ double snap_point = 0; -+ GList *children; - - g_return_if_fail (ADW_IS_CAROUSEL (self)); - g_return_if_fail (GTK_IS_WIDGET (widget)); -@@ -1214,7 +1216,8 @@ adw_carousel_insert (AdwCarousel *self, - - info = g_new0 (ChildInfo, 1); - info->widget = widget; -- info->size = 0; -+ // set a minimal size to give child it's own snap_point from the beginning -+ info->size = 0.00001; - info->adding = TRUE; - - if (position >= 0) -@@ -1230,6 +1233,22 @@ adw_carousel_insert (AdwCarousel *self, - gtk_widget_set_parent (widget, GTK_WIDGET (self)); - } - -+ // work around this usually happening in measure which can be too late -+ for (children = self->children; children; children = children->next) { -+ ChildInfo *child_info = children->data; -+ -+ child_info->snap_point = snap_point + child_info->size - 1; -+ -+ snap_point += child_info->size; -+ -+ if (child_info == self->animation_target_child) -+ adw_spring_animation_set_value_to (ADW_SPRING_ANIMATION (self->animation), -+ child_info->snap_point); -+ } -+ -+ // start animation from correct size -+ info->size = 0; -+ - gtk_widget_queue_allocate (GTK_WIDGET (self)); - - animate_child_resize (self, info, 1, self->reveal_duration); --- -GitLab - diff --git a/build-aux/flatpak/org.gnome.Loupe.Devel.json b/build-aux/flatpak/org.gnome.Loupe.Devel.json index 03ac589..0db1f54 100644 --- a/build-aux/flatpak/org.gnome.Loupe.Devel.json +++ b/build-aux/flatpak/org.gnome.Loupe.Devel.json @@ -91,22 +91,6 @@ } ] }, - /* Use patched libadwaita version to test the fix */ - { - "name": "libadwaita", - "buildsystem": "meson", - "sources": [ - { - "type": "git", - "url": "https://gitlab.gnome.org/GNOME/libadwaita.git", - "branch": "main" - }, - { - "type": "patch", - "path": "libadwaita-issue-597.patch" - } - ] - }, { "name" : "loupe", "builddir" : true, diff --git a/data/gtk/image_view.ui b/data/gtk/image_view.ui index aaf2cb0..44159d1 100644 --- a/data/gtk/image_view.ui +++ b/data/gtk/image_view.ui @@ -8,11 +8,9 @@ True - - True - False - False - + + + diff --git a/data/gtk/window.ui b/data/gtk/window.ui index ba95c8b..f03a25e 100644 --- a/data/gtk/window.ui +++ b/data/gtk/window.ui @@ -63,7 +63,11 @@ Loupe - image_view + + + image_view + + @@ -187,10 +191,9 @@ - - + image_view diff --git a/data/loupe.gresource.xml b/data/loupe.gresource.xml index e481948..042c912 100644 --- a/data/loupe.gresource.xml +++ b/data/loupe.gresource.xml @@ -2,10 +2,6 @@ gtk/help_overlay.ui - gtk/window.ui - gtk/image_page.ui - gtk/image_view.ui - gtk/properties_view.ui style.css style-hc.css diff --git a/src/application.rs b/src/application.rs index 83efa53..dc5fc90 100644 --- a/src/application.rs +++ b/src/application.rs @@ -76,13 +76,13 @@ mod imp { let application = self.instance(); for file in files { let win = LpWindow::new(&*application); - win.set_image_from_file(file, true); + win.set_image_from_path(&file.path().unwrap()); // show window when image size is known let watch = win .imp() .image_view - .property_expression("current-page-strict") + .property_expression("current-page") .chain_property::("image") .chain_property::("image-size") .watch( @@ -97,7 +97,7 @@ mod imp { let watch = win .imp() .image_view - .property_expression("current-page-strict") + .property_expression("current-page") .chain_property::("error") .watch( glib::Object::NONE, diff --git a/src/file_model.rs b/src/file_model.rs index 7a95237..e701c3d 100644 --- a/src/file_model.rs +++ b/src/file_model.rs @@ -21,81 +21,87 @@ use crate::deps::*; use crate::i18n::*; use crate::util; -use gio::prelude::*; -use gio::subclass::prelude::*; +use std::path::{Path, PathBuf}; use anyhow::Context; -use once_cell::sync::OnceCell; - +use gio::prelude::*; +use gio::subclass::prelude::*; +use indexmap::IndexSet; +use once_cell::sync::{Lazy, OnceCell}; use std::cell::RefCell; mod imp { use super::*; + use gtk::glib::subclass::Signal; #[derive(Debug, Default)] pub struct LpFileModel { - pub(super) inner: RefCell>, - pub(super) directory: OnceCell, + /// Use and IndexSet such that we can put the elements into an arbitrary order + /// while still having fast hash access via `PathBuf`. + pub(super) files: RefCell>, + pub(super) directory: OnceCell, + /// Track file changes. + pub(super) monitor: OnceCell, } #[glib::object_subclass] impl ObjectSubclass for LpFileModel { const NAME: &'static str = "LpFileModel"; type Type = super::LpFileModel; - type Interfaces = (gio::ListModel,); } - impl ObjectImpl for LpFileModel {} - - impl ListModelImpl for LpFileModel { - fn item_type(&self) -> glib::Type { - gio::File::static_type() - } - - fn n_items(&self) -> u32 { - self.inner.borrow().len() as u32 - } - - fn item(&self, position: u32) -> Option { - self.inner - .borrow() - .get(position as usize) - .map(|f| f.clone().upcast()) + impl ObjectImpl for LpFileModel { + fn signals() -> &'static [Signal] { + static SIGNALS: Lazy> = + Lazy::new(|| vec![Signal::builder("changed").build()]); + SIGNALS.as_ref() } } } glib::wrapper! { - pub struct LpFileModel(ObjectSubclass) @implements gio::ListModel; + pub struct LpFileModel(ObjectSubclass); +} + +impl Default for LpFileModel { + fn default() -> Self { + glib::Object::new(&[]) + } } impl LpFileModel { - pub fn from_file(file: &gio::File) -> anyhow::Result { - let model = glib::Object::new::(&[]); - let directory = file.parent().context("File has not parent")?; - let file = - directory.resolve_relative_path(file.basename().context("File has no basename")?); + /// Create with a single element + pub fn from_path(path: &Path) -> Self { + let model = Self::default(); { - let mut vec = model.imp().inner.borrow_mut(); - vec.push(file); + let mut vec = model.imp().files.borrow_mut(); + vec.insert(path.to_path_buf()); } - model.imp().directory.set(directory).unwrap(); - - Ok(model) + model } - pub async fn load_directory(&self) -> anyhow::Result<()> { - let directory = self.imp().directory.get().unwrap().clone(); + /// Load all file from the given directory + /// + /// This can be used if there are already files present + pub async fn load_directory(&self, directory: PathBuf) -> anyhow::Result<()> { + self.imp().directory.set(directory.clone()).unwrap(); - let original_vec: Vec> = - self.imp().inner.borrow().iter().map(|x| x.path()).collect(); + let monitor = gio::File::for_path(&directory) + .monitor_directory(gio::FileMonitorFlags::WATCH_MOVES, gio::Cancellable::NONE)?; + + monitor.connect_changed( + glib::clone!(@weak self as obj => move |_monitor, file_a, file_b, event| { + obj.file_monitor_cb(event, file_a, file_b); + }), + ); + self.imp().monitor.set(monitor).unwrap(); let new_files_result = util::spawn("list-files", move || { - let mut vec = Vec::new(); + let mut files = IndexSet::new(); - let enumerator = directory + let enumerator = gio::File::for_path(&directory) .enumerate_children( &format!( "{},{}", @@ -107,24 +113,20 @@ impl LpFileModel { ) .context(i18n("Could not list other files in directory."))?; - // Filter out non-images; For now we support "all" image types. enumerator.for_each(|info| { if let Ok(info) = info { if let Some(content_type) = info.content_type().map(|t| t.to_string()) { + // Filter out non-images; For now we support "all" image types. if content_type.starts_with("image/") { - let name = info.name(); - let relative_path = directory.resolve_relative_path(&name); - if !original_vec.contains(&relative_path.path()) { - log::debug!("{:?} is an image, adding to the list", name); - vec.push(relative_path); - } + let path = PathBuf::from_iter([directory.clone(), info.name()]); + log::debug!("{path:?} is an image, adding to the list"); + files.insert(path); } } } }); - //anyhow::Result::Ok(vec) - Ok::<_, anyhow::Error>(vec) + Ok::<_, anyhow::Error>(files) }) .await; @@ -133,32 +135,166 @@ impl LpFileModel { return Ok(()); }; + let mut new_files = new_files?; + { // Here we use a nested scope so that the mutable borrow only lasts as long as we need it - let mut files = self.imp().inner.borrow_mut(); - files.append(&mut new_files?); + let mut files = self.imp().files.borrow_mut(); + for path in files.iter() { + new_files.insert(path.clone()); + } + *files = new_files; // Then sort by name. - files.sort_by(util::compare_by_name); + Self::sort(&mut files); } Ok(()) } - pub fn directory(&self) -> Option { + pub fn directory(&self) -> Option { self.imp().directory.get().cloned() } - pub fn file(&self, index: u32) -> Option { - let vec = self.imp().inner.borrow(); - vec.get(index as usize).cloned() + pub fn index_of(&self, path: &Path) -> Option { + self.imp().files.borrow().get_index_of(path) } - pub fn index_of(&self, file: &gio::File) -> Option { - let imp = self.imp(); - let vec = imp.inner.borrow(); - vec.binary_search_by(|a| util::compare_by_name(a, file)) - .ok() - .map(|i| i as u32) + pub fn n_files(&self) -> usize { + self.imp().files.borrow().len() + } + + pub fn contains(&self, path: &Path) -> bool { + self.imp().files.borrow().contains(path) + } + + pub fn before(&self, path: &Path) -> Option { + let index = self.index_of(path)?; + + self.imp() + .files + .borrow() + .get_index(index.checked_sub(1)?) + .cloned() + } + + pub fn after(&self, path: &Path) -> Option { + let index = self.index_of(path)?; + + self.imp() + .files + .borrow() + .get_index(index.checked_add(1)?) + .cloned() + } + + /// Returns `n` elements before and after given path + pub fn files_around(&self, path: &Path, n: usize) -> IndexSet { + let Some(index) = self.index_of(path) else { + log::error!("Path not in model: {path:?}"); + return IndexSet::new(); + }; + + let reduce = if index < n { n - index } else { 0 }; + + self.imp() + .files + .borrow() + .iter() + .skip(index.saturating_sub(n)) + .take(2 * n + 1 - reduce) + .cloned() + .collect() + } + + /// Currently sorts by name + fn sort(files: &mut IndexSet) { + files.sort_by(|x, y| util::compare_by_name(x, y)); + } + + /// Determines if a file is added + fn is_image(info: &gio::FileInfo) -> bool { + info.content_type() + .map(|t| t.to_string()) + .filter(|t| t.starts_with("image/")) + .is_some() + } + + fn is_image_file(file: &gio::File) -> bool { + let Ok(info) = util::query_attributes(file, vec![&gio::FILE_ATTRIBUTE_STANDARD_CONTENT_TYPE]) else { + log::warn!("Could not query file info: {:?}", file.path()); + return false; + }; + + Self::is_image(&info) + } + + /// Signal that notifies about added/removed files + pub fn connect_changed(&self, f: impl Fn() + 'static) { + self.connect_local("changed", false, move |_| { + f(); + None + }); + } + + fn file_monitor_cb( + &self, + event: gio::FileMonitorEvent, + file_a: &gio::File, + file_b: Option<&gio::File>, + ) { + let Some(path_a) = file_a.path() else { + log::error!("File has no path: {event}"); + return; + }; + let path_b = file_b.and_then(|x| x.path()); + + let changed = match event { + gio::FileMonitorEvent::Created + | gio::FileMonitorEvent::MovedIn + // Changing file content could theoretically make it an image + // by adding a magic byte + | gio::FileMonitorEvent::ChangesDoneHint + if Self::is_image_file(file_a) => + { + let mut files = self.imp().files.borrow_mut(); + let changed = files.insert(path_a); + if changed { + Self::sort(&mut files); + } + changed + } + gio::FileMonitorEvent::Deleted | gio::FileMonitorEvent::MovedOut | gio::FileMonitorEvent::Unmounted => { + let mut files = self.imp().files.borrow_mut(); + let changed = files.remove(&path_a); + if changed { + Self::sort(&mut files); + } + changed + } + gio::FileMonitorEvent::Renamed => { + if let (Some(path_b), Some(file_b)) = (path_b, file_b) { + let mut changed = false; + { + let mut files = self.imp().files.borrow_mut(); + changed |= files.remove(&path_a); + if Self::is_image_file(file_b) { + changed |= files.insert(path_b); + if changed { + Self::sort(&mut files); + } + } + } + changed + } else { + false + } + } + _ => false, + }; + + if changed { + self.emit_by_name::<()>("changed", &[]); + } } } diff --git a/src/image_metadata/gps.rs b/src/image_metadata/gps.rs index 35cdea5..4757533 100644 --- a/src/image_metadata/gps.rs +++ b/src/image_metadata/gps.rs @@ -15,7 +15,7 @@ pub struct GPSCoord { } impl GPSCoord { - fn to_f64(&self) -> f64 { + fn to_f64(self) -> f64 { let sign = if self.sing { 1. } else { -1. }; let min = self.min.unwrap_or_default(); diff --git a/src/image_metadata/mod.rs b/src/image_metadata/mod.rs index 6f760a0..65cb01c 100644 --- a/src/image_metadata/mod.rs +++ b/src/image_metadata/mod.rs @@ -9,12 +9,12 @@ mod obj; pub use gps::GPSLocation; pub use obj::LpImageMetadata; -use gio::prelude::*; - use crate::i18n::*; use gtk::glib; +use std::path::Path; + #[derive(Default)] pub enum ImageMetadata { Exif(exif::Exif), @@ -44,16 +44,14 @@ impl std::fmt::Debug for ImageMetadata { } impl ImageMetadata { - pub fn load(file: &gio::File) -> Self { - if let Some(path) = file.path() { - log::debug!("Loading metadata for {:?}", path); - if let Ok(file) = std::fs::File::open(path) { - let mut bufreader = std::io::BufReader::new(&file); - let exifreader = exif::Reader::new(); + pub fn load(path: &Path) -> Self { + log::debug!("Loading metadata for {:?}", path); + if let Ok(file) = std::fs::File::open(path) { + let mut bufreader = std::io::BufReader::new(&file); + let exifreader = exif::Reader::new(); - if let Ok(exif) = exifreader.read_from_container(&mut bufreader) { - return Self::Exif(exif); - } + if let Ok(exif) = exifreader.read_from_container(&mut bufreader) { + return Self::Exif(exif); } } diff --git a/src/image_metadata/obj.rs b/src/image_metadata/obj.rs index 8c961e2..b24d415 100644 --- a/src/image_metadata/obj.rs +++ b/src/image_metadata/obj.rs @@ -7,16 +7,17 @@ use gtk::glib; use once_cell::sync::Lazy; use std::cell::RefCell; +use std::path::Path; glib::wrapper! { pub struct LpImageMetadata(ObjectSubclass); } impl LpImageMetadata { - pub fn load(file: &gio::File) -> Self { + pub fn load(path: &Path) -> Self { let obj = glib::Object::new::(&[]); - let metadata = ImageMetadata::load(file); + let metadata = ImageMetadata::load(path); obj.imp().metadata.replace(metadata); obj diff --git a/src/main.rs b/src/main.rs index 0fd2338..7b9ab9c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -31,7 +31,7 @@ mod widgets; mod window; mod deps { - pub use gtk::{gdk, gdk_pixbuf, gio, glib, graphene}; + pub use gtk::{gdk, gdk_pixbuf, gio, glib, graphene, gsk}; } use application::LpApplication; diff --git a/src/util.rs b/src/util.rs index 02fdebc..7c66269 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,10 +1,10 @@ use crate::deps::*; +use anyhow::Context; use gio::prelude::*; -use anyhow::Context; - use std::fmt::{Debug, Write}; +use std::path::Path; pub fn get_file_display_name(file: &gio::File) -> Option { let info = query_attributes(file, vec![&gio::FILE_ATTRIBUTE_STANDARD_DISPLAY_NAME]).ok()?; @@ -46,9 +46,9 @@ pub async fn query_attributes_future( .context("Failed to query attributes") } -pub fn compare_by_name(file_a: &gio::File, file_b: &gio::File) -> std::cmp::Ordering { - let name_a = get_file_display_name(file_a).unwrap_or_default(); - let name_b = get_file_display_name(file_b).unwrap_or_default(); +pub fn compare_by_name(file_a: &Path, file_b: &Path) -> std::cmp::Ordering { + let name_a = file_a.display().to_string(); + let name_b = file_b.display().to_string(); let key_a = glib::FilenameCollationKey::from(name_a); let key_b = glib::FilenameCollationKey::from(name_b); diff --git a/src/widgets/image.rs b/src/widgets/image.rs index 17ee4ba..2fdae83 100644 --- a/src/widgets/image.rs +++ b/src/widgets/image.rs @@ -17,18 +17,19 @@ // SPDX-License-Identifier: GPL-3.0-or-later use crate::deps::*; - -use adw::{prelude::*, subclass::prelude::*}; - -use anyhow::Context; -use once_cell::sync::Lazy; -use once_cell::unsync::OnceCell; -use std::cell::{Cell, RefCell}; - use crate::i18n::i18n; use crate::image_metadata::{ImageMetadata, LpImageMetadata}; use crate::util; +use adw::{prelude::*, subclass::prelude::*}; +use anyhow::Context; +use gtk_macros::spawn; +use once_cell::sync::Lazy; +use once_cell::unsync::OnceCell; + +use std::cell::{Cell, RefCell}; +use std::path::{Path, PathBuf}; + const ZOOM_ANIMATION_DURATION: u32 = 200; const ROTATION_ANIMATION_DURATION: u32 = 200; @@ -44,6 +45,10 @@ mod imp { #[derive(Debug, Default)] pub struct LpImage { pub file: RefCell>, + pub path: RefCell>, + pub is_deleted: Cell, + /// Track changes to this image + pub file_monitor: RefCell>, pub texture: RefCell>, /// Set when image is completely loaded and ready for displaying pub is_loaded: Cell, @@ -112,6 +117,12 @@ mod imp { glib::ParamSpecObject::builder::("file") .read_only() .build(), + glib::ParamSpecVariant::builder("path", glib::VariantTy::BYTE_STRING) + .read_only() + .build(), + glib::ParamSpecBoolean::builder("is-deleted") + .read_only() + .build(), glib::ParamSpecBoolean::builder("is-loaded") .read_only() .build(), @@ -150,6 +161,8 @@ mod imp { let obj = self.instance(); match pspec.name() { "file" => obj.file().to_value(), + "path" => obj.path().to_variant().to_value(), + "is-deleted" => obj.is_deleted().to_value(), "is-loaded" => obj.is_loaded().to_value(), "metadata" => obj.metadata().to_value(), "rotation" => obj.rotation().to_value(), @@ -556,42 +569,42 @@ glib::wrapper! { } impl LpImage { - pub async fn load(&self, file: &gio::File) -> anyhow::Result<()> { + pub async fn load(&self, path: &Path) -> anyhow::Result<()> { let imp = self.imp(); + let path = path.to_path_buf(); + let file = gio::File::for_path(&path); + self.set_file(&file); - if let Some(path) = file.path() { - imp.file.replace(Some(file.clone())); + if !path.is_file() { + return Err(anyhow::Error::msg(i18n("File does not exist"))); + } - if !path.is_file() { - return Err(anyhow::Error::msg(i18n("File does not exist"))); - } + log::debug!("Loading file {path:?}"); - log::debug!("Loading file {}", path.display()); + log::debug!("Loading EXIF"); + let metadata = util::spawn( + "image-load-exif", + glib::clone!(@strong path => move || ImageMetadata::load(&path)), + ) + .await?; + let orientation = metadata.orientation(); - log::debug!("Loading EXIF"); - let metadata = util::spawn( - "image-load-exif", - glib::clone!(@strong file => move || ImageMetadata::load(&file)), - ) - .await?; - let orientation = metadata.orientation(); + imp.image_metadata.replace(LpImageMetadata::from(metadata)); + imp.rotation_target.set(-orientation.rotation); + imp.rotation.set(-orientation.rotation); + imp.mirrored.set(orientation.mirrored); - imp.image_metadata.replace(LpImageMetadata::from(metadata)); - imp.rotation_target.set(-orientation.rotation); - imp.rotation.set(-orientation.rotation); - imp.mirrored.set(orientation.mirrored); + self.notify("metadata"); - self.notify("metadata"); + log::debug!("Loading image size"); + let (_, x, y) = gdk_pixbuf::Pixbuf::file_info_future(path.clone()) + .await? + .context(i18n("Failed to load image size"))?; + imp.image_original_size.set((x, y)); + self.notify("image-size"); - log::debug!("Loading image size"); - let (_, x, y) = gdk_pixbuf::Pixbuf::file_info_future(path.clone()) - .await? - .context(i18n("Failed to load image size"))?; - imp.image_original_size.set((x, y)); - self.notify("image-size"); - - log::debug!("Loading complete image"); - let texture = util::spawn( + log::debug!("Loading complete image"); + let texture = util::spawn( "image-load-image", glib::clone!(@weak file => @default-return Err(anyhow::Error::msg(i18n("File does not exist"))), move || { log::debug!("Creating texture"); @@ -602,20 +615,17 @@ impl LpImage { .await? .context(i18n("Failed to load image"))?; - imp.texture.replace(Some(texture)); + imp.texture.replace(Some(texture)); - self.configure_adjustments(); + self.configure_adjustments(); - self.queue_draw(); - self.queue_allocate(); - self.queue_resize(); + self.queue_draw(); + self.queue_allocate(); + self.queue_resize(); - log::debug!("Image loaded and scheduled for drawing"); - imp.is_loaded.set(true); - self.notify("is-loaded"); - } else { - log::error!("File has not path"); - } + log::debug!("Image loaded and scheduled for drawing"); + imp.is_loaded.set(true); + self.notify("is-loaded"); Ok(()) } @@ -624,6 +634,10 @@ impl LpImage { self.imp().is_loaded.get() } + pub fn is_deleted(&self) -> bool { + self.imp().is_deleted.get() + } + /// Zoom level that makes the image fit in widget /// /// During image rotation the image does not actually fit into widget. @@ -660,9 +674,60 @@ impl LpImage { } pub fn file(&self) -> Option { + self.imp().file.borrow().clone() + } + + pub fn path(&self) -> Option { + self.imp().path.borrow().clone() + } + + pub(super) fn set_file(&self, file: &gio::File) { let imp = self.imp(); - imp.file.borrow().clone() + imp.file.replace(Some(file.clone())); + imp.path.replace(file.path()); + self.notify("path"); + + let monitor = file.monitor_file(gio::FileMonitorFlags::WATCH_MOVES, gio::Cancellable::NONE); + if let Ok(m) = &monitor { + m.connect_changed( + glib::clone!(@weak self as obj => move |_, file_a, file_b, event| { + obj.file_changed(event, file_a, file_b); + }), + ); + } + + imp.file_monitor.replace(monitor.ok()); + } + + /// File changed on drive + fn file_changed( + &self, + event: gio::FileMonitorEvent, + file_a: &gio::File, + file_b: Option<&gio::File>, + ) { + match event { + gio::FileMonitorEvent::Renamed => { + if let Some(file) = file_b { + log::debug!("Moved to {:?}", file.path()); + self.set_file(file); + } + } + gio::FileMonitorEvent::ChangesDoneHint => { + let obj = self.clone(); + let file = file_a.clone(); + // TODO: error handling is missing + spawn! {async move { let _ = obj.load(&file.path().unwrap()).await; }}; + } + gio::FileMonitorEvent::Deleted + | gio::FileMonitorEvent::MovedOut + | gio::FileMonitorEvent::Unmounted => { + self.imp().is_deleted.set(true); + self.notify("is-deleted"); + } + _ => {} + } } /// Texture that contains the original image diff --git a/src/widgets/image_page.rs b/src/widgets/image_page.rs index 458080c..724583e 100644 --- a/src/widgets/image_page.rs +++ b/src/widgets/image_page.rs @@ -18,24 +18,23 @@ // SPDX-License-Identifier: GPL-3.0-or-later use crate::deps::*; +use crate::widgets::LpImage; use adw::subclass::prelude::*; use glib::clone; use gtk::prelude::*; use gtk::CompositeTemplate; - use gtk_macros::spawn; +use once_cell::sync::Lazy; -use once_cell::sync::{Lazy, OnceCell}; use std::cell::RefCell; - -use crate::widgets::LpImage; +use std::path::{Path, PathBuf}; mod imp { use super::*; #[derive(Debug, Default, CompositeTemplate)] - #[template(resource = "/org/gnome/Loupe/gtk/image_page.ui")] + #[template(file = "../../data/gtk/image_page.ui")] pub struct LpImagePage { #[template_child] pub(super) stack: TemplateChild, @@ -54,8 +53,6 @@ mod imp { #[template_child] pub(super) press_gesture: TemplateChild, - pub(super) file: OnceCell, - /// set if an error has occured and is shown on error_page pub(super) error: RefCell>, } @@ -97,6 +94,7 @@ mod imp { name => unimplemented!("property {name}"), } } + fn constructed(&self) { let obj = self.instance(); @@ -128,17 +126,20 @@ glib::wrapper! { } impl LpImagePage { - pub fn from_file(file: &gio::File) -> Self { + pub fn from_path(path: &Path) -> Self { let obj = glib::Object::new::(&[]); - obj.imp().file.set(file.clone()).unwrap(); + let path = path.to_path_buf(); + let file = gio::File::for_path(&path); + + obj.imp().image.set_file(&file); // This doesn't work properly for items not explicitly selected // via the file chooser portal. I'm not sure how to make this work. gtk::RecentManager::default().add_item(&file.uri()); - spawn!(clone!(@weak obj, @weak file => async move { + spawn!(clone!(@weak obj, @strong path => async move { let imp = obj.imp(); - match imp.image.load(&file).await { + match imp.image.load(&path).await { Ok(()) => { imp.stack.set_visible_child(&*imp.scrolled_window); imp.spinner.set_spinning(false); @@ -156,8 +157,8 @@ impl LpImagePage { obj } - pub fn file(&self) -> Option { - self.imp().file.get().cloned() + pub fn path(&self) -> PathBuf { + self.image().path().unwrap() } pub fn image(&self) -> LpImage { diff --git a/src/widgets/image_view.rs b/src/widgets/image_view.rs index de03154..d805459 100644 --- a/src/widgets/image_view.rs +++ b/src/widgets/image_view.rs @@ -18,36 +18,35 @@ // SPDX-License-Identifier: GPL-3.0-or-later use crate::deps::*; +use crate::file_model::LpFileModel; use crate::i18n::*; +use crate::thumbnail::Thumbnail; +use crate::widgets::{LpImage, LpImagePage, LpSlidingView}; use adw::prelude::*; use adw::subclass::prelude::*; -use glib::clone; -use gtk::CompositeTemplate; - -use anyhow::{bail, Context}; +use anyhow::Context; use ashpd::desktop::wallpaper; use ashpd::desktop::ResponseError; use ashpd::Error; use ashpd::WindowIdentifier; -use once_cell::sync::Lazy; -use std::cell::{Cell, RefCell}; - +use glib::clone; +use gtk::CompositeTemplate; use gtk_macros::spawn; +use once_cell::sync::Lazy; -use crate::file_model::LpFileModel; -use crate::thumbnail::Thumbnail; -use crate::widgets::LpImagePage; +use std::cell::{Cell, RefCell}; +use std::path::{Path, PathBuf}; // The number of pages we want to buffer // on either side of the current page. -const BUFFER: u32 = 2; +const BUFFER: usize = 2; mod imp { use super::*; #[derive(Debug, Default, CompositeTemplate)] - #[template(resource = "/org/gnome/Loupe/gtk/image_view.ui")] + #[template(file = "../../data/gtk/image_view.ui")] pub struct LpImageView { /// Direct child of this Adw::Bin #[template_child] @@ -59,14 +58,10 @@ mod imp { #[template_child] pub controls_box_end: TemplateChild, #[template_child] - pub carousel: TemplateChild, + pub sliding_view: TemplateChild, - pub model: RefCell>, - pub current_model_index: Cell, - - pub scrolling: Cell, - - pub current_page_strict: RefCell>, + pub model: RefCell, + pub preserve_content: Cell, } #[glib::object_subclass] @@ -89,10 +84,7 @@ mod imp { fn properties() -> &'static [glib::ParamSpec] { static PROPERTIES: Lazy> = Lazy::new(|| { vec![ - glib::ParamSpecObject::builder::("active-file") - .read_only() - .build(), - glib::ParamSpecObject::builder::("current-page-strict") + glib::ParamSpecObject::builder::("current-page") .read_only() .build(), glib::ParamSpecBoolean::builder("is-previous-available") @@ -110,8 +102,7 @@ mod imp { fn property(&self, _id: usize, psec: &glib::ParamSpec) -> glib::Value { let obj = self.instance(); match psec.name() { - "active-file" => obj.active_file().to_value(), - "current-page-strict" => obj.current_page_strict().to_value(), + "current-page" => obj.current_page().to_value(), "is-previous-available" => obj.is_previous_available().to_value(), "is-next-available" => obj.is_next_available().to_value(), _ => unimplemented!(), @@ -126,6 +117,26 @@ mod imp { // Manually mange widget layout, see `WidgetImpl` for details obj.set_layout_manager(gtk::LayoutManager::NONE); + obj.property_expression("current-page") + .chain_property::("image") + .chain_property::("path") + .watch( + glib::Object::NONE, + glib::clone!(@weak obj => move || { + obj.current_image_path_changed(); + }), + ); + + obj.property_expression("current-page") + .chain_property::("image") + .chain_property::("is-deleted") + .watch( + glib::Object::NONE, + glib::clone!(@weak obj => move || { + obj.current_image_path_changed(); + }), + ); + let source = gtk::DragSource::new(); source.set_exclusive(true); @@ -153,12 +164,6 @@ mod imp { })); obj.add_controller(&source); - - self.carousel - .connect_position_notify(clone!(@weak obj => move |_| { - obj.notify("active-file"); - obj.notify("current-page-strict"); - })); } } @@ -182,7 +187,7 @@ mod imp { // take as minimum whatever is larger let min = i32::max(child_min, overlay1_min + overlay2_min); - if let Some(page) = self.instance().current_page_strict() { + if let Some(page) = self.instance().current_page() { // measure `LpImage` let (_, image_natural, _, _) = page.image().measure(orientation, for_size); @@ -213,198 +218,215 @@ glib::wrapper! { #[gtk::template_callbacks] impl LpImageView { - pub fn set_image_from_file(&self, file: &gio::File) -> anyhow::Result<()> { - if let Some(current_file) = self.active_file() { - if current_file.equal(file) { - bail!("Image is the same as the previous image; Doing nothing."); - } + pub fn set_image_from_path(&self, path: &Path) { + if let Err(err) = self.load_path(path) { + log::error!("Failed to load path: {err}"); + self.activate_action("win.show-toast", Some(&(err.to_string(), 1).to_variant())) + .unwrap(); } - - self.build_model_from_file(file)?; - self.notify("active-file"); - - Ok(()) } // Builds an `LpFileModel`, which is an implementation of `gio::ListModel` // that holds a `gio::File` for each child within the same directory as the // file we pass. This model will update with changes to the directory, // and in turn we'll update our `adw::Carousel`. - fn build_model_from_file(&self, file: &gio::File) -> anyhow::Result<()> { - let imp = self.imp(); - let carousel = &imp.carousel; + fn load_path(&self, path: &Path) -> anyhow::Result<()> { + let sliding_view = self.sliding_view(); + let directory = path.parent().map(|x| x.to_path_buf()); - let model = { - // Here we use a nested scope so that the mutable borrow only lasts as long as we need it + let is_same_directory = directory == self.model().directory(); - if imp.model.borrow().is_some() { - let is_same_directory = !file.parent().map_or(false, |p| { - imp.model - .borrow() - .as_ref() - .and_then(|x| x.directory()) - .map_or(false, |f| !f.equal(&p)) - }); + if is_same_directory { + log::debug!("Re-using old model and navigating to the current file"); + self.navigate_to_path(path); + return Ok(()); + } - if is_same_directory { - log::debug!("Re-using old model and navigating to the current file"); - self.navigate_to_file(file); - return Ok(()); - } else { - // Clear the carousel before creating the new model - self.clear_carousel(false); - let model = LpFileModel::from_file(file)?; - imp.model.replace(Some(model.clone())); - log::debug!("new model created"); - model + let model = LpFileModel::from_path(path); + self.set_model(model); + log::debug!("New model created"); + + let page = LpImagePage::from_path(path); + + sliding_view.clear(); + sliding_view.append(&page); + + // List other files in directory + if let Some(directory) = directory { + let path = path.to_path_buf(); + spawn!(glib::clone!(@weak self as obj, @strong path => async move { + if let Err(err) = obj.model().load_directory(directory).await { + log::warn!("Failed to load directory: {err}"); + obj.activate_action("win.show-toast", Some(&(err.to_string(), 1).to_variant())) + .unwrap(); + return; } - } else { - let model = LpFileModel::from_file(file)?; - imp.model.replace(Some(model.clone())); - log::debug!("first model created"); - model - } - }; - let page = LpImagePage::from_file(file); - imp.current_page_strict.replace(Some(page.clone())); - self.notify("current-page-strict"); - self.notify("is-previous-available"); - self.notify("is-next-available"); - - carousel.append(&page); - - spawn!(glib::clone!(@weak self as obj, @strong file => async move { - if let Err(err) = model.load_directory().await { - log::warn!("Failed to load directory: {:?}", err); - obj.activate_action("win.show-toast", Some(&(err.to_string(), 1).to_variant())) - .unwrap(); - return; - } - - let index = model - .index_of(&file) - .context("File not found in model.") - .unwrap(); - log::debug!("Currently at file {index} in the directory"); - - obj.fill_carousel(&model, index); - })); + obj.update_sliding_view(&path); + })); + } Ok(()) } + /// Move forward or backwards pub fn navigate(&self, direction: adw::NavigationDirection) { - let carousel = &self.imp().carousel; - let pos = carousel.position().round() as u32; - match direction { - adw::NavigationDirection::Forward => { - if pos < carousel.n_pages() - 1 { - carousel.scroll_to(&carousel.nth_page(pos + 1), true); - } + if let Some(current_path) = self.current_path() { + let new_path = match direction { + adw::NavigationDirection::Forward => self.model().after(¤t_path), + adw::NavigationDirection::Back => self.model().before(¤t_path), + _ => unimplemented!("Navigation direction should only be back or forward."), + }; + + if let Some(new_path) = new_path { + self.scroll_sliding_view(&new_path); } - adw::NavigationDirection::Back => { - if pos > 0 { - carousel.scroll_to(&carousel.nth_page(pos - 1), true) - } - } - _ => unimplemented!("Navigation direction should only be back or forward."), - }; + } } - fn navigate_to_file(&self, file: &gio::File) { - let imp = self.imp(); + /// Used for drag and drop + fn navigate_to_path(&self, new_path: &Path) { + let sliding_view = self.sliding_view(); - let Some(new_index) = imp.model.borrow().as_ref().and_then(|model| model.index_of(file)) else { - log::warn!("Could not navigate to file {:?}", file.path()); - return; - }; - - let carousel = imp.carousel.get(); - let current_index = imp.current_model_index.get(); - - if new_index == current_index { + if Some(new_path.to_path_buf()) == self.current_path() { return; } - let page = LpImagePage::from_file(file); + let Some(new_index) = self.model().index_of(new_path) else { + log::warn!("Could not navigate to file {new_path:?}"); + return; + }; - if new_index > current_index { - carousel.append(&page); + let current_index = self + .current_path() + .and_then(|x| self.model().index_of(&x)) + .unwrap_or_default(); + + let page = if let Some(page) = sliding_view.get(new_path) { + page } else { - carousel.prepend(&page); + let page = LpImagePage::from_path(new_path); + + if new_index > current_index { + sliding_view.append(&page); + } else { + sliding_view.prepend(&page); + } + + page + }; + + log::debug!("Scrolling to page for {new_path:?}"); + self.imp().preserve_content.set(true); + sliding_view.scroll_to(&page); + } + + /// Ensures the sliding view contains the correct images + fn update_sliding_view(&self, current_path: &Path) { + log::debug!("Updating sliding_view neighbors for current path {current_path:?}"); + let sliding_view = self.sliding_view(); + + self.imp().preserve_content.set(false); + + let existing = sliding_view.pages(); + let target = self.model().files_around(current_path, BUFFER); + + // remove old pages + for (path, page) in &existing { + if !target.contains(path) { + sliding_view.remove(page); + } } - // Set a flag and scroll to the page. Our `page-changed` signal handler - // will handle clearing and refilling the carousel. This is so we're - // not changing the state of the carousel while it's scrolling to the - // new page. - log::debug!("Scrolling to page for {new_index}"); - imp.scrolling.set(true); - carousel.scroll_to(&page, true); + // add missing pages or put in correct position + for (position, path) in target.iter().enumerate() { + if let Some(page) = existing.get(path) { + sliding_view.move_to(page, position); + } else { + sliding_view.insert(&LpImagePage::from_path(path), position); + } + } self.notify("is-previous-available"); self.notify("is-next-available"); } - // Fills the carousel with items on either side of the given `index` of `model` - fn fill_carousel(&self, model: &LpFileModel, index: u32) { - let imp = self.imp(); - let carousel = imp.carousel.get(); + fn scroll_sliding_view(&self, path: &Path) { + let Some(current_page) = self.sliding_view().pages().remove(path) else { + log::error!("Current path not availabel in sliding_view for scrolling: {path:?}"); + return; + }; - for i in 1..=BUFFER { - if let Some(ref file) = model.file(index + i) { - carousel.append(&LpImagePage::from_file(file)) - } - } - - for i in 1..=BUFFER { - if let Some(ref file) = index.checked_sub(i).and_then(|i| model.file(i)) { - carousel.prepend(&LpImagePage::from_file(file)) - } - } - - log::debug!("Carousel filled, current file at index {index}"); - imp.current_model_index.set(index); - - self.notify("is-previous-available"); - self.notify("is-next-available"); + self.sliding_view().scroll_to(¤t_page); } - // Clear the carousel, optionally preserving the current position - // as a point to refill from - fn clear_carousel(&self, preserve_current_page: bool) { - let carousel = self.imp().carousel.get(); + fn sliding_view(&self) -> LpSlidingView { + self.imp().sliding_view.clone() + } - if preserve_current_page { - // Remove everything before the current page - for _ in 0..(carousel.position() as u32) { - carousel.remove(&carousel.nth_page(0)); - } + fn model(&self) -> LpFileModel { + self.imp().model.borrow().clone() + } - // Then everything after - while carousel.n_pages() > 1 { - carousel.remove(&carousel.nth_page(carousel.n_pages() - 1)); - } - } else { - while carousel.n_pages() > 0 { - carousel.remove(&carousel.nth_page(0)); + fn set_model(&self, model: LpFileModel) { + model.connect_changed( + glib::clone!(@weak self as obj => move || obj.model_content_changed_cb()), + ); + self.imp().model.replace(model); + } + + /// Handle files are added or removed from directory + fn model_content_changed_cb(&self) { + let Some(current_path) = self.current_path() else { return; }; + + // LpImage did not get the update yet + // Update will be handled by current_image_path_changed + if !self.model().contains(¤t_path) { + return; + } + + self.update_sliding_view(¤t_path); + } + + /// Handle current image being moved or deleted + /// + /// This is handled separately since we want to delete animations and + /// want to still show the same image if renamed. + fn current_image_path_changed(&self) { + if let Some(image) = self.current_page().map(|x| x.image()) { + if image.is_deleted() { + self.sliding_view().scroll_to_neighbor(); } } - self.notify("is-previous-available"); - self.notify("is-next-available"); + if let Some(current_path) = self.current_path() { + if self.model().contains(¤t_path) { + if !self.imp().preserve_content.get() { + self.update_sliding_view(¤t_path); + } + } + } + } + + pub fn current_page(&self) -> Option { + self.imp().sliding_view.current_page() + } + + pub fn current_path(&self) -> Option { + self.imp().sliding_view.current_page().map(|x| x.path()) + } + + pub fn current_file(&self) -> Option { + self.imp() + .sliding_view + .current_page() + .and_then(|x| x.image().file()) } /// Returns `true` if there is an image before the current one pub fn is_previous_available(&self) -> bool { - let imp = self.imp(); - if let Some(model) = imp.model.borrow().as_ref() { - imp.current_model_index - .get() - .checked_sub(1) - .and_then(|i| model.item(i)) - .is_some() + if let Some(path) = self.current_path() { + self.model().index_of(&path) != Some(0) } else { false } @@ -412,93 +434,39 @@ impl LpImageView { /// Returns `true` if there is an image after the current one pub fn is_next_available(&self) -> bool { - let imp = self.imp(); - if let Some(model) = imp.model.borrow().as_ref() { - model.item(imp.current_model_index.get() + 1).is_some() + if let Some(path) = self.current_path() { + let model = self.model(); + model.index_of(&path) != Some(model.n_files().saturating_sub(1)) } else { false } } #[template_callback] - fn page_changed_cb(&self, index: u32, carousel: &adw::Carousel) { - let imp = self.imp(); + fn page_changed_cb(&self) { + self.notify("current-page"); + self.notify("is-previous-available"); + self.notify("is-next-available"); - imp.current_page_strict - .replace(carousel.nth_page(index).downcast().ok()); - self.notify("current-page-strict"); + let Some(new_page) = self.current_page() else { + log::debug!("Page changed but no current page"); + return; + }; - let b = imp.model.borrow(); - let model = b.as_ref().unwrap(); - let current = self.active_file().unwrap(); - - let model_index = model.index_of(¤t).unwrap(); - let prev_index = imp.current_model_index.get(); - - if model_index != prev_index { - if imp.scrolling.get() { - log::debug!("Scrolling finished, refilling carousel"); - imp.scrolling.set(false); - // We need to clear the carousel (excluding the current page) - // and refill the page buffer. - self.clear_carousel(true); - self.fill_carousel(model, model_index); - } else { - self.update_page_buffer(model, carousel, model_index, prev_index); - } + if !self.imp().preserve_content.get() { + self.update_sliding_view(&new_page.path()); } } - fn update_page_buffer( - &self, - model: &LpFileModel, - carousel: &adw::Carousel, - model_index: u32, - prev_index: u32, - ) { - let imp = self.imp(); - - // We've moved forward - if let Some(diff) = model_index.checked_sub(prev_index) { - for i in 0..diff { - if prev_index - .checked_sub(BUFFER) - .and_then(|r| r.checked_sub(i)) - .is_some() - { - carousel.remove(&carousel.nth_page(0)); - } - - let s = prev_index + BUFFER + i + 1; - if s <= model.n_items() { - if let Some(ref file) = model.file(s) { - carousel.append(&LpImagePage::from_file(file)); - } - } + #[template_callback] + fn target_page_reached_cb(&self) { + if self.imp().preserve_content.get() { + if let Some(new_page) = self.current_page() { + self.update_sliding_view(&new_page.path()); + } else { + log::error!("No LpImagePage"); } } - - // We've moved backward - if let Some(diff) = prev_index.checked_sub(model_index) { - for i in 0..diff { - let s = prev_index + BUFFER + i + 1; - if s <= model.n_items() { - carousel.remove(&carousel.nth_page(carousel.n_pages() - 1)); - } - - if let Some(ref file) = prev_index - .checked_sub(BUFFER) - .and_then(|d| d.checked_sub(i + 1)) - .and_then(|d| model.file(d)) - { - carousel.prepend(&LpImagePage::from_file(file)); - } - } - } - - imp.current_model_index.set(model_index); - self.notify("is-previous-available"); - self.notify("is-next-available"); } #[template_callback] @@ -535,7 +503,10 @@ impl LpImageView { } pub async fn set_background(&self) -> anyhow::Result<()> { - let background = self.uri().context("No URI for current file")?; + let background = self + .current_path() + .and_then(|p| std::fs::File::open(p).ok()) + .context(i18n("Failed to open new background image"))?; let native = self.native().expect("View should have a GtkNative"); let id = WindowIdentifier::from_native(&native).await; @@ -543,7 +514,7 @@ impl LpImageView { .set_on(wallpaper::SetOn::Background) .show_preview(true) .identifier(id) - .build_uri(&background) + .build_file(&background) .await { // We use `1` here because we can't pass enums directly as GVariants, @@ -571,11 +542,7 @@ impl LpImageView { pub fn print(&self) -> anyhow::Result<()> { let operation = gtk::PrintOperation::new(); - let path = self - .active_file() - .context("No file")? - .peek_path() - .context("No path")?; + let path = self.current_path().context("No file")?; let pb = gdk_pixbuf::Pixbuf::from_file(path)?; let setup = gtk::PageSetup::default(); @@ -619,30 +586,4 @@ impl LpImageView { Ok(()) } - - // TODO: check how often we actually want to use this - pub fn current_page(&self) -> Option { - let carousel = &self.imp().carousel; - let pos = carousel.position().round() as u32; - if carousel.n_pages() > 0 && pos < carousel.n_pages() { - carousel.nth_page(pos).downcast().ok() - } else { - None - } - } - - /// Returns `None` during animations instead of returning the closest image - pub fn current_page_strict(&self) -> Option { - self.imp().current_page_strict.borrow().clone() - } - - pub fn uri(&self) -> Option { - self.active_file() - .and_then(|f| url::Url::parse(&f.uri()).ok()) - } - - pub fn active_file(&self) -> Option { - let page = self.current_page()?; - page.file() - } } diff --git a/src/widgets/mod.rs b/src/widgets/mod.rs index c491c20..3627dc2 100644 --- a/src/widgets/mod.rs +++ b/src/widgets/mod.rs @@ -21,8 +21,10 @@ mod image; mod image_page; mod image_view; mod properties_view; +mod sliding_view; pub use image::LpImage; pub use image_page::LpImagePage; pub use image_view::LpImageView; pub use properties_view::LpPropertiesView; +pub use sliding_view::LpSlidingView; diff --git a/src/widgets/properties_view.rs b/src/widgets/properties_view.rs index ec5a2a8..f8a9401 100644 --- a/src/widgets/properties_view.rs +++ b/src/widgets/properties_view.rs @@ -41,7 +41,7 @@ mod imp { use super::*; #[derive(Debug, Default, CompositeTemplate)] - #[template(resource = "/org/gnome/Loupe/gtk/properties_view.ui")] + #[template(file = "../../data/gtk/properties_view.ui")] pub struct LpPropertiesView { pub file: RefCell>>, pub image: RefCell>>, @@ -130,8 +130,7 @@ mod imp { let prop_name = pspec.name(); match prop_name { - "file" => obj.set_file(&value.get().ok()), - "image" => obj.set_image(&value.get().ok()), + "image" => obj.set_image(value.get().ok().as_ref()), _ => unimplemented!(), } } @@ -159,6 +158,16 @@ mod imp { obj.notify("metadata"); }), ); + + // watch changes to image metadata + obj.property_expression("image") + .chain_property::("path") + .watch( + glib::Object::NONE, + glib::clone!(@weak obj => move || { + obj.set_file(obj.image().and_then(|x| x.file()).as_ref()); + }), + ); } } @@ -174,7 +183,7 @@ glib::wrapper! { #[gtk::template_callbacks] impl LpPropertiesView { - fn set_file(&self, file: &Option) { + fn set_file(&self, file: Option<&gio::File>) { let imp = self.imp(); self.action_set_enabled("properties.open-folder", file.is_some()); @@ -196,10 +205,11 @@ impl LpPropertiesView { } self.build_file_info(file); - let weak = file.downgrade(); - imp.file.replace(Some(weak)); - self.notify("file"); } + + let weak = file.map(|x| x.downgrade()); + imp.file.replace(weak); + self.notify("file"); } fn file(&self) -> Option { @@ -207,7 +217,7 @@ impl LpPropertiesView { imp.file.borrow().as_ref().and_then(|w| w.upgrade()) } - fn set_image(&self, image: &Option) { + fn set_image(&self, image: Option<&LpImage>) { let imp = self.imp(); imp.image.replace(image.as_ref().map(|x| x.downgrade())); diff --git a/src/widgets/sliding_view.rs b/src/widgets/sliding_view.rs new file mode 100644 index 0000000..1f80f94 --- /dev/null +++ b/src/widgets/sliding_view.rs @@ -0,0 +1,332 @@ +//! Sliding view for images +//! +//! This widget it similar to `AdwCarousel`. +//! Currently, there is no swipe-gesture support since it's not working with +//! `GtkScrolledWindow` anyways. + +use crate::deps::*; +use crate::widgets::LpImagePage; + +use adw::prelude::*; +use adw::subclass::prelude::*; +use indexmap::IndexMap; +use once_cell::sync::{Lazy, OnceCell}; + +use std::cell::{Cell, RefCell}; +use std::path::{Path, PathBuf}; + +static SCROLL_DURATION: u32 = 200; + +mod imp { + use super::*; + use glib::subclass::Signal; + + #[derive(Default)] + pub struct LpSlidingView { + /// Pages that can be slided through + pub(super) pages: RefCell>, + /// Page that is currently shown or the animation is animating towards + pub(super) current_page: RefCell>, + /// Animatable position, 0.0 first image, 1.0 second image etc + pub(super) position: Cell, + /// Move position to not break animations when pages are removed/added + pub(super) position_shift: Cell, + /// The animation used to animate image changes + pub(super) scroll_animation: OnceCell, + } + + #[glib::object_subclass] + impl ObjectSubclass for LpSlidingView { + const NAME: &'static str = "LpSlidingView"; + type Type = super::LpSlidingView; + type ParentType = gtk::Widget; + } + + impl ObjectImpl for LpSlidingView { + fn properties() -> &'static [glib::ParamSpec] { + static PROPERTIES: Lazy> = Lazy::new(|| { + vec![ + glib::ParamSpecObject::builder::("current-page") + .read_only() + .build(), + ] + }); + + PROPERTIES.as_ref() + } + + fn property(&self, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { + let obj = self.instance(); + match pspec.name() { + "current-page" => obj.current_page().to_value(), + name => unimplemented!("property {name}"), + } + } + + fn signals() -> &'static [Signal] { + static SIGNALS: Lazy> = + Lazy::new(|| vec![Signal::builder("target-page-reached").build()]); + SIGNALS.as_ref() + } + + fn constructed(&self) { + self.instance().set_overflow(gtk::Overflow::Hidden); + } + } + + impl WidgetImpl for LpSlidingView { + fn size_allocate(&self, width: i32, height: i32, _baseline: i32) { + let scroll_position = self.position.get() as f32; + let position_shift = self.position_shift.get() as f32; + + for (page_index, page) in self.pages.borrow().iter().enumerate() { + // This positions the pages within the carousel and shifts them + // according to the position that should currently be shown. + let x = (page_index as f32 - scroll_position - position_shift) * width as f32; + + let transform = gsk::Transform::new().translate(&graphene::Point::new(x, 0.)); + page.allocate(width, height, 0, Some(&transform)); + } + } + + fn measure(&self, orientation: gtk::Orientation, for_size: i32) -> (i32, i32, i32, i32) { + let obj = self.instance(); + + for page in self.pages.borrow().iter() { + if Some(page) != obj.current_page().as_ref() { + page.measure(orientation, for_size); + } + } + + if let Some(current_page) = obj.current_page() { + current_page.measure(orientation, for_size) + } else { + (0, 0, -1, -1) + } + } + } +} + +glib::wrapper! { + pub struct LpSlidingView(ObjectSubclass) + @extends gtk::Widget, + @implements gtk::Buildable; +} + +impl LpSlidingView { + /// Add page to the end + pub fn append(&self, page: &LpImagePage) { + self.insert(page, self.n_pages()); + } + + /// Add page to the front + pub fn prepend(&self, page: &LpImagePage) { + self.insert(page, 0); + } + + /// Add page to the specified position + pub fn insert(&self, page: &LpImagePage, pos: usize) { + let current_index = self.current_index(); + + let previous_sibling = self.imp().pages.borrow().get(pos).cloned(); + self.imp().pages.borrow_mut().insert(pos, page.clone()); + page.insert_after(self, previous_sibling.as_ref()); + + self.shift_position(current_index); + + if self.n_pages() == 1 { + self.set_current_page(Some(page)); + } + } + + /// Reorders the pages by moving the specified page to the new index + pub fn move_to(&self, page: &LpImagePage, new_index: usize) { + if let Some(index) = self.index_of(page) { + let current_index = self.current_index(); + { + let mut vec = self.imp().pages.borrow_mut(); + let page = vec.remove(index); + vec.insert(new_index, page); + } + self.shift_position(current_index); + } else { + log::error!("Page to move not found"); + } + } + + /// Removes the page + pub fn remove(&self, page: &LpImagePage) { + let current_index = self.current_index(); + + if let Some(index) = self.index_of(page) { + self.imp().pages.borrow_mut().remove(index); + page.unparent(); + + self.queue_allocate(); + + self.shift_position(current_index); + + if self.is_empty() { + self.clear(); + } + } else { + log::error!("Trying to remove non-existent page."); + } + } + + /// Signal that fires when the animation for scrolling to new page ends + pub fn connect_target_page_reached(&self, f: impl Fn() + 'static) { + self.connect_local("target-page-reached", false, move |_| { + f(); + None + }); + } + + /// Removes all pages + pub fn clear(&self) { + self.scroll_animation().pause(); + + for page in self.imp().pages.borrow().iter() { + page.unparent(); + } + + self.imp().pages.borrow_mut().clear(); + self.set_position(0.); + self.imp().position_shift.set(0.); + + self.set_current_page(None); + } + + /// Gives all pages with hash access via their path + pub fn pages(&self) -> IndexMap { + self.imp() + .pages + .borrow() + .iter() + .cloned() + .map(|x| (x.path(), x)) + .collect() + } + + pub fn get(&self, path: &Path) -> Option { + self.imp() + .pages + .borrow() + .iter() + .find(|x| x.path() == path) + .cloned() + } + + /// Move to specified page with animation + pub fn scroll_to(&self, page: &LpImagePage) { + if self.current_page().as_ref() != Some(page) { + let index = self.index_of(page).unwrap(); + + let animation = self.scroll_animation(); + + animation.set_value_from(self.position()); + animation.set_value_to(index as f64 - self.position_shift()); + animation.play(); + self.set_current_page(Some(page)); + } + } + + /// Animates removal of current page + /// + /// Moves to next page if possible, or to previous, or emmediately removes + /// the current page if last remaining. + pub fn scroll_to_neighbor(&self) { + if let Some(next_page) = self.next_page() { + self.scroll_to(&next_page); + } else if let Some(prev_page) = self.prev_page() { + self.scroll_to(&prev_page); + } else if let Some(current_page) = self.current_page() { + self.remove(¤t_page); + } + } + + fn next_page(&self) -> Option { + let current_index = self.current_index()?; + let next_index = current_index.checked_add(1)?; + self.imp().pages.borrow().get(next_index).cloned() + } + + fn prev_page(&self) -> Option { + let current_index = self.current_index()?; + let prev_index = current_index.checked_sub(1)?; + self.imp().pages.borrow().get(prev_index).cloned() + } + + fn n_pages(&self) -> usize { + self.imp().pages.borrow().len() + } + + fn is_empty(&self) -> bool { + self.imp().pages.borrow().is_empty() + } + + fn index_of(&self, page: &LpImagePage) -> Option { + self.imp().pages.borrow().iter().position(|x| x == page) + } + + fn position(&self) -> f64 { + self.imp().position.get() + } + + fn position_shift(&self) -> f64 { + self.imp().position_shift.get() + } + + /// Sets position shift to correct page insert or removal + /// + /// Takes the old position of the current page and calculates and sets the shift + /// to keep the current page optically at the same position. + fn shift_position(&self, old_index: Option) { + if let (Some(old_index), Some(new_index)) = (old_index, self.current_index()) { + let shift = new_index as f64 - old_index as f64; + self.imp().position_shift.set(self.position_shift() + shift); + } + } + + fn set_position(&self, position: f64) { + self.imp().position.set(position); + self.queue_allocate(); + } + + pub fn current_page(&self) -> Option { + self.imp().current_page.borrow().clone() + } + + fn set_current_page(&self, page: Option<&LpImagePage>) { + self.imp().current_page.replace(page.cloned()); + self.notify("current-page"); + } + + fn current_index(&self) -> Option { + self.imp() + .current_page + .borrow() + .as_ref() + .and_then(|x| self.index_of(x)) + } + + fn scroll_animation(&self) -> &adw::TimedAnimation { + self.imp().scroll_animation.get_or_init(|| { + let target = adw::CallbackAnimationTarget::new( + glib::clone!(@weak self as obj => move |position| obj.set_position(position)), + ); + + let animation = adw::TimedAnimation::builder() + .duration(SCROLL_DURATION) + .widget(self) + .target(&target) + .build(); + + animation.connect_done(glib::clone!(@weak self as obj => move |_| { + obj.emit_by_name("target-page-reached", &[]) + })); + + animation + }) + } +} diff --git a/src/window.rs b/src/window.rs index 356d0e7..7d66afe 100644 --- a/src/window.rs +++ b/src/window.rs @@ -26,6 +26,7 @@ use gtk::prelude::*; use gtk::CompositeTemplate; use std::cell::RefCell; +use std::path::Path; use crate::config; use crate::util; @@ -49,7 +50,7 @@ mod imp { // we'd need to have a `new()` function in the // `impl ObjectSubclass for $TYPE` section. #[derive(Default, Debug, CompositeTemplate)] - #[template(resource = "/org/gnome/Loupe/gtk/window.ui")] + #[template(file = "../data/gtk/window.ui")] pub struct LpWindow { // Template children are used with the // TemplateChild wrapper, where T is the @@ -182,7 +183,7 @@ mod imp { obj.set_actions_enabled(false); self.image_view - .property_expression("current-page-strict") + .property_expression("current-page") .chain_property::("image") .chain_property::("best-fit") .watch( @@ -209,7 +210,7 @@ mod imp { // disable zoom-in if at maximum zoom level self.image_view - .property_expression("current-page-strict") + .property_expression("current-page") .chain_property::("image") .chain_property::("is-max-zoom") .watch( @@ -226,6 +227,13 @@ mod imp { }), ); + self.image_view.connect_notify_local( + Some("current-page"), + glib::clone!(@weak obj => move |_, _| { + obj.images_available(); + }), + ); + // action win.previous status self.image_view.connect_notify_local( Some("is-previous-available"), @@ -295,7 +303,7 @@ mod imp { .map(|t| t.to_string()) .filter(|t| t.starts_with("image/")) .is_some() { - obj.set_image_from_file(&file, false); + obj.set_image_from_path(&file.path().unwrap()); } else { obj.show_toast( i18n_f("“{}” is not a valid image.", &[&info.display_name()]), @@ -374,14 +382,17 @@ impl LpWindow { .build(); if let Ok(file) = chooser.open_future(Some(self)).await { - self.set_image_from_file(&file, true); + self.set_image_from_path(&file.path().unwrap()); + } else { + // TODO: ui error + log::error!("Failed to choose file"); } } async fn open_with(&self) { let imp = self.imp(); - if let Some(ref file) = imp.image_view.active_file() { + if let Some(ref file) = imp.image_view.current_path().map(gio::File::for_path) { let launcher = gtk::FileLauncher::new(Some(file)); if let Err(e) = launcher.launch_future(Some(self)).await { if !e.matches(gtk::DialogError::Dismissed) { @@ -432,22 +443,12 @@ impl LpWindow { imp.toast_overlay.add_toast(&toast); } - pub fn set_image_from_file(&self, file: &gio::File, _resize: bool) { + pub fn set_image_from_path(&self, path: &Path) { let imp = self.imp(); - log::debug!("Loading file: {}", file.uri().to_string()); - match imp.image_view.set_image_from_file(file) { - Ok((/*width, height*/)) => { - // if resize { - // self.resize_from_dimensions(width, height); - // } - - imp.stack.set_visible_child(&*imp.image_view); - imp.image_view.grab_focus(); - self.set_actions_enabled(true) - } - Err(e) => log::error!("Could not load file: {}", e.to_string()), - } + log::debug!("Loading file: {path:?}"); + imp.image_view.set_image_from_path(path); + self.set_actions_enabled(true); } pub fn set_actions_enabled(&self, enabled: bool) { @@ -462,6 +463,18 @@ impl LpWindow { self.action_set_enabled("win.toggle-properties", enabled); } + pub fn images_available(&self) { + let imp = self.imp(); + + if imp.image_view.current_page().is_some() { + imp.stack.set_visible_child(&*imp.image_view); + imp.image_view.grab_focus(); + } else { + imp.stack.set_visible_child(&*imp.status_page); + imp.status_page.grab_focus(); + } + } + pub fn image_size_ready(&self) { // if visible for whatever reason, don't do any resize if self.is_visible() { @@ -472,7 +485,7 @@ impl LpWindow { let image = self .imp() .image_view - .current_page_strict() + .current_page() .map(|page| page.image()); if let Some(image) = image { @@ -492,7 +505,7 @@ impl LpWindow { return; } - let current_page = self.imp().image_view.current_page_strict(); + let current_page = self.imp().image_view.current_page(); if let Some(page) = current_page { if page.error().is_some() { @@ -555,9 +568,17 @@ impl LpWindow { // have multiple `match` or `if let` branches, and without needing // to unwrap. #[template_callback] - fn window_title(&self, file: Option) -> String { - file.and_then(|f| util::get_file_display_name(&f)) // If the file exists, get display name - .unwrap_or_else(|| i18n("Loupe")) // Return that or the default if there's nothing + fn window_title(&self, path: glib::variant::Variant) -> String { + // ensure that templates are initialized + if path.as_maybe().is_none() { + i18n("Loupe") + } else { + self.imp() + .image_view + .current_file() + .and_then(|f| util::get_file_display_name(&f)) // If the file exists, get display name + .unwrap_or_else(|| i18n("Loupe")) // Return that or the default if there's nothing + } } // We also have a closure that returns `adw::FlapFoldPolicy`.