diff --git a/Cargo.lock b/Cargo.lock index e0cc8b6..883e891 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -232,6 +232,12 @@ dependencies = [ "syn", ] +[[package]] +name = "doc-comment" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" + [[package]] name = "easy-parallel" version = "3.1.0" @@ -299,9 +305,9 @@ dependencies = [ [[package]] name = "futures" -version = "0.3.19" +version = "0.3.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28560757fe2bb34e79f907794bb6b22ae8b0e5c669b638a1132f2592b19035b4" +checksum = "f73fe65f54d1e12b726f517d3e2135ca3125a437b6d998caf1962961f7172d9e" dependencies = [ "futures-channel", "futures-core", @@ -314,9 +320,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.19" +version = "0.3.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba3dda0b6588335f360afc675d0564c17a77a2bda81ca178a4b6081bd86c7f0b" +checksum = "c3083ce4b914124575708913bca19bfe887522d6e2e6d0952943f5eac4a74010" dependencies = [ "futures-core", "futures-sink", @@ -324,15 +330,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.19" +version = "0.3.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0c8ff0461b82559810cdccfde3215c3f373807f5e5232b71479bff7bb2583d7" +checksum = "0c09fd04b7e4073ac7156a9539b57a484a8ea920f79c7c675d05d289ab6110d3" [[package]] name = "futures-executor" -version = "0.3.19" +version = "0.3.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29d6d2ff5bb10fb95c85b8ce46538a2e5f5e7fdc755623a7d4529ab8a4ed9d2a" +checksum = "9420b90cfa29e327d0429f19be13e7ddb68fa1cccb09d65e5706b8c7a749b8a6" dependencies = [ "futures-core", "futures-task", @@ -341,9 +347,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.19" +version = "0.3.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f9d34af5a1aac6fb380f735fe510746c38067c5bf16c7fd250280503c971b2" +checksum = "fc4045962a5a5e935ee2fdedaa4e08284547402885ab326734432bed5d12966b" [[package]] name = "futures-lite" @@ -362,9 +368,9 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.19" +version = "0.3.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbd947adfffb0efc70599b3ddcf7b5597bb5fa9e245eb99f62b3a5f7bb8bd3c" +checksum = "33c1e13800337f4d4d7a316bf45a567dbcb6ffe087f16424852d97e97a91f512" dependencies = [ "proc-macro2", "quote", @@ -373,21 +379,21 @@ dependencies = [ [[package]] name = "futures-sink" -version = "0.3.19" +version = "0.3.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3055baccb68d74ff6480350f8d6eb8fcfa3aa11bdc1a1ae3afdd0514617d508" +checksum = "21163e139fa306126e6eedaf49ecdb4588f939600f0b1e770f4205ee4b7fa868" [[package]] name = "futures-task" -version = "0.3.19" +version = "0.3.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ee7c6485c30167ce4dfb83ac568a849fe53274c831081476ee13e0dce1aad72" +checksum = "57c66a976bf5909d801bbef33416c41372779507e7a6b3a5e25e4749c58f776a" [[package]] name = "futures-util" -version = "0.3.19" +version = "0.3.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b5cf40b47a271f77a8b1bec03ca09044d99d2372c0de244e66430761127164" +checksum = "d8b7abd5d659d9b90c8cba917f6ec750a74e2dc23902ef9cd4cc8c8b22e6036a" dependencies = [ "futures-channel", "futures-core", @@ -863,14 +869,17 @@ version = "0.1.0" dependencies = [ "anyhow", "ashpd", + "futures", "futures-channel", "gettext-rs", + "gio", "gtk-macros", "gtk4", "libadwaita", "log", "once_cell", "pretty_env_logger", + "svg_metadata", ] [[package]] @@ -1157,6 +1166,15 @@ version = "0.6.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f497285884f3fcff424ffc933e56d7cbca511def0c9831a7f9b5f6153e3cc89b" +[[package]] +name = "roxmltree" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbf7d7b1ea646d380d0e8153158063a6da7efe30ddbf3184042848e3f8a6f671" +dependencies = [ + "xmlparser", +] + [[package]] name = "rustc_version" version = "0.3.3" @@ -1249,6 +1267,18 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "svg_metadata" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d39f36262efe61096a17aa42140b0e44b9189806c3fa71ab3cff123429938eb0" +dependencies = [ + "doc-comment", + "lazy_static", + "regex", + "roxmltree", +] + [[package]] name = "syn" version = "1.0.83" @@ -1399,6 +1429,12 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "xmlparser" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "114ba2b24d2167ef6d67d7d04c8cc86522b87f490025f39f0303b7db5bf5e3d8" + [[package]] name = "zbus" version = "2.1.1" diff --git a/Cargo.toml b/Cargo.toml index f0a26a2..60facf8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,8 @@ log = "0.4.11" once_cell = "1.9.0" pretty_env_logger = "0.4.0" futures-channel = "0.3.19" +futures = "0.3.21" +svg_metadata = "0.4.2" [dependencies.ashpd] version = "0.2.2" @@ -20,6 +22,11 @@ package = "gtk4" version = "0.4.4" features = ["v4_6"] +[dependencies.gio] +package = "gio" +version = "0.15.2" +features = ["v2_70"] + [dependencies.adw] package = "libadwaita" version = "0.1.0" diff --git a/data/gtk/properties_view.ui b/data/gtk/properties_view.ui new file mode 100644 index 0000000..0dec98b --- /dev/null +++ b/data/gtk/properties_view.ui @@ -0,0 +1,110 @@ + + + + diff --git a/data/gtk/window.ui b/data/gtk/window.ui index b7ba5de..a1eeb1c 100644 --- a/data/gtk/window.ui +++ b/data/gtk/window.ui @@ -18,6 +18,12 @@ False true false + + + LpWindow + properties_flap + + @@ -35,39 +41,60 @@ Primary Menu + + + help-about-symbolic + + - - status_page - True - - - Image Viewer - Click "Open File…" or drag an image here. + + end + never + True + + + + status_page + True - - center - Open File… - win.open - + + Image Viewer + Click "Open File…" or drag an image here. + + + center + Open File… + win.open + + + + + + + + + + + copy - - - - - - - copy + + + + + + + - + diff --git a/data/loupe.gresource.xml b/data/loupe.gresource.xml index e321c41..e481948 100644 --- a/data/loupe.gresource.xml +++ b/data/loupe.gresource.xml @@ -5,6 +5,8 @@ gtk/window.ui gtk/image_page.ui gtk/image_view.ui + gtk/properties_view.ui style.css + style-hc.css diff --git a/data/style-hc.css b/data/style-hc.css new file mode 100644 index 0000000..5376146 --- /dev/null +++ b/data/style-hc.css @@ -0,0 +1,3 @@ +row.property label.title { + opacity: 0.9; +} diff --git a/data/style.css b/data/style.css index 702c76d..e9db6f5 100644 --- a/data/style.css +++ b/data/style.css @@ -36,4 +36,14 @@ flap.fullscreen-widget dimming, shadow, border, outline { ); background-size: 20px 20px; background-position: 0 0, 0 10px, 10px -10px, -10px 0px; -} \ No newline at end of file +} + +row.property label.title { + opacity: 0.55; + font-size: smaller; +} + +row.property label.subtitle { + opacity: 1.0; + font-size: 11pt; +} diff --git a/src/meson.build b/src/meson.build index 0299cda..11b7671 100644 --- a/src/meson.build +++ b/src/meson.build @@ -17,6 +17,7 @@ rust_sources = files( 'widgets/image_page.rs', 'widgets/image_view.rs', 'widgets/mod.rs', + 'widgets/properties_view.rs', 'application.rs', 'config.rs', 'file_model.rs', diff --git a/src/util.rs b/src/util.rs index 9d0a988..df6451b 100644 --- a/src/util.rs +++ b/src/util.rs @@ -37,6 +37,25 @@ pub fn query_attributes(file: &gio::File, attributes: Vec<&str>) -> anyhow::Resu .context("Failed to query string") } +pub async fn query_attributes_future( + file: &gio::File, + attributes: Vec<&str>, +) -> anyhow::Result { + let mut attr_str = String::from(*attributes.get(0).context("No attributes")?); + + for attr in &attributes[1..] { + write!(attr_str, ",{}", attr)?; + } + + file.query_info_future( + &attr_str, + gio::FileQueryInfoFlags::empty(), + glib::Priority::default(), + ) + .await + .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(); diff --git a/src/widgets/mod.rs b/src/widgets/mod.rs index 076921a..c491c20 100644 --- a/src/widgets/mod.rs +++ b/src/widgets/mod.rs @@ -20,7 +20,9 @@ mod image; mod image_page; mod image_view; +mod properties_view; pub use image::LpImage; pub use image_page::LpImagePage; pub use image_view::LpImageView; +pub use properties_view::LpPropertiesView; diff --git a/src/widgets/properties_view.rs b/src/widgets/properties_view.rs new file mode 100644 index 0000000..551b278 --- /dev/null +++ b/src/widgets/properties_view.rs @@ -0,0 +1,388 @@ +// properties_view.rs +// +// Copyright $year 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 crate::util; + +use adw::prelude::*; +use adw::subclass::prelude::*; +use glib::clone; +use gtk::subclass::prelude::*; +use gtk::CompositeTemplate; + +use anyhow::Context; +use ashpd::desktop::open_uri; +use ashpd::WindowIdentifier; +use futures::future::{AbortHandle, Abortable}; +use once_cell::sync::Lazy; +use std::cell::RefCell; + +const MB: f64 = 1_000_000.0; +const KB: f64 = 1_000.0; +const FALLBACK: &str = "-"; + +mod imp { + use super::*; + + #[derive(Debug, Default, CompositeTemplate)] + #[template(resource = "/org/gnome/Loupe/gtk/properties_view.ui")] + pub struct LpPropertiesView { + pub file: RefCell>>, + pub file_info: RefCell>, + pub dimensions: RefCell>, + + pub info_handle: RefCell>, + pub dimensions_handle: RefCell>, + } + + #[glib::object_subclass] + impl ObjectSubclass for LpPropertiesView { + const NAME: &'static str = "LpPropertiesView"; + type Type = super::LpPropertiesView; + type ParentType = adw::Bin; + + fn class_init(klass: &mut Self::Class) { + Self::bind_template(klass); + Self::Type::bind_template_callbacks(klass); + + klass.install_action("properties.open-folder", None, move |properties, _, _| { + properties.open_directory(); + }); + } + + fn instance_init(obj: &glib::subclass::InitializingObject) { + obj.init_template(); + } + } + + impl ObjectImpl for LpPropertiesView { + fn properties() -> &'static [glib::ParamSpec] { + static PROPERTIES: Lazy> = Lazy::new(|| { + vec![ + glib::ParamSpecObject::new( + "file", + "", + "", + gio::File::static_type(), + glib::ParamFlags::READWRITE | glib::ParamFlags::EXPLICIT_NOTIFY, + ), + glib::ParamSpecObject::new( + "file-info", + "", + "", + gio::FileInfo::static_type(), + glib::ParamFlags::READABLE | glib::ParamFlags::EXPLICIT_NOTIFY, + ), + glib::ParamSpecString::new( + "dimensions", + "", + "", + Some(FALLBACK), + glib::ParamFlags::READABLE | glib::ParamFlags::EXPLICIT_NOTIFY, + ), + ] + }); + + PROPERTIES.as_ref() + } + + fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { + let prop_name = pspec.name(); + + match prop_name { + "file" => obj.file().to_value(), + "file-info" => obj.file_info().to_value(), + "dimensions" => self.dimensions.borrow().to_value(), + _ => unimplemented!("Failed to get property \"{prop_name}\""), + } + } + + fn set_property( + &self, + obj: &Self::Type, + _id: usize, + value: &glib::Value, + pspec: &glib::ParamSpec, + ) { + let prop_name = pspec.name(); + + match prop_name { + "file" => obj.set_file(&value.get().ok()), + _ => unimplemented!(), + } + } + + fn constructed(&self, obj: &Self::Type) { + self.parent_constructed(obj); + obj.action_set_enabled("properties.open-folder", false); + } + } + + impl WidgetImpl for LpPropertiesView {} + impl BinImpl for LpPropertiesView {} +} + +glib::wrapper! { + pub struct LpPropertiesView(ObjectSubclass) + @extends gtk::Widget, adw::Bin, + @implements gtk::Buildable, gtk::ConstraintTarget, gtk::Orientable; +} + +#[gtk::template_callbacks] +impl LpPropertiesView { + fn set_file(&self, file: &Option) { + let imp = self.imp(); + + self.action_set_enabled("properties.open-folder", file.is_some()); + + if let Some(file) = file { + if let Some(current_file) = self.file() { + if current_file.equal(file) { + return; + } + } + + // Cancel the file info future. See `build_file_info()` for details. + if let Some(handle) = imp.info_handle.take() { + handle.abort(); + } + + if let Some(handle) = imp.dimensions_handle.take() { + handle.abort(); + } + + self.build_file_info(file); + let weak = file.downgrade(); + imp.file.replace(Some(weak)); + self.notify("file"); + } + } + + fn file(&self) -> Option { + let imp = self.imp(); + imp.file.borrow().as_ref().and_then(|w| w.upgrade()) + } + + fn file_info(&self) -> Option { + let imp = self.imp(); + imp.file_info.borrow().clone() + } + + fn build_file_info(&self, file: &gio::File) { + let imp = self.imp(); + let ctx = glib::MainContext::default(); + + // We need to be able to cancel this future so that + // changing files before the metadata loads does not cause + // old metadata to be loaded. In order to make a cancellable + // future, we use `AbortHandle` and `Abortable` from the `futures` + // crate. + // + // `handle` is the `AbortHandle`, which we need to store in order + // to abort the future. `reg` is the `AbortRegistration` that we + // give to `Abortable::new()` to tie our future to the `AbortHandle`. + // + // Here we build the future: + let (handle, reg) = AbortHandle::new_pair(); + let fut = Abortable::new( + clone!(@weak self as view, @weak file => async move { + if let Ok(info) = util::query_attributes_future( + &file, + vec![ + &gio::FILE_ATTRIBUTE_STANDARD_CONTENT_TYPE, + &gio::FILE_ATTRIBUTE_STANDARD_SIZE, + &gio::FILE_ATTRIBUTE_TIME_CREATED, + &gio::FILE_ATTRIBUTE_TIME_MODIFIED, + ], + ) + .await { + view.on_info_loaded(info, &file); + } + }), + reg, + ); + + // ...store the handle: + imp.info_handle.replace(Some(handle)); + + // ...then spawn the future. + ctx.spawn_local(async { + let _ = fut.await; + }); + } + + // This is where we handle the results of the future. + // This could technically be part of the future, but we keep + // them separate for a bit of cleanliness. + fn on_info_loaded(&self, info: gio::FileInfo, file: &gio::File) { + let imp = self.imp(); + self.build_dimensions(&info, file); + imp.file_info.replace(Some(info)); + self.notify("file-info"); + } + + fn build_dimensions(&self, info: &gio::FileInfo, file: &gio::File) { + let imp = self.imp(); + let ctx = glib::MainContext::default(); + + // Here we do the same thing we do for the rest of the file info. + let (handle, reg) = AbortHandle::new_pair(); + let fut = Abortable::new( + clone!(@weak self as view, @weak file, @weak info => async move { + let is_svg = info.content_type() + .map(|t| t.to_string()) + .map(|t| t == "image/svg+xml") + .unwrap_or_default(); + + let path = file.peek_path(); + if path.is_none() { + return; + } + + let dimensions = if is_svg { + parse_svg_file_future(path.unwrap()) + .await + .ok() + .and_then(|m| { + match (m.width(), m.height()) { + (Some(w), Some(h)) => Some((w, h)), + _ => None::<(f64, f64)>, + } + }) + .map(|(w, h)| format!("{w:.1} \u{D7} {h:.1}")) + } else { + gdk_pixbuf::Pixbuf::file_info_future(path.unwrap()) + .await + .ok() + .flatten() + .map(|(_, w, h)| format!("{w} \u{D7} {h}")) + }; + + view.on_dimensions_loaded(dimensions.unwrap_or_else(|| FALLBACK.to_string())); + }), + reg, + ); + + imp.dimensions_handle.replace(Some(handle)); + + ctx.spawn_local(async { + let _ = fut.await; + }); + } + + fn on_dimensions_loaded(&self, dimensions: String) { + let imp = self.imp(); + imp.dimensions.replace(Some(dimensions)); + self.notify("dimensions"); + } + + fn open_directory(&self) { + if let Some(directory) = self + .file() + .and_then(|f| f.peek_path()) + .and_then(|p| std::fs::File::open(p).ok()) + { + let ctx = glib::MainContext::default(); + + ctx.spawn_local(clone!(@weak self as view => async move { + let id = WindowIdentifier::from_native(&view.native().expect("No GtkNative for view")).await; + if let Err(e) = open_uri::open_directory(&id, &directory).await { + log::error!("Could not open parent directory: {e}"); + }; + })); + } + } + + // In the LpPropertiesView UI file we define a few `gtk::Expression`s + // that are closures. These closures take either the current `gio::File` + // or the current file's associated `gio::FileInfo` and process them + // accordingly. + // + // In this function we chain `Option`s with `and_then()` in order + // to handle optional results with a fallback, without needing to + // have multiple `match` or `if let` branches, and without needing + // to unwrap. + #[template_callback] + fn folder_name(&self, file: Option) -> String { + file.and_then(|f| f.parent()) // If the file exists, get the parent + .and_then(|p| util::get_file_display_name(&p)) // if that exists, the display name + .unwrap_or_else(|| FALLBACK.to_owned()) // and if we get nothing, use `FALLBACK` + } + + #[template_callback] + fn file_type(&self, info: Option) -> String { + info.and_then(|i| i.content_type()) + .map(|t| t.to_string()) + .map(|t| { + t.rsplit('/').collect::>()[0] + .to_owned() + .to_uppercase() + }) + .unwrap_or_else(|| FALLBACK.to_owned()) + } + + #[template_callback] + fn file_size(&self, info: Option) -> String { + info.map(|info| { + let bytes = info.size() as f64; + + let (mut size, mut unit) = (bytes, "B"); + let sizes = [(MB, "MB"), (KB, "kB")]; + + for (b, u) in sizes { + if bytes > b { + let res = bytes / b; + size = res; + unit = u; + break; + } + } + + String::from(&format!("{size:.1} {unit}")) + }) + .unwrap_or_else(|| FALLBACK.to_owned()) + } + + #[template_callback] + fn created_date(&self, info: Option) -> String { + info.and_then(|i| i.creation_date_time()) + .and_then(|t| t.format("%x, %X").ok()) + .map(|t| t.to_string()) + .unwrap_or_else(|| FALLBACK.to_owned()) + } + + #[template_callback] + fn modified_date(&self, info: Option) -> String { + info.and_then(|i| i.modification_date_time()) + .and_then(|t| t.format("%x, %X").ok()) + .map(|t| t.to_string()) + .unwrap_or_else(|| FALLBACK.to_owned()) + } +} + +async fn parse_svg_file_future(path: std::path::PathBuf) -> anyhow::Result { + let (sender, receiver) = futures_channel::oneshot::channel(); + + std::thread::spawn(move || { + let result = svg_metadata::Metadata::parse_file(path); + sender.send(result).unwrap() + }); + + receiver.await.unwrap().context("Could not load metadata") +} diff --git a/src/window.rs b/src/window.rs index 30555f3..71e7a57 100644 --- a/src/window.rs +++ b/src/window.rs @@ -30,7 +30,7 @@ use ashpd::WindowIdentifier; use crate::config; use crate::util; -use crate::widgets::LpImageView; +use crate::widgets::{LpImageView, LpPropertiesView}; mod imp { use super::*; @@ -70,6 +70,8 @@ mod imp { #[template_child] pub image_view: TemplateChild, #[template_child] + pub properties_view: TemplateChild, + #[template_child] pub drop_target: TemplateChild, } @@ -209,11 +211,11 @@ impl LpWindow { let imp = self.imp(); if fullscreen { - imp.flap.set_fold_policy(adw::FlapFoldPolicy::Always); imp.headerbar.add_css_class("osd"); + imp.properties_view.add_css_class("osd") } else { - imp.flap.set_fold_policy(adw::FlapFoldPolicy::Never); imp.headerbar.remove_css_class("osd"); + imp.properties_view.remove_css_class("osd") } self.set_fullscreened(fullscreen); @@ -382,4 +384,16 @@ impl LpWindow { file.and_then(|f| util::get_file_display_name(&f)) // If the file exists, get display name .unwrap_or_else(|| String::from("Image Viewer")) // Return that or the default if there's nothing } + + // We also have a closure that returns `adw::FlapFoldPolicy`. + // if we aren't fullscreened, or if the properties are revealed, + // we unfold the main flap. Otherwise we're always folded. + #[template_callback] + fn fold_policy(&self, fullscreened: bool, properties_revealed: bool) -> adw::FlapFoldPolicy { + if !fullscreened || properties_revealed { + adw::FlapFoldPolicy::Never + } else { + adw::FlapFoldPolicy::Always + } + } }