diff --git a/Cargo.lock b/Cargo.lock index abbaeea..e0cc8b6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -863,6 +863,7 @@ version = "0.1.0" dependencies = [ "anyhow", "ashpd", + "futures-channel", "gettext-rs", "gtk-macros", "gtk4", diff --git a/Cargo.toml b/Cargo.toml index 0babf77..f0a26a2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ gtk-macros = "0.3.0" log = "0.4.11" once_cell = "1.9.0" pretty_env_logger = "0.4.0" +futures-channel = "0.3.19" [dependencies.ashpd] version = "0.2.2" diff --git a/data/gtk/image_page.ui b/data/gtk/image_page.ui new file mode 100644 index 0000000..815b249 --- /dev/null +++ b/data/gtk/image_page.ui @@ -0,0 +1,76 @@ + + + + +
+ + _Open With… + win.open-with + + + + _Print… + win.print + + + _Copy + win.copy + + + + _Set as Wallpaper + win.set-wallpaper + +
+
+
diff --git a/data/gtk/image_view.ui b/data/gtk/image_view.ui index 7dcb506..88af8a3 100644 --- a/data/gtk/image_view.ui +++ b/data/gtk/image_view.ui @@ -8,61 +8,42 @@ True - - center - center + + True + False + False + - - True - - - start - center - 18 - 18 - go-previous-symbolic - iv.previous - Previous Image - - - - - - True - end - center - 18 - 18 - go-next-symbolic - iv.next - Next Image - - - - - - 3 - - - - - True - - - - - False - image_menu - - + + start + center + 18 + 18 + go-previous-symbolic + iv.previous + Previous Image + + + + + + True + end + center + 18 + 18 + go-next-symbolic + iv.next + Next Image + @@ -87,32 +68,4 @@ - -
- - _Open With… - win.open-with - - - - _Print… - win.print - - - _Copy - win.copy - - - - _Set as Wallpaper - win.set-wallpaper - -
-
diff --git a/data/gtk/window.ui b/data/gtk/window.ui index 7feb7f5..bf803d3 100644 --- a/data/gtk/window.ui +++ b/data/gtk/window.ui @@ -16,11 +16,6 @@ - - - image_view - - open-menu-symbolic diff --git a/data/loupe.gresource.xml b/data/loupe.gresource.xml index f4ab741..e321c41 100644 --- a/data/loupe.gresource.xml +++ b/data/loupe.gresource.xml @@ -3,6 +3,7 @@ gtk/help_overlay.ui gtk/window.ui + gtk/image_page.ui gtk/image_view.ui style.css diff --git a/src/file_model.rs b/src/file_model.rs index d1e94af..61b8e22 100644 --- a/src/file_model.rs +++ b/src/file_model.rs @@ -136,4 +136,10 @@ impl LpFileModel { pub fn directory(&self) -> Option { self.imp().directory.get().cloned() } + + pub fn index_of(&self, file: &gio::File) -> Option { + let imp = self.imp(); + let vec = imp.inner.borrow(); + vec.iter().position(|f| f.equal(file)) + } } diff --git a/src/meson.build b/src/meson.build index 4942195..0299cda 100644 --- a/src/meson.build +++ b/src/meson.build @@ -13,8 +13,9 @@ run_command( ) rust_sources = files( - 'widgets/image_view.rs', 'widgets/image.rs', + 'widgets/image_page.rs', + 'widgets/image_view.rs', 'widgets/mod.rs', 'application.rs', 'config.rs', diff --git a/src/widgets/image.rs b/src/widgets/image.rs index 8b9a520..b663cb2 100644 --- a/src/widgets/image.rs +++ b/src/widgets/image.rs @@ -82,6 +82,12 @@ mod imp { obj.set_hexpand(true); obj.set_vexpand(true); } + + fn dispose(&self, obj: &Self::Type) { + while let Some(child) = obj.first_child() { + child.unparent(); + } + } } impl WidgetImpl for LpImage { @@ -184,7 +190,6 @@ impl LpImage { self.queue_draw(); self.queue_resize(); - imp.image_height.get(); } pub fn image_width(&self) -> i32 { @@ -197,12 +202,21 @@ impl LpImage { imp.image_height.get() } + pub fn set_texture_with_file(&self, texture: gdk::Texture, source_file: &gio::File) { + let imp = self.imp(); + imp.texture.replace(Some(texture)); + imp.file.replace(Some(source_file.clone())); + + self.queue_draw(); + self.queue_resize(); + } + pub fn texture(&self) -> Option { let imp = self.imp(); imp.texture.borrow().clone() } - pub fn content_provider(&self) -> anyhow::Result { + pub fn content_provider(&self) -> gdk::ContentProvider { let imp = self.imp(); let mut contents = vec![]; @@ -217,6 +231,6 @@ impl LpImage { contents.push(content); } - Ok(gdk::ContentProvider::new_union(contents.as_slice())) + gdk::ContentProvider::new_union(contents.as_slice()) } } diff --git a/src/widgets/image_page.rs b/src/widgets/image_page.rs new file mode 100644 index 0000000..99c6ce8 --- /dev/null +++ b/src/widgets/image_page.rs @@ -0,0 +1,157 @@ +// image_page.rs +// +// Copyright 2022 Christopher Davis +// +// 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 crate::deps::*; + +use adw::subclass::prelude::*; +use glib::clone; +use gtk::prelude::*; +use gtk::subclass::prelude::*; +use gtk::CompositeTemplate; + +use once_cell::sync::OnceCell; + +use crate::widgets::LpImage; + +mod imp { + use super::*; + + #[derive(Debug, Default, CompositeTemplate)] + #[template(resource = "/org/gnome/Loupe/gtk/image_page.ui")] + pub struct LpImagePage { + #[template_child] + pub(super) stack: TemplateChild, + #[template_child] + pub(super) spinner: TemplateChild, + #[template_child] + pub(super) error_page: TemplateChild, + #[template_child] + pub(super) image: TemplateChild, + #[template_child] + pub(super) popover: TemplateChild, + #[template_child] + pub(super) click_gesture: TemplateChild, + #[template_child] + pub(super) press_gesture: TemplateChild, + + pub(super) file: OnceCell, + } + + #[glib::object_subclass] + impl ObjectSubclass for LpImagePage { + const NAME: &'static str = "LpImagePage"; + type Type = super::LpImagePage; + type ParentType = adw::Bin; + + fn class_init(klass: &mut Self::Class) { + Self::bind_template(klass); + } + + fn instance_init(obj: &glib::subclass::InitializingObject) { + obj.init_template(); + } + } + + impl ObjectImpl for LpImagePage { + fn constructed(&self, obj: &Self::Type) { + self.parent_constructed(obj); + + self.click_gesture + .connect_pressed(clone!(@weak obj => move |_, _, x, y| { + obj.show_popover_at(x, y); + })); + + self.press_gesture + .connect_pressed(clone!(@weak obj => move |_, x, y| { + obj.show_popover_at(x, y); + })); + } + } + + impl WidgetImpl for LpImagePage {} + impl BinImpl for LpImagePage {} +} + +glib::wrapper! { + pub struct LpImagePage(ObjectSubclass) + @extends gtk::Widget, adw::Bin, + @implements gtk::Buildable, gtk::ConstraintTarget, gtk::Orientable; +} + +impl LpImagePage { + pub fn from_file(file: &gio::File) -> Self { + let obj = glib::Object::new::(&[]).unwrap(); + obj.imp().file.set(file.clone()).unwrap(); + + // 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()); + + let ctx = glib::MainContext::default(); + ctx.spawn_local(clone!(@weak obj, @weak file => async move { + let imp = obj.imp(); + match load_texture_from_file(&file).await { + Ok(texture) => { + imp.image.set_texture_with_file(texture, &file); + imp.stack.set_visible_child(&*imp.image); + imp.spinner.set_spinning(false); + }, + Err(e) => { + imp.stack.set_visible_child(&*imp.error_page); + imp.spinner.set_spinning(false); + log::error!("Could not load image: {e}"); + } + } + })); + + obj + } + + pub fn file(&self) -> Option { + self.imp().file.get().cloned() + } + + pub fn texture(&self) -> Option { + self.imp().image.texture() + } + + pub fn content_provider(&self) -> gdk::ContentProvider { + self.imp().image.content_provider() + } + + pub fn show_popover_at(&self, x: f64, y: f64) { + let imp = self.imp(); + + let rect = gdk::Rectangle::new(x as i32, y as i32, 0, 0); + + imp.popover.set_pointing_to(Some(&rect)); + imp.popover.popup(); + } +} + +async fn load_texture_from_file(file: &gio::File) -> Result { + let (sender, receiver) = futures_channel::oneshot::channel(); + + std::thread::spawn(clone!(@weak file => move || { + let result = gdk::Texture::from_file(&file); + sender.send(result).unwrap() + })); + + receiver.await.unwrap() +} diff --git a/src/widgets/image_view.rs b/src/widgets/image_view.rs index bfdef0f..6ad74c7 100644 --- a/src/widgets/image_view.rs +++ b/src/widgets/image_view.rs @@ -25,7 +25,7 @@ use gtk::prelude::*; use gtk::subclass::prelude::*; use gtk::CompositeTemplate; -use anyhow::Context; +use anyhow::{bail, Context}; use ashpd::desktop::wallpaper; use ashpd::WindowIdentifier; use once_cell::sync::Lazy; @@ -34,7 +34,11 @@ use std::cell::RefCell; use crate::file_model::LpFileModel; use crate::thumbnail::Thumbnail; use crate::util; -use crate::widgets::LpImage; +use crate::widgets::LpImagePage; + +// Maximum number of pages to load +// at any given time +const N_PAGES: u32 = 3; mod imp { use super::*; @@ -43,23 +47,10 @@ mod imp { #[template(resource = "/org/gnome/Loupe/gtk/image_view.ui")] pub struct LpImageView { #[template_child] - pub picture: TemplateChild, - #[template_child] - pub controls: TemplateChild, - #[template_child] - pub popover: TemplateChild, - #[template_child] - pub click_gesture: TemplateChild, - #[template_child] - pub press_gesture: TemplateChild, - - // RefCell allows for interior mutability of non-primitive types. - pub menu_model: RefCell>, - pub popover_menu_model: RefCell>, + pub carousel: TemplateChild, pub model: RefCell>, - pub current_file: RefCell>, - pub uri: RefCell>, + pub filename: RefCell>, } #[glib::object_subclass] @@ -70,13 +61,14 @@ mod imp { fn class_init(klass: &mut Self::Class) { Self::bind_template(klass); + Self::Type::bind_template_callbacks(klass); klass.install_action("iv.next", None, move |image_view, _, _| { - image_view.next(); + image_view.navigate(adw::NavigationDirection::Forward); }); klass.install_action("iv.previous", None, move |image_view, _, _| { - image_view.previous(); + image_view.navigate(adw::NavigationDirection::Back); }); } @@ -88,22 +80,13 @@ mod imp { impl ObjectImpl for LpImageView { fn properties() -> &'static [glib::ParamSpec] { static PROPERTIES: Lazy> = Lazy::new(|| { - vec![ - glib::ParamSpecString::new( - "filename", - "Filename", - "The filename of the current file", - None, - glib::ParamFlags::READABLE, - ), - glib::ParamSpecObject::new( - "controls", - "Controls", - "The controls for the image view", - gtk::Box::static_type(), - glib::ParamFlags::READABLE, - ), - ] + vec![glib::ParamSpecString::new( + "filename", + "Filename", + "The filename of the current file", + None, + glib::ParamFlags::READABLE, + )] }); PROPERTIES.as_ref() @@ -112,7 +95,6 @@ mod imp { fn property(&self, obj: &Self::Type, _id: usize, psec: &glib::ParamSpec) -> glib::Value { match psec.name() { "filename" => obj.filename().to_value(), - "controls" => self.controls.to_value(), _ => unimplemented!(), } } @@ -120,33 +102,16 @@ mod imp { fn constructed(&self, obj: &Self::Type) { self.parent_constructed(obj); - self.click_gesture - .connect_pressed(clone!(@weak obj => move |_, _, x, y| { - obj.show_popover_at(x, y); - })); - - self.press_gesture - .connect_pressed(clone!(@weak obj => move |_, x, y| { - obj.show_popover_at(x, y); - })); - let source = gtk::DragSource::new(); source.connect_prepare( glib::clone!(@weak obj => @default-return None, move |_, _, _| { - match obj.imp().picture.content_provider() { - Ok(content) => Some(content), - Err(e) => { - log::error!("Could not get content provider: {:?}", e); - None - } - } + obj.current_page().map(|p| p.content_provider()) }), ); source.connect_drag_begin(glib::clone!(@weak obj => move |source, _| { - let imp = obj.imp(); - if let Some(texture) = imp.picture.texture() { + if let Some(texture) = obj.current_page().and_then(|p| p.texture()) { let thumbnail = Thumbnail::new(&texture); source.set_icon(Some(&thumbnail), 0, 0); }; @@ -166,85 +131,116 @@ glib::wrapper! { @implements gtk::Buildable, gtk::ConstraintTarget, gtk::Orientable; } +#[gtk::template_callbacks] impl LpImageView { - pub fn set_image_from_file(&self, file: &gio::File) -> anyhow::Result<(i32, i32)> { - let imp = self.imp(); - - if let Some(current_file) = imp.picture.file() { - if current_file.path().as_deref() == file.path().as_deref() { - return Err(anyhow::Error::msg( - "Image is the same as the previous image; Doing nothing.", - )); + pub fn set_image_from_file(&self, file: &gio::File) -> anyhow::Result<()> { + if let Some(current_file) = self.current_page().and_then(|p| p.file()) { + if current_file.equal(file) { + bail!("Image is the same as the previous image; Doing nothing."); } } - imp.current_file.replace(Some(file.clone())); - self.set_parent_from_file(file); - self.update_image(file); + self.build_model_from_file(file); + self.update_action_state(file); self.notify("filename"); - let width = imp.picture.image_width(); - let height = imp.picture.image_height(); + // TODO: rework width stuff + // let width = imp.picture.image_width(); + // let height = imp.picture.image_height(); - log::debug!("Image dimensions: {} x {}", width, height); - Ok((width, height)) + // log::debug!("Image dimensions: {} x {}", width, height); + // Ok((width, height)) + Ok(()) } - fn set_parent_from_file(&self, file: &gio::File) { + // 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`. + // + // TODO: Properly loading a new model with the same view, + // & loading a file from the same directory + fn build_model_from_file(&self, file: &gio::File) { let imp = self.imp(); - let mut model = imp.model.borrow_mut(); + let carousel = &imp.carousel; - if let Some(ref parent) = file.parent() { - if let Some(ref m) = *model { - if m.directory().map_or(false, |f| !f.equal(parent)) { + { + // Here we use a nested scope so that the mutable borrow only lasts as long as we need it + let mut model = imp.model.borrow_mut(); + + if let Some(ref parent) = file.parent() { + if let Some(ref m) = *model { + if m.directory().map_or(false, |f| !f.equal(parent)) { + *model = Some(LpFileModel::from_directory(parent)); + log::debug!("new model created"); + } else { + // Early return if the parent is equal to the current model's directory + return; + } + } else { *model = Some(LpFileModel::from_directory(parent)); log::debug!("new model created"); } + } + } + + if let Some(model) = imp.model.borrow().as_ref() { + let index = model.index_of(file).unwrap() as u32; + imp.filename.replace(util::get_file_display_name(file)); + carousel.append(&LpImagePage::from_file(file)); + + // Here we need to check if we're at the start or end of our directory. + // If we are, we try to add two files to the other side. Otherwise, + // add one file on each end of the current file. + let iterations = if index == 0 || index == model.n_items() - 1 { + 2 } else { - *model = Some(LpFileModel::from_directory(parent)); - log::debug!("new model created"); + 1 + }; + + let mut next_file = file.clone(); + for i in 0..iterations { + log::debug!("Loop to find next files: {}", i); + if let Some(f) = model.next(&next_file) { + log::debug!("Adding next image URI: {}", f.uri()); + carousel.append(&LpImagePage::from_file(&f)); + next_file = f; + } else { + // Return early if there are no files in this direction; + break; + } + } + + let mut prev_file = file.clone(); + for i in 0..iterations { + log::debug!("Loop to find prior files: {}", i); + if let Some(f) = model.previous(&prev_file) { + log::debug!("Adding previous image URI: {}", f.uri()); + carousel.prepend(&LpImagePage::from_file(&f)); + prev_file = f; + } else { + break; + } } } } - fn update_image(&self, file: &gio::File) { - let imp = self.imp(); - - imp.picture.set_file(file); - imp.uri.replace(Some(file.uri().to_string())); - self.update_action_state(file); - } - - pub fn next(&self) { - { - let imp = self.imp(); - let b = imp.model.borrow(); - let model = b.as_ref().unwrap(); - let mut current_file = imp.current_file.borrow_mut(); - - if let Some(file) = current_file.as_mut() { - *file = model.next(file).unwrap(); - self.update_image(file); + 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); + } } - } - - self.notify("filename"); - } - - pub fn previous(&self) { - { - let imp = self.imp(); - let b = imp.model.borrow(); - let model = b.as_ref().unwrap(); - let mut current_file = imp.current_file.borrow_mut(); - - if let Some(file) = current_file.as_mut() { - *file = model.previous(file).unwrap(); - self.update_image(file); + adw::NavigationDirection::Back => { + if pos > 0 { + carousel.scroll_to(&carousel.nth_page(pos - 1), true) + } } - } - - self.notify("filename"); + _ => unimplemented!("Navigation direction should only be back or forward."), + }; } pub fn update_action_state(&self, file: &gio::File) { @@ -259,6 +255,37 @@ impl LpImageView { self.action_set_enabled("iv.previous", prev_enabled); } + #[template_callback] + fn page_changed_cb(&self, index: u32, carousel: &adw::Carousel) { + let imp = self.imp(); + let b = imp.model.borrow(); + let model = b.as_ref().unwrap(); + let current = self.current_page().and_then(|p| p.file()).unwrap(); + imp.filename.replace(util::get_file_display_name(¤t)); + self.notify("filename"); + self.update_action_state(¤t); + + // We've moved forward + if index + 1 == N_PAGES { + if let Some(ref next) = model.next(¤t) { + log::debug!("Next URI: {}", next.uri()); + // Remove the page at index 0, add a new page at the end + carousel.remove(&carousel.nth_page(0)); + carousel.append(&LpImagePage::from_file(next)); + } + } + + // We've moved backward + if index == 0 { + if let Some(ref prev) = model.previous(¤t) { + log::debug!("Previous URI: {}", prev.uri()); + // Remove the page at the front, add a new page at the back + carousel.remove(&carousel.nth_page(carousel.n_pages() - 1)); + carousel.prepend(&LpImagePage::from_file(prev)); + } + } + } + pub fn set_wallpaper(&self) -> anyhow::Result<()> { let wallpaper = self.uri().context("No URI for current file")?; let ctx = glib::MainContext::default(); @@ -293,10 +320,10 @@ impl LpImageView { } pub fn print(&self) -> anyhow::Result<()> { - let imp = self.imp(); - let operation = gtk::PrintOperation::new(); - let path = (imp.current_file.borrow().as_ref()) + let path = self + .current_page() + .and_then(|p| p.file()) .context("No file")? .peek_path() .context("No path")?; @@ -334,9 +361,8 @@ impl LpImageView { pub fn copy(&self) -> anyhow::Result<()> { let clipboard = self.clipboard(); - let imp = self.imp(); - if let Some(texture) = imp.picture.texture() { + if let Some(texture) = self.current_page().context("No current page")?.texture() { clipboard.set_texture(&texture); } else { anyhow::bail!("No Image displayed."); @@ -345,24 +371,23 @@ impl LpImageView { Ok(()) } + fn current_page(&self) -> Option { + let carousel = &self.imp().carousel; + let pos = carousel.position().round() as u32; + if carousel.n_pages() > 0 { + carousel.nth_page(pos).downcast().ok() + } else { + None + } + } + pub fn uri(&self) -> Option { - let imp = self.imp(); - imp.uri.borrow().to_owned() + let page = self.current_page().expect("No page"); + let file = page.file().expect("No file"); + Some(file.uri().to_string()) } pub fn filename(&self) -> Option { - let imp = self.imp(); - let b = imp.current_file.borrow(); - let file = b.as_ref()?; - util::get_file_display_name(file) - } - - pub fn show_popover_at(&self, x: f64, y: f64) { - let imp = self.imp(); - - let rect = gdk::Rectangle::new(x as i32, y as i32, 0, 0); - - imp.popover.set_pointing_to(Some(&rect)); - imp.popover.popup(); + self.imp().filename.borrow().clone() } } diff --git a/src/widgets/mod.rs b/src/widgets/mod.rs index a20abfe..076921a 100644 --- a/src/widgets/mod.rs +++ b/src/widgets/mod.rs @@ -18,7 +18,9 @@ // SPDX-License-Identifier: GPL-3.0-or-later mod image; +mod image_page; mod image_view; pub use image::LpImage; +pub use image_page::LpImagePage; pub use image_view::LpImageView; diff --git a/src/window.rs b/src/window.rs index b5b7c88..bfebcb7 100644 --- a/src/window.rs +++ b/src/window.rs @@ -324,15 +324,15 @@ 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_file(&self, file: &gio::File, _resize: bool) { 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); - } + Ok((/*width, height*/)) => { + // if resize { + // self.resize_from_dimensions(width, height); + // } imp.stack.set_visible_child(&*imp.image_view); imp.image_view.grab_focus();