diff --git a/Cargo.lock b/Cargo.lock index 1ddc627..7469cbd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1044,6 +1044,8 @@ version = "0.1.0" dependencies = [ "anyhow", "ashpd", + "async-global-executor", + "async-std", "futures", "futures-channel", "gettext-rs", diff --git a/Cargo.toml b/Cargo.toml index 70ab448..7aa7494 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,9 +2,12 @@ name = "loupe" version = "0.1.0" edition = "2021" +rust-version = "1.65" [dependencies] anyhow = "1.0.66" +async-global-executor = "2.3.1" +async-std = "1.12.0" gtk-macros = "0.3.0" kamadak-exif = "0.5.5" log = "0.4.17" diff --git a/data/gtk/image_page.ui b/data/gtk/image_page.ui index c19451c..86f3a24 100644 --- a/data/gtk/image_page.ui +++ b/data/gtk/image_page.ui @@ -53,11 +53,13 @@ image-missing-symbolic Could not Load Image - This image could be corrupted or may use an unsupported file format. + + True + True diff --git a/data/gtk/image_view.ui b/data/gtk/image_view.ui index b41c111..1f27b79 100644 --- a/data/gtk/image_view.ui +++ b/data/gtk/image_view.ui @@ -5,7 +5,7 @@ True True - + True diff --git a/src/application.rs b/src/application.rs index 07446ea..7f9378c 100644 --- a/src/application.rs +++ b/src/application.rs @@ -24,6 +24,7 @@ use adw::prelude::*; use adw::subclass::prelude::*; use crate::config; +use crate::widgets::{LpImage, LpImagePage}; use crate::window::LpWindow; mod imp { @@ -74,7 +75,35 @@ mod imp { for file in files { let win = LpWindow::new(&*application); win.set_image_from_file(file, true); - win.show(); + + // show window when image size is known + let watch = win + .imp() + .image_view + .property_expression("current-page-strict") + .chain_property::("image") + .chain_property::("image-size") + .watch( + glib::Object::NONE, + glib::clone!(@weak win => move || { + win.image_size_ready(); + }), + ); + win.imp().watch_image_size.replace(Some(watch)); + + // show if loading image errors + let watch = win + .imp() + .image_view + .property_expression("current-page-strict") + .chain_property::("error") + .watch( + glib::Object::NONE, + glib::clone!(@weak win => move || { + win.image_error(); + }), + ); + win.imp().watch_image_error.replace(Some(watch)); } } } diff --git a/src/file_model.rs b/src/file_model.rs index 394d735..7a95237 100644 --- a/src/file_model.rs +++ b/src/file_model.rs @@ -81,19 +81,19 @@ impl LpFileModel { vec.push(file); } - model.imp().directory.set(directory.clone()).unwrap(); + model.imp().directory.set(directory).unwrap(); Ok(model) } - pub fn load_directory(&self) -> anyhow::Result<()> { - let directory = self.imp().directory.get().unwrap(); + pub async fn load_directory(&self) -> anyhow::Result<()> { + let directory = self.imp().directory.get().unwrap().clone(); - { - // Here we use a nested scope so that the mutable borrow only lasts as long as we need it - let mut vec = self.imp().inner.borrow_mut(); - let original_vec: Vec> = - vec.iter().map(|x| x.path()).collect(); + let original_vec: Vec> = + self.imp().inner.borrow().iter().map(|x| x.path()).collect(); + + let new_files_result = util::spawn("list-files", move || { + let mut vec = Vec::new(); let enumerator = directory .enumerate_children( @@ -105,7 +105,7 @@ impl LpFileModel { gio::FileQueryInfoFlags::NONE, gio::Cancellable::NONE, ) - .context(i18n("Directory does not exist."))?; + .context(i18n("Could not list other files in directory."))?; // Filter out non-images; For now we support "all" image types. enumerator.for_each(|info| { @@ -123,8 +123,23 @@ impl LpFileModel { } }); + //anyhow::Result::Ok(vec) + Ok::<_, anyhow::Error>(vec) + }) + .await; + + let Ok(new_files) = new_files_result else { + log::debug!("Thread listing directory canceled."); + return Ok(()); + }; + + { + // 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?); // Then sort by name. - vec.sort_by(util::compare_by_name); + files.sort_by(util::compare_by_name); } Ok(()) diff --git a/src/main.rs b/src/main.rs index e88bc20..0fd2338 100644 --- a/src/main.rs +++ b/src/main.rs @@ -37,7 +37,7 @@ mod deps { use application::LpApplication; fn main() { - pretty_env_logger::init(); + pretty_env_logger::init_timed(); setlocale(LocaleCategory::LcAll, ""); bindtextdomain("loupe", config::LOCALEDIR).unwrap(); diff --git a/src/util.rs b/src/util.rs index b627e0d..9edc2eb 100644 --- a/src/util.rs +++ b/src/util.rs @@ -5,7 +5,7 @@ use glib::translate::*; use anyhow::Context; -use std::fmt::Write; +use std::fmt::{Debug, Write}; pub fn utf8_collate_key_for_filename(filename: &str) -> String { unsafe { @@ -62,3 +62,15 @@ pub fn compare_by_name(file_a: &gio::File, file_b: &gio::File) -> std::cmp::Orde utf8_collate_key_for_filename(&name_a).cmp(&utf8_collate_key_for_filename(&name_b)) } + +pub async fn spawn( + name: &str, + f: impl Fn() -> T + Send + 'static, +) -> async_std::io::Result { + log::trace!("Starting thread '{name}'"); + + Ok(async_std::task::Builder::new() + .name(name.to_string()) + .spawn(async_global_executor::spawn_blocking(f))? + .await) +} diff --git a/src/widgets/image.rs b/src/widgets/image.rs index ca5f5b4..9754cdb 100644 --- a/src/widgets/image.rs +++ b/src/widgets/image.rs @@ -20,12 +20,14 @@ 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; +use crate::util; const ZOOM_ANIMATION_DURATION: u32 = 200; const ROTATION_ANIMATION_DURATION: u32 = 200; @@ -43,6 +45,9 @@ mod imp { pub struct LpImage { pub file: RefCell>, pub texture: RefCell>, + /// Set when image is completely loaded and ready for displaying + pub is_loaded: Cell, + /// Rotation final value (can differ from `rotation` during animation) pub rotation_target: Cell, /// Rotated presentation of original image in degrees clockwise @@ -75,6 +80,8 @@ mod imp { /// Currently EXIF data pub image_metadata: RefCell, + /// This will often be available earlier than the complete image + pub image_original_size: Cell<(i32, i32)>, /// Current pointer position pub pointer_position: Cell>, @@ -105,6 +112,9 @@ mod imp { glib::ParamSpecObject::builder::("file") .read_only() .build(), + glib::ParamSpecBoolean::builder("is-loaded") + .read_only() + .build(), glib::ParamSpecDouble::builder("rotation") .explicit_notify() .build(), @@ -120,6 +130,9 @@ mod imp { glib::ParamSpecBoolean::builder("is-max-zoom") .read_only() .build(), + glib::ParamSpecVariant::builder("image-size", glib::VariantTy::TUPLE) + .read_only() + .build(), glib::ParamSpecOverride::for_interface::("hadjustment"), glib::ParamSpecOverride::for_interface::("vadjustment"), glib::ParamSpecOverride::for_interface::("hscroll-policy"), @@ -134,11 +147,13 @@ mod imp { let obj = self.instance(); match pspec.name() { "file" => obj.file().to_value(), + "is-loaded" => obj.is_loaded().to_value(), "rotation" => obj.rotation().to_value(), "mirrored" => obj.mirrored().to_value(), "zoom" => obj.zoom().to_value(), "best-fit" => obj.is_best_fit().to_value(), "is-max-zoom" => obj.is_max_zoom().to_value(), + "image-size" => obj.image_size().to_variant().to_value(), // don't use getter functions here sicne they can return a fake adjustment "hadjustment" => self.hadjustment.borrow().to_value(), "vadjustment" => self.vadjustment.borrow().to_value(), @@ -450,6 +465,81 @@ mod imp { snapshot.restore(); } } + + fn measure(&self, orientation: gtk::Orientation, _for_size: i32) -> (i32, i32, i32, i32) { + let (image_width, image_height) = self.instance().image_size(); + + if image_width > 0 && image_height > 0 { + if let Some(display) = gdk::Display::default() { + if let Some(native) = self.instance().native() { + let hidpi_scale = self.instance().scale_factor() as f64; + + // get monitor information + let monitor = display.monitor_at_surface(&native.surface()); + let monitor_geometry = monitor.geometry(); + // TODO: Per documentation those dimensions should not be physical pixels. + // But on Wayland they are physiscal pixels and on X11 not. + // Taking the version that works on Wayland for now. + // + let monitor_width = monitor_geometry.width() as f64 - 40.; + let monitor_height = monitor_geometry.height() as f64 - 60.; + + // areas + let monitor_area = monitor_width * monitor_height; + let image_area = image_width as f64 * image_height as f64; + + let ocuppy_area_factor = if monitor_area < 1024. * 768. { + // for small monitors occupy 80% of the area + 0.8 + } else { + // for large monitors occupy 30% of the area + 0.3 + }; + + // factor for width and height that will achive the desired area occupation + // derived from: + // monitor_area * ocuppy_area_factor == + // (image_width * size_scale) * (image_height * size_scale) + let size_scale = f64::sqrt(monitor_area / image_area * ocuppy_area_factor); + // ensure that we never increase image size + let target_scale = f64::min(1.0, size_scale); + let mut nat_width = image_width as f64 * target_scale; + let mut nat_height = image_height as f64 * target_scale; + + // scale down if targeted occupation does not fit in one direction + if nat_width > monitor_width { + nat_width = monitor_width; + nat_height = nat_height * monitor_width / nat_width; + } + + // same for other direction + if nat_height > monitor_height { + nat_height = monitor_height; + nat_width = nat_width * monitor_height / nat_height; + } + + let size = match orientation { + gtk::Orientation::Horizontal => (nat_width / hidpi_scale).round(), + gtk::Orientation::Vertical => (nat_height / hidpi_scale).round(), + _ => unreachable!(), + }; + + return (0, size as i32, -1, -1); + } + } + } + + // fallback if monitor size or image size is not known: + // use original image size and hope for the best + // TODO: We could use a small default monitor size estimate instead + let size = match orientation { + gtk::Orientation::Horizontal => image_width, + gtk::Orientation::Vertical => image_height, + _ => unreachable!(), + }; + + (0, size, -1, -1) + } } impl ScrollableImpl for LpImage {} @@ -462,26 +552,73 @@ glib::wrapper! { } impl LpImage { - pub fn set_texture_with_file(&self, texture: gdk::Texture, source_file: &gio::File) { + pub async fn load(&self, file: &gio::File) -> anyhow::Result<()> { let imp = self.imp(); - imp.texture.replace(Some(texture)); - imp.file.replace(Some(source_file.clone())); + if let Some(path) = file.path() { + imp.file.replace(Some(file.clone())); - let metadata = ImageMetadata::load(source_file); - let orientation = metadata.orientation(); + if !path.is_file() { + return Err(anyhow::Error::msg(i18n("File does not exist"))); + } - imp.image_metadata.replace(metadata); + log::debug!("Loading file {}", path.display()); - imp.rotation_target.set(-orientation.rotation); - imp.rotation.set(-orientation.rotation); - imp.mirrored.set(orientation.mirrored); + log::debug!("Loading EXIF"); + let metadata = util::spawn( + "image-load-exif", + glib::clone!(@strong file => move || ImageMetadata::load(&file)), + ) + .await?; + let orientation = metadata.orientation(); - self.configure_adjustments(); + imp.image_metadata.replace(metadata); + imp.rotation_target.set(-orientation.rotation); + imp.rotation.set(-orientation.rotation); + imp.mirrored.set(orientation.mirrored); - self.queue_draw(); - self.queue_allocate(); - self.queue_resize(); + log::debug!("Loading image size"); + let (_, x, y) = util::spawn( + "image-load-size", + glib::clone!(@strong path => move || gdk_pixbuf::Pixbuf::file_info(&path)), + ) + .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( + "image-load-image", + glib::clone!(@strong path => move || { + let pixbuf = gdk_pixbuf::Pixbuf::from_file(&path)?; + log::debug!("Creating texture"); + Ok::<_,anyhow::Error>(gdk::Texture::for_pixbuf(&pixbuf)) + }), + ) + .await? + .context(i18n("Failed to load image size"))?; + + imp.texture.replace(Some(texture)); + + self.configure_adjustments(); + + 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"); + } + + Ok(()) + } + + pub fn is_loaded(&self) -> bool { + self.imp().is_loaded.get() } /// Zoom level that makes the image fit in widget @@ -807,6 +944,17 @@ impl LpImage { } } + /// Image size of original image with EXIF rotation applied + pub fn image_size(&self) -> (i32, i32) { + let orientation = self.imp().image_metadata.borrow().orientation(); + if orientation.rotation.abs() == 90. { + let (x, y) = self.imp().image_original_size.get(); + (y, x) + } else { + self.imp().image_original_size.get() + } + } + /// Image width with current zoom factor and rotation /// /// During rotation it is an interpolated size that does not @@ -838,7 +986,7 @@ impl LpImage { if let Some(adj) = self.imp().hadjustment.borrow().as_ref() { adj.clone() } else { - log::debug!("Hadjustment not set yet: Using fake object"); + log::trace!("Hadjustment not set yet: Using fake object"); gtk::Adjustment::default() } } @@ -860,7 +1008,7 @@ impl LpImage { if let Some(adj) = self.imp().vadjustment.borrow().as_ref() { adj.clone() } else { - log::debug!("Vadjustment not set yet: Using fake object"); + log::trace!("Vadjustment not set yet: Using fake object"); gtk::Adjustment::default() } } diff --git a/src/widgets/image_page.rs b/src/widgets/image_page.rs index 074fee8..458080c 100644 --- a/src/widgets/image_page.rs +++ b/src/widgets/image_page.rs @@ -27,6 +27,7 @@ use gtk::CompositeTemplate; use gtk_macros::spawn; use once_cell::sync::{Lazy, OnceCell}; +use std::cell::RefCell; use crate::widgets::LpImage; @@ -54,6 +55,9 @@ mod imp { 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>, } #[glib::object_subclass] @@ -74,9 +78,12 @@ mod imp { impl ObjectImpl for LpImagePage { fn properties() -> &'static [glib::ParamSpec] { static PROPERTIES: Lazy> = Lazy::new(|| { - vec![glib::ParamSpecObject::builder::("image") - .read_only() - .build()] + vec![ + glib::ParamSpecObject::builder::("image") + .read_only() + .build(), + glib::ParamSpecString::builder("error").read_only().build(), + ] }); PROPERTIES.as_ref() @@ -86,6 +93,7 @@ mod imp { let obj = self.instance(); match pspec.name() { "image" => obj.image().to_value(), + "error" => obj.error().to_value(), name => unimplemented!("property {name}"), } } @@ -130,14 +138,15 @@ impl LpImagePage { spawn!(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); + match imp.image.load(&file).await { + Ok(()) => { imp.stack.set_visible_child(&*imp.scrolled_window); imp.spinner.set_spinning(false); }, Err(e) => { + imp.error.replace(Some(e.to_string())); imp.stack.set_visible_child(&*imp.error_page); + obj.notify("error"); imp.spinner.set_spinning(false); log::error!("Could not load image: {e}"); } @@ -159,6 +168,10 @@ impl LpImagePage { self.imp().image.texture() } + pub fn error(&self) -> Option { + self.imp().error.borrow().clone() + } + pub fn content_provider(&self) -> Option { self.imp().image.content_provider() } @@ -172,16 +185,3 @@ impl LpImagePage { imp.popover.popup(); } } - -async fn load_texture_from_file(file: &gio::File) -> Result { - let (sender, receiver) = futures_channel::oneshot::channel(); - - let _ = std::thread::Builder::new() - .name("Load Texture".to_string()) - .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 dd39dc3..f1bbb69 100644 --- a/src/widgets/image_view.rs +++ b/src/widgets/image_view.rs @@ -20,9 +20,9 @@ use crate::deps::*; use crate::i18n::*; +use adw::prelude::*; use adw::subclass::prelude::*; use glib::clone; -use gtk::prelude::*; use gtk::CompositeTemplate; use anyhow::{bail, Context}; @@ -49,6 +49,10 @@ mod imp { #[derive(Debug, Default, CompositeTemplate)] #[template(resource = "/org/gnome/Loupe/gtk/image_view.ui")] pub struct LpImageView { + /// Direct child of this Adw::Bin + #[template_child] + pub bin_child: TemplateChild, + #[template_child] pub carousel: TemplateChild, @@ -56,6 +60,8 @@ mod imp { pub current_model_index: Cell, pub scrolling: Cell, + + pub current_page_strict: RefCell>, } #[glib::object_subclass] @@ -112,6 +118,9 @@ mod imp { self.parent_constructed(); + // Manually mange widget layout, see `WidgetImpl` for details + obj.set_layout_manager(gtk::LayoutManager::NONE); + let source = gtk::DragSource::new(); source.set_exclusive(true); @@ -148,7 +157,40 @@ mod imp { } } - impl WidgetImpl for LpImageView {} + impl WidgetImpl for LpImageView { + /// The main target of this manual implemtation is to provide a good + /// natural width that allows to have a neat size for newly opened `LpWindow`s. + /// The actual calculation of the image natural width happens in + /// the measure function of `LpImage`. + /// + /// This manual implementation is necessary since AdwCarousel gives the + /// largest natural width of all of it's children. But we actually want the + /// one of the opened (current) image. + fn measure(&self, orientation: gtk::Orientation, for_size: i32) -> (i32, i32, i32, i32) { + // Measure child of AdwBin + let child_measure @ (child_min, _, _, _) = + self.bin_child.measure(orientation, for_size); + + if let Some(page) = self.instance().current_page_strict() { + // measure `LpImage` + let (_, image_natural, _, _) = page.image().measure(orientation, for_size); + + // Ensure that minimum size is not smaller than the one of the child. + // Also ensure that the natural width is not smaller than the minimal size. + // Both things are required by GTK. + (child_min, i32::max(child_min, image_natural), -1, -1) + } else { + // If there is no image, just pass through the normal measurement + child_measure + } + } + + /// this is necessary because we do layout manually + fn size_allocate(&self, width: i32, height: i32, baseline: i32) { + self.bin_child.allocate(width, height, baseline, None); + } + } + impl BinImpl for LpImageView {} } @@ -169,7 +211,6 @@ impl LpImageView { self.build_model_from_file(file)?; self.notify("active-file"); - self.notify("current-page-strict"); Ok(()) } @@ -206,22 +247,35 @@ impl LpImageView { } else { let model = LpFileModel::from_file(file)?; *model_store = Some(model.clone()); - log::debug!("new model created"); + log::debug!("first model created"); model } }; - carousel.append(&LpImagePage::from_file(file)); + let page = LpImagePage::from_file(file); + imp.current_page_strict.replace(Some(page.clone())); + self.notify("current-page-strict"); + + carousel.append(&page); self.update_action_state(&model, 0); - model.load_directory()?; - let index = model - .index_of(file) - .context(i18n("File not found in model. (TODO: bad message)"))?; - log::debug!("Currently at file {index} in the directory"); + 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; + } - self.fill_carousel(&model, index); - self.update_action_state(&model, index); + 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_action_state(&model, index); + })); Ok(()) } @@ -323,8 +377,13 @@ impl LpImageView { } #[template_callback] - fn page_changed_cb(&self, _index: u32, carousel: &adw::Carousel) { + fn page_changed_cb(&self, index: u32, carousel: &adw::Carousel) { let imp = self.imp(); + + imp.current_page_strict + .replace(carousel.nth_page(index).downcast().ok()); + self.notify("current-page-strict"); + let b = imp.model.borrow(); let model = b.as_ref().unwrap(); let current = self.active_file().unwrap(); @@ -535,22 +594,7 @@ impl LpImageView { /// Returns `None` during animations instead of returning the closest image pub fn current_page_strict(&self) -> Option { - let carousel = &self.imp().carousel; - - if carousel.position().fract() != 0. { - // there is no current page during animations - None - } else { - let pos = carousel.position().round() as u32; - if pos >= carousel.n_pages() { - log::warn!("Hit AdwCarousel bug"); - None - } else if carousel.n_pages() > 0 { - carousel.nth_page(pos).downcast().ok() - } else { - None - } - } + self.imp().current_page_strict.borrow().clone() } pub fn uri(&self) -> Option { diff --git a/src/window.rs b/src/window.rs index 911d937..453264f 100644 --- a/src/window.rs +++ b/src/window.rs @@ -30,6 +30,8 @@ use ashpd::WindowIdentifier; use gtk_macros::spawn; +use std::cell::RefCell; + use crate::config; use crate::util; use crate::widgets::{LpImage, LpImagePage, LpImageView, LpPropertiesView}; @@ -77,6 +79,9 @@ mod imp { pub properties_view: TemplateChild, #[template_child] pub drop_target: TemplateChild, + + pub watch_image_size: RefCell>, + pub watch_image_error: RefCell>, } #[glib::object_subclass] @@ -449,6 +454,56 @@ impl LpWindow { self.action_set_enabled("win.toggle-properties", enabled); } + pub fn image_size_ready(&self) { + // if visible for whatever reason, don't do any resize + if self.is_visible() { + self.disconnect_present_watches(); + return; + } + + let image = self + .imp() + .image_view + .current_page_strict() + .map(|page| page.image()); + + if let Some(image) = image { + if image.image_size() > (0, 0) { + log::debug!("Showing window because image size is ready"); + // this let's the window determine the default size from LpImage's natural size + self.set_default_size(-1, -1); + self.disconnect_present_watches(); + self.present(); + } + } + } + + pub fn image_error(&self) { + if self.is_visible() { + self.disconnect_present_watches(); + return; + } + + let current_page = self.imp().image_view.current_page_strict(); + + if let Some(page) = current_page { + if page.error().is_some() { + log::debug!("Showin window because loading image failed"); + self.disconnect_present_watches(); + self.present(); + } + } + } + + fn disconnect_present_watches(&self) { + if let Some(watch) = self.imp().watch_image_size.take() { + watch.unwatch(); + } + if let Some(watch) = self.imp().watch_image_error.take() { + watch.unwatch(); + } + } + // Adapted from https://gitlab.gnome.org/GNOME/eog/-/blob/master/src/eog-window.c:eog_window_obtain_desired_size pub fn resize_from_dimensions(&self, img_width: i32, img_height: i32) { let imp = self.imp();