imageView: Use custom slide widget and IndexSet
Use IndexSet to allow direct access by Path and also allow an arbitrary ordering that was not possible with Vec. Also add monitoring of file changes in the directory. Use a custom LpSlidingView instead of AdwCarousel because Carousel was never designed for this job and suffered of bugs. LpSlidingView does not support swiping gestures yet since they currently don't work yet anyways #24. Use a new algorithm to fill the LpSlidingView that is stateless and covers all potential changes in the directory. This not only fixes bugs but would also work with changed sorting orders. This also adds build time template XML validation since it was useful for the refactoring #46. Closes #9, #11, #30, #33, #42
This commit is contained in:
parent
73764810eb
commit
f2357513da
21 changed files with 1010 additions and 553 deletions
27
Cargo.lock
generated
27
Cargo.lock
generated
|
|
@ -987,6 +987,7 @@ dependencies = [
|
||||||
"proc-macro-crate",
|
"proc-macro-crate",
|
||||||
"proc-macro-error",
|
"proc-macro-error",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
|
"quick-xml",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
@ -1023,6 +1024,12 @@ dependencies = [
|
||||||
"system-deps",
|
"system-deps",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.12.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "heck"
|
name = "heck"
|
||||||
version = "0.4.0"
|
version = "0.4.0"
|
||||||
|
|
@ -1063,6 +1070,16 @@ dependencies = [
|
||||||
"unicode-normalization",
|
"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]]
|
[[package]]
|
||||||
name = "instant"
|
name = "instant"
|
||||||
version = "0.1.12"
|
version = "0.1.12"
|
||||||
|
|
@ -1206,6 +1223,7 @@ dependencies = [
|
||||||
"gio 0.16.6",
|
"gio 0.16.6",
|
||||||
"gtk-macros",
|
"gtk-macros",
|
||||||
"gtk4",
|
"gtk4",
|
||||||
|
"indexmap",
|
||||||
"kamadak-exif",
|
"kamadak-exif",
|
||||||
"libadwaita",
|
"libadwaita",
|
||||||
"libgweather",
|
"libgweather",
|
||||||
|
|
@ -1474,6 +1492,15 @@ version = "1.2.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
|
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]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.21"
|
version = "1.0.21"
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ futures = "0.3.25"
|
||||||
svg_metadata = "0.4.3"
|
svg_metadata = "0.4.3"
|
||||||
url = "2.3.1"
|
url = "2.3.1"
|
||||||
libgweather = "4.0.0"
|
libgweather = "4.0.0"
|
||||||
|
indexmap = "1.9.2"
|
||||||
|
|
||||||
[dependencies.ashpd]
|
[dependencies.ashpd]
|
||||||
version = "0.4.0-alpha.1"
|
version = "0.4.0-alpha.1"
|
||||||
|
|
@ -26,7 +27,7 @@ features = ["gtk4"]
|
||||||
[dependencies.gtk]
|
[dependencies.gtk]
|
||||||
package = "gtk4"
|
package = "gtk4"
|
||||||
version = "0.5.5"
|
version = "0.5.5"
|
||||||
features = ["v4_10"]
|
features = ["v4_10", "xml_validation"]
|
||||||
|
|
||||||
[dependencies.gio]
|
[dependencies.gio]
|
||||||
package = "gio"
|
package = "gio"
|
||||||
|
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
From b2c52f258757e37a74a95c109431f6af8cda8f91 Mon Sep 17 00:00:00 2001
|
|
||||||
From: Sophie Herold <sophie@hemio.de>
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
@ -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",
|
"name" : "loupe",
|
||||||
"builddir" : true,
|
"builddir" : true,
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,9 @@
|
||||||
<object class="GtkOverlay" id="bin_child">
|
<object class="GtkOverlay" id="bin_child">
|
||||||
<property name="hexpand">True</property>
|
<property name="hexpand">True</property>
|
||||||
<child>
|
<child>
|
||||||
<object class="AdwCarousel" id="carousel">
|
<object class="LpSlidingView" id="sliding_view">
|
||||||
<property name="interactive">True</property>
|
<signal name="notify::current-page" handler="page_changed_cb" swapped="yes"/>
|
||||||
<property name="allow-mouse-drag">False</property>
|
<signal name="target-page-reached" handler="target_page_reached_cb" swapped="yes"/>
|
||||||
<property name="allow-scroll-wheel">False</property>
|
|
||||||
<signal name="page-changed" handler="page_changed_cb" swapped="yes"/>
|
|
||||||
</object>
|
</object>
|
||||||
</child>
|
</child>
|
||||||
<child type="overlay">
|
<child type="overlay">
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,11 @@
|
||||||
<property name="title" translatable="yes">Loupe</property>
|
<property name="title" translatable="yes">Loupe</property>
|
||||||
<binding name="title">
|
<binding name="title">
|
||||||
<closure type="gchararray" function="window_title">
|
<closure type="gchararray" function="window_title">
|
||||||
<lookup type="LpImageView" name="active-file">image_view</lookup>
|
<lookup name="path">
|
||||||
|
<lookup name="image">
|
||||||
|
<lookup name="current-page">image_view</lookup>
|
||||||
|
</lookup>
|
||||||
|
</lookup>
|
||||||
</closure>
|
</closure>
|
||||||
</binding>
|
</binding>
|
||||||
<property name="content">
|
<property name="content">
|
||||||
|
|
@ -187,10 +191,9 @@
|
||||||
</property>
|
</property>
|
||||||
<property name="flap">
|
<property name="flap">
|
||||||
<object class="LpPropertiesView" id="properties_view">
|
<object class="LpPropertiesView" id="properties_view">
|
||||||
<property name="file" bind-source="image_view" bind-property="active-file" bind-flags="sync-create"/>
|
|
||||||
<binding name="image">
|
<binding name="image">
|
||||||
<lookup name="image">
|
<lookup name="image">
|
||||||
<lookup name="current-page-strict">
|
<lookup name="current-page">
|
||||||
image_view
|
image_view
|
||||||
</lookup>
|
</lookup>
|
||||||
</lookup>
|
</lookup>
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,6 @@
|
||||||
<gresources>
|
<gresources>
|
||||||
<gresource prefix="/org/gnome/Loupe">
|
<gresource prefix="/org/gnome/Loupe">
|
||||||
<file compressed="true" preprocess="xml-stripblanks">gtk/help_overlay.ui</file>
|
<file compressed="true" preprocess="xml-stripblanks">gtk/help_overlay.ui</file>
|
||||||
<file compressed="true" preprocess="xml-stripblanks">gtk/window.ui</file>
|
|
||||||
<file compressed="true" preprocess="xml-stripblanks">gtk/image_page.ui</file>
|
|
||||||
<file compressed="true" preprocess="xml-stripblanks">gtk/image_view.ui</file>
|
|
||||||
<file compressed="true" preprocess="xml-stripblanks">gtk/properties_view.ui</file>
|
|
||||||
<file>style.css</file>
|
<file>style.css</file>
|
||||||
<file>style-hc.css</file>
|
<file>style-hc.css</file>
|
||||||
</gresource>
|
</gresource>
|
||||||
|
|
|
||||||
|
|
@ -76,13 +76,13 @@ mod imp {
|
||||||
let application = self.instance();
|
let application = self.instance();
|
||||||
for file in files {
|
for file in files {
|
||||||
let win = LpWindow::new(&*application);
|
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
|
// show window when image size is known
|
||||||
let watch = win
|
let watch = win
|
||||||
.imp()
|
.imp()
|
||||||
.image_view
|
.image_view
|
||||||
.property_expression("current-page-strict")
|
.property_expression("current-page")
|
||||||
.chain_property::<LpImagePage>("image")
|
.chain_property::<LpImagePage>("image")
|
||||||
.chain_property::<LpImage>("image-size")
|
.chain_property::<LpImage>("image-size")
|
||||||
.watch(
|
.watch(
|
||||||
|
|
@ -97,7 +97,7 @@ mod imp {
|
||||||
let watch = win
|
let watch = win
|
||||||
.imp()
|
.imp()
|
||||||
.image_view
|
.image_view
|
||||||
.property_expression("current-page-strict")
|
.property_expression("current-page")
|
||||||
.chain_property::<LpImagePage>("error")
|
.chain_property::<LpImagePage>("error")
|
||||||
.watch(
|
.watch(
|
||||||
glib::Object::NONE,
|
glib::Object::NONE,
|
||||||
|
|
|
||||||
|
|
@ -21,81 +21,87 @@ use crate::deps::*;
|
||||||
use crate::i18n::*;
|
use crate::i18n::*;
|
||||||
use crate::util;
|
use crate::util;
|
||||||
|
|
||||||
use gio::prelude::*;
|
use std::path::{Path, PathBuf};
|
||||||
use gio::subclass::prelude::*;
|
|
||||||
|
|
||||||
use anyhow::Context;
|
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;
|
use std::cell::RefCell;
|
||||||
|
|
||||||
mod imp {
|
mod imp {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use gtk::glib::subclass::Signal;
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
pub struct LpFileModel {
|
pub struct LpFileModel {
|
||||||
pub(super) inner: RefCell<Vec<gio::File>>,
|
/// Use and IndexSet such that we can put the elements into an arbitrary order
|
||||||
pub(super) directory: OnceCell<gio::File>,
|
/// while still having fast hash access via `PathBuf`.
|
||||||
|
pub(super) files: RefCell<IndexSet<PathBuf>>,
|
||||||
|
pub(super) directory: OnceCell<PathBuf>,
|
||||||
|
/// Track file changes.
|
||||||
|
pub(super) monitor: OnceCell<gio::FileMonitor>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[glib::object_subclass]
|
#[glib::object_subclass]
|
||||||
impl ObjectSubclass for LpFileModel {
|
impl ObjectSubclass for LpFileModel {
|
||||||
const NAME: &'static str = "LpFileModel";
|
const NAME: &'static str = "LpFileModel";
|
||||||
type Type = super::LpFileModel;
|
type Type = super::LpFileModel;
|
||||||
type Interfaces = (gio::ListModel,);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ObjectImpl for LpFileModel {}
|
impl ObjectImpl for LpFileModel {
|
||||||
|
fn signals() -> &'static [Signal] {
|
||||||
impl ListModelImpl for LpFileModel {
|
static SIGNALS: Lazy<Vec<Signal>> =
|
||||||
fn item_type(&self) -> glib::Type {
|
Lazy::new(|| vec![Signal::builder("changed").build()]);
|
||||||
gio::File::static_type()
|
SIGNALS.as_ref()
|
||||||
}
|
|
||||||
|
|
||||||
fn n_items(&self) -> u32 {
|
|
||||||
self.inner.borrow().len() as u32
|
|
||||||
}
|
|
||||||
|
|
||||||
fn item(&self, position: u32) -> Option<glib::Object> {
|
|
||||||
self.inner
|
|
||||||
.borrow()
|
|
||||||
.get(position as usize)
|
|
||||||
.map(|f| f.clone().upcast())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
glib::wrapper! {
|
glib::wrapper! {
|
||||||
pub struct LpFileModel(ObjectSubclass<imp::LpFileModel>) @implements gio::ListModel;
|
pub struct LpFileModel(ObjectSubclass<imp::LpFileModel>);
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for LpFileModel {
|
||||||
|
fn default() -> Self {
|
||||||
|
glib::Object::new(&[])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LpFileModel {
|
impl LpFileModel {
|
||||||
pub fn from_file(file: &gio::File) -> anyhow::Result<Self> {
|
/// Create with a single element
|
||||||
let model = glib::Object::new::<Self>(&[]);
|
pub fn from_path(path: &Path) -> Self {
|
||||||
let directory = file.parent().context("File has not parent")?;
|
let model = Self::default();
|
||||||
let file =
|
|
||||||
directory.resolve_relative_path(file.basename().context("File has no basename")?);
|
|
||||||
|
|
||||||
{
|
{
|
||||||
let mut vec = model.imp().inner.borrow_mut();
|
let mut vec = model.imp().files.borrow_mut();
|
||||||
vec.push(file);
|
vec.insert(path.to_path_buf());
|
||||||
}
|
}
|
||||||
|
|
||||||
model.imp().directory.set(directory).unwrap();
|
model
|
||||||
|
|
||||||
Ok(model)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn load_directory(&self) -> anyhow::Result<()> {
|
/// Load all file from the given directory
|
||||||
let directory = self.imp().directory.get().unwrap().clone();
|
///
|
||||||
|
/// 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<Option<std::path::PathBuf>> =
|
let monitor = gio::File::for_path(&directory)
|
||||||
self.imp().inner.borrow().iter().map(|x| x.path()).collect();
|
.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 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(
|
.enumerate_children(
|
||||||
&format!(
|
&format!(
|
||||||
"{},{}",
|
"{},{}",
|
||||||
|
|
@ -107,24 +113,20 @@ impl LpFileModel {
|
||||||
)
|
)
|
||||||
.context(i18n("Could not list other files in directory."))?;
|
.context(i18n("Could not list other files in directory."))?;
|
||||||
|
|
||||||
// Filter out non-images; For now we support "all" image types.
|
|
||||||
enumerator.for_each(|info| {
|
enumerator.for_each(|info| {
|
||||||
if let Ok(info) = info {
|
if let Ok(info) = info {
|
||||||
if let Some(content_type) = info.content_type().map(|t| t.to_string()) {
|
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/") {
|
if content_type.starts_with("image/") {
|
||||||
let name = info.name();
|
let path = PathBuf::from_iter([directory.clone(), info.name()]);
|
||||||
let relative_path = directory.resolve_relative_path(&name);
|
log::debug!("{path:?} is an image, adding to the list");
|
||||||
if !original_vec.contains(&relative_path.path()) {
|
files.insert(path);
|
||||||
log::debug!("{:?} is an image, adding to the list", name);
|
|
||||||
vec.push(relative_path);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
//anyhow::Result::Ok(vec)
|
Ok::<_, anyhow::Error>(files)
|
||||||
Ok::<_, anyhow::Error>(vec)
|
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|
@ -133,32 +135,166 @@ impl LpFileModel {
|
||||||
return Ok(());
|
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
|
// 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();
|
let mut files = self.imp().files.borrow_mut();
|
||||||
files.append(&mut new_files?);
|
for path in files.iter() {
|
||||||
|
new_files.insert(path.clone());
|
||||||
|
}
|
||||||
|
*files = new_files;
|
||||||
// Then sort by name.
|
// Then sort by name.
|
||||||
files.sort_by(util::compare_by_name);
|
Self::sort(&mut files);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn directory(&self) -> Option<gio::File> {
|
pub fn directory(&self) -> Option<PathBuf> {
|
||||||
self.imp().directory.get().cloned()
|
self.imp().directory.get().cloned()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn file(&self, index: u32) -> Option<gio::File> {
|
pub fn index_of(&self, path: &Path) -> Option<usize> {
|
||||||
let vec = self.imp().inner.borrow();
|
self.imp().files.borrow().get_index_of(path)
|
||||||
vec.get(index as usize).cloned()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn index_of(&self, file: &gio::File) -> Option<u32> {
|
pub fn n_files(&self) -> usize {
|
||||||
let imp = self.imp();
|
self.imp().files.borrow().len()
|
||||||
let vec = imp.inner.borrow();
|
}
|
||||||
vec.binary_search_by(|a| util::compare_by_name(a, file))
|
|
||||||
.ok()
|
pub fn contains(&self, path: &Path) -> bool {
|
||||||
.map(|i| i as u32)
|
self.imp().files.borrow().contains(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn before(&self, path: &Path) -> Option<PathBuf> {
|
||||||
|
let index = self.index_of(path)?;
|
||||||
|
|
||||||
|
self.imp()
|
||||||
|
.files
|
||||||
|
.borrow()
|
||||||
|
.get_index(index.checked_sub(1)?)
|
||||||
|
.cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn after(&self, path: &Path) -> Option<PathBuf> {
|
||||||
|
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<PathBuf> {
|
||||||
|
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<PathBuf>) {
|
||||||
|
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", &[]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ pub struct GPSCoord {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GPSCoord {
|
impl GPSCoord {
|
||||||
fn to_f64(&self) -> f64 {
|
fn to_f64(self) -> f64 {
|
||||||
let sign = if self.sing { 1. } else { -1. };
|
let sign = if self.sing { 1. } else { -1. };
|
||||||
|
|
||||||
let min = self.min.unwrap_or_default();
|
let min = self.min.unwrap_or_default();
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,12 @@ mod obj;
|
||||||
pub use gps::GPSLocation;
|
pub use gps::GPSLocation;
|
||||||
pub use obj::LpImageMetadata;
|
pub use obj::LpImageMetadata;
|
||||||
|
|
||||||
use gio::prelude::*;
|
|
||||||
|
|
||||||
use crate::i18n::*;
|
use crate::i18n::*;
|
||||||
|
|
||||||
use gtk::glib;
|
use gtk::glib;
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub enum ImageMetadata {
|
pub enum ImageMetadata {
|
||||||
Exif(exif::Exif),
|
Exif(exif::Exif),
|
||||||
|
|
@ -44,8 +44,7 @@ impl std::fmt::Debug for ImageMetadata {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ImageMetadata {
|
impl ImageMetadata {
|
||||||
pub fn load(file: &gio::File) -> Self {
|
pub fn load(path: &Path) -> Self {
|
||||||
if let Some(path) = file.path() {
|
|
||||||
log::debug!("Loading metadata for {:?}", path);
|
log::debug!("Loading metadata for {:?}", path);
|
||||||
if let Ok(file) = std::fs::File::open(path) {
|
if let Ok(file) = std::fs::File::open(path) {
|
||||||
let mut bufreader = std::io::BufReader::new(&file);
|
let mut bufreader = std::io::BufReader::new(&file);
|
||||||
|
|
@ -55,7 +54,6 @@ impl ImageMetadata {
|
||||||
return Self::Exif(exif);
|
return Self::Exif(exif);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Self::None
|
Self::None
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,16 +7,17 @@ use gtk::glib;
|
||||||
use once_cell::sync::Lazy;
|
use once_cell::sync::Lazy;
|
||||||
|
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
glib::wrapper! {
|
glib::wrapper! {
|
||||||
pub struct LpImageMetadata(ObjectSubclass<imp::LpImageMetadata>);
|
pub struct LpImageMetadata(ObjectSubclass<imp::LpImageMetadata>);
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LpImageMetadata {
|
impl LpImageMetadata {
|
||||||
pub fn load(file: &gio::File) -> Self {
|
pub fn load(path: &Path) -> Self {
|
||||||
let obj = glib::Object::new::<Self>(&[]);
|
let obj = glib::Object::new::<Self>(&[]);
|
||||||
|
|
||||||
let metadata = ImageMetadata::load(file);
|
let metadata = ImageMetadata::load(path);
|
||||||
obj.imp().metadata.replace(metadata);
|
obj.imp().metadata.replace(metadata);
|
||||||
|
|
||||||
obj
|
obj
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ mod widgets;
|
||||||
mod window;
|
mod window;
|
||||||
|
|
||||||
mod deps {
|
mod deps {
|
||||||
pub use gtk::{gdk, gdk_pixbuf, gio, glib, graphene};
|
pub use gtk::{gdk, gdk_pixbuf, gio, glib, graphene, gsk};
|
||||||
}
|
}
|
||||||
|
|
||||||
use application::LpApplication;
|
use application::LpApplication;
|
||||||
|
|
|
||||||
10
src/util.rs
10
src/util.rs
|
|
@ -1,10 +1,10 @@
|
||||||
use crate::deps::*;
|
use crate::deps::*;
|
||||||
|
|
||||||
|
use anyhow::Context;
|
||||||
use gio::prelude::*;
|
use gio::prelude::*;
|
||||||
|
|
||||||
use anyhow::Context;
|
|
||||||
|
|
||||||
use std::fmt::{Debug, Write};
|
use std::fmt::{Debug, Write};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
pub fn get_file_display_name(file: &gio::File) -> Option<String> {
|
pub fn get_file_display_name(file: &gio::File) -> Option<String> {
|
||||||
let info = query_attributes(file, vec![&gio::FILE_ATTRIBUTE_STANDARD_DISPLAY_NAME]).ok()?;
|
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")
|
.context("Failed to query attributes")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn compare_by_name(file_a: &gio::File, file_b: &gio::File) -> std::cmp::Ordering {
|
pub fn compare_by_name(file_a: &Path, file_b: &Path) -> std::cmp::Ordering {
|
||||||
let name_a = get_file_display_name(file_a).unwrap_or_default();
|
let name_a = file_a.display().to_string();
|
||||||
let name_b = get_file_display_name(file_b).unwrap_or_default();
|
let name_b = file_b.display().to_string();
|
||||||
|
|
||||||
let key_a = glib::FilenameCollationKey::from(name_a);
|
let key_a = glib::FilenameCollationKey::from(name_a);
|
||||||
let key_b = glib::FilenameCollationKey::from(name_b);
|
let key_b = glib::FilenameCollationKey::from(name_b);
|
||||||
|
|
|
||||||
|
|
@ -17,18 +17,19 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
use crate::deps::*;
|
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::i18n::i18n;
|
||||||
use crate::image_metadata::{ImageMetadata, LpImageMetadata};
|
use crate::image_metadata::{ImageMetadata, LpImageMetadata};
|
||||||
use crate::util;
|
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 ZOOM_ANIMATION_DURATION: u32 = 200;
|
||||||
const ROTATION_ANIMATION_DURATION: u32 = 200;
|
const ROTATION_ANIMATION_DURATION: u32 = 200;
|
||||||
|
|
||||||
|
|
@ -44,6 +45,10 @@ mod imp {
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
pub struct LpImage {
|
pub struct LpImage {
|
||||||
pub file: RefCell<Option<gio::File>>,
|
pub file: RefCell<Option<gio::File>>,
|
||||||
|
pub path: RefCell<Option<PathBuf>>,
|
||||||
|
pub is_deleted: Cell<bool>,
|
||||||
|
/// Track changes to this image
|
||||||
|
pub file_monitor: RefCell<Option<gio::FileMonitor>>,
|
||||||
pub texture: RefCell<Option<gdk::Texture>>,
|
pub texture: RefCell<Option<gdk::Texture>>,
|
||||||
/// Set when image is completely loaded and ready for displaying
|
/// Set when image is completely loaded and ready for displaying
|
||||||
pub is_loaded: Cell<bool>,
|
pub is_loaded: Cell<bool>,
|
||||||
|
|
@ -112,6 +117,12 @@ mod imp {
|
||||||
glib::ParamSpecObject::builder::<gio::File>("file")
|
glib::ParamSpecObject::builder::<gio::File>("file")
|
||||||
.read_only()
|
.read_only()
|
||||||
.build(),
|
.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")
|
glib::ParamSpecBoolean::builder("is-loaded")
|
||||||
.read_only()
|
.read_only()
|
||||||
.build(),
|
.build(),
|
||||||
|
|
@ -150,6 +161,8 @@ mod imp {
|
||||||
let obj = self.instance();
|
let obj = self.instance();
|
||||||
match pspec.name() {
|
match pspec.name() {
|
||||||
"file" => obj.file().to_value(),
|
"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(),
|
"is-loaded" => obj.is_loaded().to_value(),
|
||||||
"metadata" => obj.metadata().to_value(),
|
"metadata" => obj.metadata().to_value(),
|
||||||
"rotation" => obj.rotation().to_value(),
|
"rotation" => obj.rotation().to_value(),
|
||||||
|
|
@ -556,22 +569,22 @@ glib::wrapper! {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LpImage {
|
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 imp = self.imp();
|
||||||
|
let path = path.to_path_buf();
|
||||||
if let Some(path) = file.path() {
|
let file = gio::File::for_path(&path);
|
||||||
imp.file.replace(Some(file.clone()));
|
self.set_file(&file);
|
||||||
|
|
||||||
if !path.is_file() {
|
if !path.is_file() {
|
||||||
return Err(anyhow::Error::msg(i18n("File does not exist")));
|
return Err(anyhow::Error::msg(i18n("File does not exist")));
|
||||||
}
|
}
|
||||||
|
|
||||||
log::debug!("Loading file {}", path.display());
|
log::debug!("Loading file {path:?}");
|
||||||
|
|
||||||
log::debug!("Loading EXIF");
|
log::debug!("Loading EXIF");
|
||||||
let metadata = util::spawn(
|
let metadata = util::spawn(
|
||||||
"image-load-exif",
|
"image-load-exif",
|
||||||
glib::clone!(@strong file => move || ImageMetadata::load(&file)),
|
glib::clone!(@strong path => move || ImageMetadata::load(&path)),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let orientation = metadata.orientation();
|
let orientation = metadata.orientation();
|
||||||
|
|
@ -613,9 +626,6 @@ impl LpImage {
|
||||||
log::debug!("Image loaded and scheduled for drawing");
|
log::debug!("Image loaded and scheduled for drawing");
|
||||||
imp.is_loaded.set(true);
|
imp.is_loaded.set(true);
|
||||||
self.notify("is-loaded");
|
self.notify("is-loaded");
|
||||||
} else {
|
|
||||||
log::error!("File has not path");
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -624,6 +634,10 @@ impl LpImage {
|
||||||
self.imp().is_loaded.get()
|
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
|
/// Zoom level that makes the image fit in widget
|
||||||
///
|
///
|
||||||
/// During image rotation the image does not actually fit into widget.
|
/// During image rotation the image does not actually fit into widget.
|
||||||
|
|
@ -660,9 +674,60 @@ impl LpImage {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn file(&self) -> Option<gio::File> {
|
pub fn file(&self) -> Option<gio::File> {
|
||||||
|
self.imp().file.borrow().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn path(&self) -> Option<PathBuf> {
|
||||||
|
self.imp().path.borrow().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn set_file(&self, file: &gio::File) {
|
||||||
let imp = self.imp();
|
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
|
/// Texture that contains the original image
|
||||||
|
|
|
||||||
|
|
@ -18,24 +18,23 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
use crate::deps::*;
|
use crate::deps::*;
|
||||||
|
use crate::widgets::LpImage;
|
||||||
|
|
||||||
use adw::subclass::prelude::*;
|
use adw::subclass::prelude::*;
|
||||||
use glib::clone;
|
use glib::clone;
|
||||||
use gtk::prelude::*;
|
use gtk::prelude::*;
|
||||||
use gtk::CompositeTemplate;
|
use gtk::CompositeTemplate;
|
||||||
|
|
||||||
use gtk_macros::spawn;
|
use gtk_macros::spawn;
|
||||||
|
use once_cell::sync::Lazy;
|
||||||
|
|
||||||
use once_cell::sync::{Lazy, OnceCell};
|
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
use crate::widgets::LpImage;
|
|
||||||
|
|
||||||
mod imp {
|
mod imp {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[derive(Debug, Default, CompositeTemplate)]
|
#[derive(Debug, Default, CompositeTemplate)]
|
||||||
#[template(resource = "/org/gnome/Loupe/gtk/image_page.ui")]
|
#[template(file = "../../data/gtk/image_page.ui")]
|
||||||
pub struct LpImagePage {
|
pub struct LpImagePage {
|
||||||
#[template_child]
|
#[template_child]
|
||||||
pub(super) stack: TemplateChild<gtk::Stack>,
|
pub(super) stack: TemplateChild<gtk::Stack>,
|
||||||
|
|
@ -54,8 +53,6 @@ mod imp {
|
||||||
#[template_child]
|
#[template_child]
|
||||||
pub(super) press_gesture: TemplateChild<gtk::GestureLongPress>,
|
pub(super) press_gesture: TemplateChild<gtk::GestureLongPress>,
|
||||||
|
|
||||||
pub(super) file: OnceCell<gio::File>,
|
|
||||||
|
|
||||||
/// set if an error has occured and is shown on error_page
|
/// set if an error has occured and is shown on error_page
|
||||||
pub(super) error: RefCell<Option<String>>,
|
pub(super) error: RefCell<Option<String>>,
|
||||||
}
|
}
|
||||||
|
|
@ -97,6 +94,7 @@ mod imp {
|
||||||
name => unimplemented!("property {name}"),
|
name => unimplemented!("property {name}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn constructed(&self) {
|
fn constructed(&self) {
|
||||||
let obj = self.instance();
|
let obj = self.instance();
|
||||||
|
|
||||||
|
|
@ -128,17 +126,20 @@ glib::wrapper! {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LpImagePage {
|
impl LpImagePage {
|
||||||
pub fn from_file(file: &gio::File) -> Self {
|
pub fn from_path(path: &Path) -> Self {
|
||||||
let obj = glib::Object::new::<Self>(&[]);
|
let obj = glib::Object::new::<Self>(&[]);
|
||||||
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
|
// This doesn't work properly for items not explicitly selected
|
||||||
// via the file chooser portal. I'm not sure how to make this work.
|
// via the file chooser portal. I'm not sure how to make this work.
|
||||||
gtk::RecentManager::default().add_item(&file.uri());
|
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();
|
let imp = obj.imp();
|
||||||
match imp.image.load(&file).await {
|
match imp.image.load(&path).await {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
imp.stack.set_visible_child(&*imp.scrolled_window);
|
imp.stack.set_visible_child(&*imp.scrolled_window);
|
||||||
imp.spinner.set_spinning(false);
|
imp.spinner.set_spinning(false);
|
||||||
|
|
@ -156,8 +157,8 @@ impl LpImagePage {
|
||||||
obj
|
obj
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn file(&self) -> Option<gio::File> {
|
pub fn path(&self) -> PathBuf {
|
||||||
self.imp().file.get().cloned()
|
self.image().path().unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn image(&self) -> LpImage {
|
pub fn image(&self) -> LpImage {
|
||||||
|
|
|
||||||
|
|
@ -18,36 +18,35 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
use crate::deps::*;
|
use crate::deps::*;
|
||||||
|
use crate::file_model::LpFileModel;
|
||||||
use crate::i18n::*;
|
use crate::i18n::*;
|
||||||
|
use crate::thumbnail::Thumbnail;
|
||||||
|
use crate::widgets::{LpImage, LpImagePage, LpSlidingView};
|
||||||
|
|
||||||
use adw::prelude::*;
|
use adw::prelude::*;
|
||||||
use adw::subclass::prelude::*;
|
use adw::subclass::prelude::*;
|
||||||
use glib::clone;
|
use anyhow::Context;
|
||||||
use gtk::CompositeTemplate;
|
|
||||||
|
|
||||||
use anyhow::{bail, Context};
|
|
||||||
use ashpd::desktop::wallpaper;
|
use ashpd::desktop::wallpaper;
|
||||||
use ashpd::desktop::ResponseError;
|
use ashpd::desktop::ResponseError;
|
||||||
use ashpd::Error;
|
use ashpd::Error;
|
||||||
use ashpd::WindowIdentifier;
|
use ashpd::WindowIdentifier;
|
||||||
use once_cell::sync::Lazy;
|
use glib::clone;
|
||||||
use std::cell::{Cell, RefCell};
|
use gtk::CompositeTemplate;
|
||||||
|
|
||||||
use gtk_macros::spawn;
|
use gtk_macros::spawn;
|
||||||
|
use once_cell::sync::Lazy;
|
||||||
|
|
||||||
use crate::file_model::LpFileModel;
|
use std::cell::{Cell, RefCell};
|
||||||
use crate::thumbnail::Thumbnail;
|
use std::path::{Path, PathBuf};
|
||||||
use crate::widgets::LpImagePage;
|
|
||||||
|
|
||||||
// The number of pages we want to buffer
|
// The number of pages we want to buffer
|
||||||
// on either side of the current page.
|
// on either side of the current page.
|
||||||
const BUFFER: u32 = 2;
|
const BUFFER: usize = 2;
|
||||||
|
|
||||||
mod imp {
|
mod imp {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[derive(Debug, Default, CompositeTemplate)]
|
#[derive(Debug, Default, CompositeTemplate)]
|
||||||
#[template(resource = "/org/gnome/Loupe/gtk/image_view.ui")]
|
#[template(file = "../../data/gtk/image_view.ui")]
|
||||||
pub struct LpImageView {
|
pub struct LpImageView {
|
||||||
/// Direct child of this Adw::Bin
|
/// Direct child of this Adw::Bin
|
||||||
#[template_child]
|
#[template_child]
|
||||||
|
|
@ -59,14 +58,10 @@ mod imp {
|
||||||
#[template_child]
|
#[template_child]
|
||||||
pub controls_box_end: TemplateChild<gtk::Widget>,
|
pub controls_box_end: TemplateChild<gtk::Widget>,
|
||||||
#[template_child]
|
#[template_child]
|
||||||
pub carousel: TemplateChild<adw::Carousel>,
|
pub sliding_view: TemplateChild<LpSlidingView>,
|
||||||
|
|
||||||
pub model: RefCell<Option<LpFileModel>>,
|
pub model: RefCell<LpFileModel>,
|
||||||
pub current_model_index: Cell<u32>,
|
pub preserve_content: Cell<bool>,
|
||||||
|
|
||||||
pub scrolling: Cell<bool>,
|
|
||||||
|
|
||||||
pub current_page_strict: RefCell<Option<LpImagePage>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[glib::object_subclass]
|
#[glib::object_subclass]
|
||||||
|
|
@ -89,10 +84,7 @@ mod imp {
|
||||||
fn properties() -> &'static [glib::ParamSpec] {
|
fn properties() -> &'static [glib::ParamSpec] {
|
||||||
static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = Lazy::new(|| {
|
static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = Lazy::new(|| {
|
||||||
vec![
|
vec![
|
||||||
glib::ParamSpecObject::builder::<gio::File>("active-file")
|
glib::ParamSpecObject::builder::<LpImagePage>("current-page")
|
||||||
.read_only()
|
|
||||||
.build(),
|
|
||||||
glib::ParamSpecObject::builder::<LpImagePage>("current-page-strict")
|
|
||||||
.read_only()
|
.read_only()
|
||||||
.build(),
|
.build(),
|
||||||
glib::ParamSpecBoolean::builder("is-previous-available")
|
glib::ParamSpecBoolean::builder("is-previous-available")
|
||||||
|
|
@ -110,8 +102,7 @@ mod imp {
|
||||||
fn property(&self, _id: usize, psec: &glib::ParamSpec) -> glib::Value {
|
fn property(&self, _id: usize, psec: &glib::ParamSpec) -> glib::Value {
|
||||||
let obj = self.instance();
|
let obj = self.instance();
|
||||||
match psec.name() {
|
match psec.name() {
|
||||||
"active-file" => obj.active_file().to_value(),
|
"current-page" => obj.current_page().to_value(),
|
||||||
"current-page-strict" => obj.current_page_strict().to_value(),
|
|
||||||
"is-previous-available" => obj.is_previous_available().to_value(),
|
"is-previous-available" => obj.is_previous_available().to_value(),
|
||||||
"is-next-available" => obj.is_next_available().to_value(),
|
"is-next-available" => obj.is_next_available().to_value(),
|
||||||
_ => unimplemented!(),
|
_ => unimplemented!(),
|
||||||
|
|
@ -126,6 +117,26 @@ mod imp {
|
||||||
// Manually mange widget layout, see `WidgetImpl` for details
|
// Manually mange widget layout, see `WidgetImpl` for details
|
||||||
obj.set_layout_manager(gtk::LayoutManager::NONE);
|
obj.set_layout_manager(gtk::LayoutManager::NONE);
|
||||||
|
|
||||||
|
obj.property_expression("current-page")
|
||||||
|
.chain_property::<LpImagePage>("image")
|
||||||
|
.chain_property::<LpImage>("path")
|
||||||
|
.watch(
|
||||||
|
glib::Object::NONE,
|
||||||
|
glib::clone!(@weak obj => move || {
|
||||||
|
obj.current_image_path_changed();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
obj.property_expression("current-page")
|
||||||
|
.chain_property::<LpImagePage>("image")
|
||||||
|
.chain_property::<LpImage>("is-deleted")
|
||||||
|
.watch(
|
||||||
|
glib::Object::NONE,
|
||||||
|
glib::clone!(@weak obj => move || {
|
||||||
|
obj.current_image_path_changed();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
let source = gtk::DragSource::new();
|
let source = gtk::DragSource::new();
|
||||||
source.set_exclusive(true);
|
source.set_exclusive(true);
|
||||||
|
|
||||||
|
|
@ -153,12 +164,6 @@ mod imp {
|
||||||
}));
|
}));
|
||||||
|
|
||||||
obj.add_controller(&source);
|
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
|
// take as minimum whatever is larger
|
||||||
let min = i32::max(child_min, overlay1_min + overlay2_min);
|
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`
|
// measure `LpImage`
|
||||||
let (_, image_natural, _, _) = page.image().measure(orientation, for_size);
|
let (_, image_natural, _, _) = page.image().measure(orientation, for_size);
|
||||||
|
|
||||||
|
|
@ -213,198 +218,215 @@ glib::wrapper! {
|
||||||
|
|
||||||
#[gtk::template_callbacks]
|
#[gtk::template_callbacks]
|
||||||
impl LpImageView {
|
impl LpImageView {
|
||||||
pub fn set_image_from_file(&self, file: &gio::File) -> anyhow::Result<()> {
|
pub fn set_image_from_path(&self, path: &Path) {
|
||||||
if let Some(current_file) = self.active_file() {
|
if let Err(err) = self.load_path(path) {
|
||||||
if current_file.equal(file) {
|
log::error!("Failed to load path: {err}");
|
||||||
bail!("Image is the same as the previous image; Doing nothing.");
|
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`
|
// Builds an `LpFileModel`, which is an implementation of `gio::ListModel`
|
||||||
// that holds a `gio::File` for each child within the same directory as the
|
// 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,
|
// file we pass. This model will update with changes to the directory,
|
||||||
// and in turn we'll update our `adw::Carousel`.
|
// and in turn we'll update our `adw::Carousel`.
|
||||||
fn build_model_from_file(&self, file: &gio::File) -> anyhow::Result<()> {
|
fn load_path(&self, path: &Path) -> anyhow::Result<()> {
|
||||||
let imp = self.imp();
|
let sliding_view = self.sliding_view();
|
||||||
let carousel = &imp.carousel;
|
let directory = path.parent().map(|x| x.to_path_buf());
|
||||||
|
|
||||||
let model = {
|
let is_same_directory = directory == self.model().directory();
|
||||||
// Here we use a nested scope so that the mutable borrow only lasts as long as we need it
|
|
||||||
|
|
||||||
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 {
|
if is_same_directory {
|
||||||
log::debug!("Re-using old model and navigating to the current file");
|
log::debug!("Re-using old model and navigating to the current file");
|
||||||
self.navigate_to_file(file);
|
self.navigate_to_path(path);
|
||||||
return Ok(());
|
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
|
|
||||||
}
|
}
|
||||||
} 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);
|
let model = LpFileModel::from_path(path);
|
||||||
imp.current_page_strict.replace(Some(page.clone()));
|
self.set_model(model);
|
||||||
self.notify("current-page-strict");
|
log::debug!("New model created");
|
||||||
self.notify("is-previous-available");
|
|
||||||
self.notify("is-next-available");
|
|
||||||
|
|
||||||
carousel.append(&page);
|
let page = LpImagePage::from_path(path);
|
||||||
|
|
||||||
spawn!(glib::clone!(@weak self as obj, @strong file => async move {
|
sliding_view.clear();
|
||||||
if let Err(err) = model.load_directory().await {
|
sliding_view.append(&page);
|
||||||
log::warn!("Failed to load directory: {:?}", err);
|
|
||||||
|
// 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()))
|
obj.activate_action("win.show-toast", Some(&(err.to_string(), 1).to_variant()))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let index = model
|
obj.update_sliding_view(&path);
|
||||||
.index_of(&file)
|
|
||||||
.context("File not found in model.")
|
|
||||||
.unwrap();
|
|
||||||
log::debug!("Currently at file {index} in the directory");
|
|
||||||
|
|
||||||
obj.fill_carousel(&model, index);
|
|
||||||
}));
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Move forward or backwards
|
||||||
pub fn navigate(&self, direction: adw::NavigationDirection) {
|
pub fn navigate(&self, direction: adw::NavigationDirection) {
|
||||||
let carousel = &self.imp().carousel;
|
if let Some(current_path) = self.current_path() {
|
||||||
let pos = carousel.position().round() as u32;
|
let new_path = match direction {
|
||||||
match direction {
|
adw::NavigationDirection::Forward => self.model().after(¤t_path),
|
||||||
adw::NavigationDirection::Forward => {
|
adw::NavigationDirection::Back => self.model().before(¤t_path),
|
||||||
if pos < carousel.n_pages() - 1 {
|
|
||||||
carousel.scroll_to(&carousel.nth_page(pos + 1), true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
adw::NavigationDirection::Back => {
|
|
||||||
if pos > 0 {
|
|
||||||
carousel.scroll_to(&carousel.nth_page(pos - 1), true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => unimplemented!("Navigation direction should only be back or forward."),
|
_ => unimplemented!("Navigation direction should only be back or forward."),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if let Some(new_path) = new_path {
|
||||||
|
self.scroll_sliding_view(&new_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn navigate_to_file(&self, file: &gio::File) {
|
/// Used for drag and drop
|
||||||
let imp = self.imp();
|
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 {
|
if Some(new_path.to_path_buf()) == self.current_path() {
|
||||||
log::warn!("Could not navigate to file {:?}", file.path());
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(new_index) = self.model().index_of(new_path) else {
|
||||||
|
log::warn!("Could not navigate to file {new_path:?}");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let carousel = imp.carousel.get();
|
let current_index = self
|
||||||
let current_index = imp.current_model_index.get();
|
.current_path()
|
||||||
|
.and_then(|x| self.model().index_of(&x))
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
if new_index == current_index {
|
let page = if let Some(page) = sliding_view.get(new_path) {
|
||||||
|
page
|
||||||
|
} else {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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");
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
self.sliding_view().scroll_to(¤t_page);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sliding_view(&self) -> LpSlidingView {
|
||||||
|
self.imp().sliding_view.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn model(&self) -> LpFileModel {
|
||||||
|
self.imp().model.borrow().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let page = LpImagePage::from_file(file);
|
self.update_sliding_view(¤t_path);
|
||||||
|
|
||||||
if new_index > current_index {
|
|
||||||
carousel.append(&page);
|
|
||||||
} else {
|
|
||||||
carousel.prepend(&page);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set a flag and scroll to the page. Our `page-changed` signal handler
|
/// Handle current image being moved or deleted
|
||||||
// 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
|
/// This is handled separately since we want to delete animations and
|
||||||
// new page.
|
/// want to still show the same image if renamed.
|
||||||
log::debug!("Scrolling to page for {new_index}");
|
fn current_image_path_changed(&self) {
|
||||||
imp.scrolling.set(true);
|
if let Some(image) = self.current_page().map(|x| x.image()) {
|
||||||
carousel.scroll_to(&page, true);
|
if image.is_deleted() {
|
||||||
|
self.sliding_view().scroll_to_neighbor();
|
||||||
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();
|
|
||||||
|
|
||||||
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(current_path) = self.current_path() {
|
||||||
if let Some(ref file) = index.checked_sub(i).and_then(|i| model.file(i)) {
|
if self.model().contains(¤t_path) {
|
||||||
carousel.prepend(&LpImagePage::from_file(file))
|
if !self.imp().preserve_content.get() {
|
||||||
|
self.update_sliding_view(¤t_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log::debug!("Carousel filled, current file at index {index}");
|
pub fn current_page(&self) -> Option<LpImagePage> {
|
||||||
imp.current_model_index.set(index);
|
self.imp().sliding_view.current_page()
|
||||||
|
|
||||||
self.notify("is-previous-available");
|
|
||||||
self.notify("is-next-available");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear the carousel, optionally preserving the current position
|
pub fn current_path(&self) -> Option<PathBuf> {
|
||||||
// as a point to refill from
|
self.imp().sliding_view.current_page().map(|x| x.path())
|
||||||
fn clear_carousel(&self, preserve_current_page: bool) {
|
|
||||||
let carousel = self.imp().carousel.get();
|
|
||||||
|
|
||||||
if preserve_current_page {
|
|
||||||
// Remove everything before the current page
|
|
||||||
for _ in 0..(carousel.position() as u32) {
|
|
||||||
carousel.remove(&carousel.nth_page(0));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then everything after
|
pub fn current_file(&self) -> Option<gio::File> {
|
||||||
while carousel.n_pages() > 1 {
|
self.imp()
|
||||||
carousel.remove(&carousel.nth_page(carousel.n_pages() - 1));
|
.sliding_view
|
||||||
}
|
.current_page()
|
||||||
} else {
|
.and_then(|x| x.image().file())
|
||||||
while carousel.n_pages() > 0 {
|
|
||||||
carousel.remove(&carousel.nth_page(0));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.notify("is-previous-available");
|
|
||||||
self.notify("is-next-available");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns `true` if there is an image before the current one
|
/// Returns `true` if there is an image before the current one
|
||||||
pub fn is_previous_available(&self) -> bool {
|
pub fn is_previous_available(&self) -> bool {
|
||||||
let imp = self.imp();
|
if let Some(path) = self.current_path() {
|
||||||
if let Some(model) = imp.model.borrow().as_ref() {
|
self.model().index_of(&path) != Some(0)
|
||||||
imp.current_model_index
|
|
||||||
.get()
|
|
||||||
.checked_sub(1)
|
|
||||||
.and_then(|i| model.item(i))
|
|
||||||
.is_some()
|
|
||||||
} else {
|
} else {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
@ -412,93 +434,39 @@ impl LpImageView {
|
||||||
|
|
||||||
/// Returns `true` if there is an image after the current one
|
/// Returns `true` if there is an image after the current one
|
||||||
pub fn is_next_available(&self) -> bool {
|
pub fn is_next_available(&self) -> bool {
|
||||||
let imp = self.imp();
|
if let Some(path) = self.current_path() {
|
||||||
if let Some(model) = imp.model.borrow().as_ref() {
|
let model = self.model();
|
||||||
model.item(imp.current_model_index.get() + 1).is_some()
|
model.index_of(&path) != Some(model.n_files().saturating_sub(1))
|
||||||
} else {
|
} else {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[template_callback]
|
#[template_callback]
|
||||||
fn page_changed_cb(&self, index: u32, carousel: &adw::Carousel) {
|
fn page_changed_cb(&self) {
|
||||||
let imp = self.imp();
|
self.notify("current-page");
|
||||||
|
|
||||||
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();
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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-previous-available");
|
||||||
self.notify("is-next-available");
|
self.notify("is-next-available");
|
||||||
|
|
||||||
|
let Some(new_page) = self.current_page() else {
|
||||||
|
log::debug!("Page changed but no current page");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if !self.imp().preserve_content.get() {
|
||||||
|
self.update_sliding_view(&new_page.path());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[template_callback]
|
#[template_callback]
|
||||||
|
|
@ -535,7 +503,10 @@ impl LpImageView {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn set_background(&self) -> anyhow::Result<()> {
|
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 native = self.native().expect("View should have a GtkNative");
|
||||||
let id = WindowIdentifier::from_native(&native).await;
|
let id = WindowIdentifier::from_native(&native).await;
|
||||||
|
|
||||||
|
|
@ -543,7 +514,7 @@ impl LpImageView {
|
||||||
.set_on(wallpaper::SetOn::Background)
|
.set_on(wallpaper::SetOn::Background)
|
||||||
.show_preview(true)
|
.show_preview(true)
|
||||||
.identifier(id)
|
.identifier(id)
|
||||||
.build_uri(&background)
|
.build_file(&background)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
// We use `1` here because we can't pass enums directly as GVariants,
|
// 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<()> {
|
pub fn print(&self) -> anyhow::Result<()> {
|
||||||
let operation = gtk::PrintOperation::new();
|
let operation = gtk::PrintOperation::new();
|
||||||
let path = self
|
let path = self.current_path().context("No file")?;
|
||||||
.active_file()
|
|
||||||
.context("No file")?
|
|
||||||
.peek_path()
|
|
||||||
.context("No path")?;
|
|
||||||
let pb = gdk_pixbuf::Pixbuf::from_file(path)?;
|
let pb = gdk_pixbuf::Pixbuf::from_file(path)?;
|
||||||
|
|
||||||
let setup = gtk::PageSetup::default();
|
let setup = gtk::PageSetup::default();
|
||||||
|
|
@ -619,30 +586,4 @@ impl LpImageView {
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: check how often we actually want to use this
|
|
||||||
pub fn current_page(&self) -> Option<LpImagePage> {
|
|
||||||
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<LpImagePage> {
|
|
||||||
self.imp().current_page_strict.borrow().clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn uri(&self) -> Option<url::Url> {
|
|
||||||
self.active_file()
|
|
||||||
.and_then(|f| url::Url::parse(&f.uri()).ok())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn active_file(&self) -> Option<gio::File> {
|
|
||||||
let page = self.current_page()?;
|
|
||||||
page.file()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,10 @@ mod image;
|
||||||
mod image_page;
|
mod image_page;
|
||||||
mod image_view;
|
mod image_view;
|
||||||
mod properties_view;
|
mod properties_view;
|
||||||
|
mod sliding_view;
|
||||||
|
|
||||||
pub use image::LpImage;
|
pub use image::LpImage;
|
||||||
pub use image_page::LpImagePage;
|
pub use image_page::LpImagePage;
|
||||||
pub use image_view::LpImageView;
|
pub use image_view::LpImageView;
|
||||||
pub use properties_view::LpPropertiesView;
|
pub use properties_view::LpPropertiesView;
|
||||||
|
pub use sliding_view::LpSlidingView;
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ mod imp {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[derive(Debug, Default, CompositeTemplate)]
|
#[derive(Debug, Default, CompositeTemplate)]
|
||||||
#[template(resource = "/org/gnome/Loupe/gtk/properties_view.ui")]
|
#[template(file = "../../data/gtk/properties_view.ui")]
|
||||||
pub struct LpPropertiesView {
|
pub struct LpPropertiesView {
|
||||||
pub file: RefCell<Option<glib::WeakRef<gio::File>>>,
|
pub file: RefCell<Option<glib::WeakRef<gio::File>>>,
|
||||||
pub image: RefCell<Option<glib::WeakRef<LpImage>>>,
|
pub image: RefCell<Option<glib::WeakRef<LpImage>>>,
|
||||||
|
|
@ -130,8 +130,7 @@ mod imp {
|
||||||
let prop_name = pspec.name();
|
let prop_name = pspec.name();
|
||||||
|
|
||||||
match prop_name {
|
match prop_name {
|
||||||
"file" => obj.set_file(&value.get().ok()),
|
"image" => obj.set_image(value.get().ok().as_ref()),
|
||||||
"image" => obj.set_image(&value.get().ok()),
|
|
||||||
_ => unimplemented!(),
|
_ => unimplemented!(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -159,6 +158,16 @@ mod imp {
|
||||||
obj.notify("metadata");
|
obj.notify("metadata");
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// watch changes to image metadata
|
||||||
|
obj.property_expression("image")
|
||||||
|
.chain_property::<LpImage>("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]
|
#[gtk::template_callbacks]
|
||||||
impl LpPropertiesView {
|
impl LpPropertiesView {
|
||||||
fn set_file(&self, file: &Option<gio::File>) {
|
fn set_file(&self, file: Option<&gio::File>) {
|
||||||
let imp = self.imp();
|
let imp = self.imp();
|
||||||
|
|
||||||
self.action_set_enabled("properties.open-folder", file.is_some());
|
self.action_set_enabled("properties.open-folder", file.is_some());
|
||||||
|
|
@ -196,10 +205,11 @@ impl LpPropertiesView {
|
||||||
}
|
}
|
||||||
|
|
||||||
self.build_file_info(file);
|
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<gio::File> {
|
fn file(&self) -> Option<gio::File> {
|
||||||
|
|
@ -207,7 +217,7 @@ impl LpPropertiesView {
|
||||||
imp.file.borrow().as_ref().and_then(|w| w.upgrade())
|
imp.file.borrow().as_ref().and_then(|w| w.upgrade())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_image(&self, image: &Option<LpImage>) {
|
fn set_image(&self, image: Option<&LpImage>) {
|
||||||
let imp = self.imp();
|
let imp = self.imp();
|
||||||
|
|
||||||
imp.image.replace(image.as_ref().map(|x| x.downgrade()));
|
imp.image.replace(image.as_ref().map(|x| x.downgrade()));
|
||||||
|
|
|
||||||
332
src/widgets/sliding_view.rs
Normal file
332
src/widgets/sliding_view.rs
Normal file
|
|
@ -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<Vec<LpImagePage>>,
|
||||||
|
/// Page that is currently shown or the animation is animating towards
|
||||||
|
pub(super) current_page: RefCell<Option<LpImagePage>>,
|
||||||
|
/// Animatable position, 0.0 first image, 1.0 second image etc
|
||||||
|
pub(super) position: Cell<f64>,
|
||||||
|
/// Move position to not break animations when pages are removed/added
|
||||||
|
pub(super) position_shift: Cell<f64>,
|
||||||
|
/// The animation used to animate image changes
|
||||||
|
pub(super) scroll_animation: OnceCell<adw::TimedAnimation>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<Vec<glib::ParamSpec>> = Lazy::new(|| {
|
||||||
|
vec![
|
||||||
|
glib::ParamSpecObject::builder::<LpImagePage>("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<Vec<Signal>> =
|
||||||
|
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<imp::LpSlidingView>)
|
||||||
|
@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<PathBuf, LpImagePage> {
|
||||||
|
self.imp()
|
||||||
|
.pages
|
||||||
|
.borrow()
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.map(|x| (x.path(), x))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(&self, path: &Path) -> Option<LpImagePage> {
|
||||||
|
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<LpImagePage> {
|
||||||
|
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<LpImagePage> {
|
||||||
|
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<usize> {
|
||||||
|
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<usize>) {
|
||||||
|
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<LpImagePage> {
|
||||||
|
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<usize> {
|
||||||
|
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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -26,6 +26,7 @@ use gtk::prelude::*;
|
||||||
use gtk::CompositeTemplate;
|
use gtk::CompositeTemplate;
|
||||||
|
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
use crate::config;
|
use crate::config;
|
||||||
use crate::util;
|
use crate::util;
|
||||||
|
|
@ -49,7 +50,7 @@ mod imp {
|
||||||
// we'd need to have a `new()` function in the
|
// we'd need to have a `new()` function in the
|
||||||
// `impl ObjectSubclass for $TYPE` section.
|
// `impl ObjectSubclass for $TYPE` section.
|
||||||
#[derive(Default, Debug, CompositeTemplate)]
|
#[derive(Default, Debug, CompositeTemplate)]
|
||||||
#[template(resource = "/org/gnome/Loupe/gtk/window.ui")]
|
#[template(file = "../data/gtk/window.ui")]
|
||||||
pub struct LpWindow {
|
pub struct LpWindow {
|
||||||
// Template children are used with the
|
// Template children are used with the
|
||||||
// TemplateChild<T> wrapper, where T is the
|
// TemplateChild<T> wrapper, where T is the
|
||||||
|
|
@ -182,7 +183,7 @@ mod imp {
|
||||||
|
|
||||||
obj.set_actions_enabled(false);
|
obj.set_actions_enabled(false);
|
||||||
self.image_view
|
self.image_view
|
||||||
.property_expression("current-page-strict")
|
.property_expression("current-page")
|
||||||
.chain_property::<LpImagePage>("image")
|
.chain_property::<LpImagePage>("image")
|
||||||
.chain_property::<LpImage>("best-fit")
|
.chain_property::<LpImage>("best-fit")
|
||||||
.watch(
|
.watch(
|
||||||
|
|
@ -209,7 +210,7 @@ mod imp {
|
||||||
|
|
||||||
// disable zoom-in if at maximum zoom level
|
// disable zoom-in if at maximum zoom level
|
||||||
self.image_view
|
self.image_view
|
||||||
.property_expression("current-page-strict")
|
.property_expression("current-page")
|
||||||
.chain_property::<LpImagePage>("image")
|
.chain_property::<LpImagePage>("image")
|
||||||
.chain_property::<LpImage>("is-max-zoom")
|
.chain_property::<LpImage>("is-max-zoom")
|
||||||
.watch(
|
.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
|
// action win.previous status
|
||||||
self.image_view.connect_notify_local(
|
self.image_view.connect_notify_local(
|
||||||
Some("is-previous-available"),
|
Some("is-previous-available"),
|
||||||
|
|
@ -295,7 +303,7 @@ mod imp {
|
||||||
.map(|t| t.to_string())
|
.map(|t| t.to_string())
|
||||||
.filter(|t| t.starts_with("image/"))
|
.filter(|t| t.starts_with("image/"))
|
||||||
.is_some() {
|
.is_some() {
|
||||||
obj.set_image_from_file(&file, false);
|
obj.set_image_from_path(&file.path().unwrap());
|
||||||
} else {
|
} else {
|
||||||
obj.show_toast(
|
obj.show_toast(
|
||||||
i18n_f("“{}” is not a valid image.", &[&info.display_name()]),
|
i18n_f("“{}” is not a valid image.", &[&info.display_name()]),
|
||||||
|
|
@ -374,14 +382,17 @@ impl LpWindow {
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
if let Ok(file) = chooser.open_future(Some(self)).await {
|
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) {
|
async fn open_with(&self) {
|
||||||
let imp = self.imp();
|
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));
|
let launcher = gtk::FileLauncher::new(Some(file));
|
||||||
if let Err(e) = launcher.launch_future(Some(self)).await {
|
if let Err(e) = launcher.launch_future(Some(self)).await {
|
||||||
if !e.matches(gtk::DialogError::Dismissed) {
|
if !e.matches(gtk::DialogError::Dismissed) {
|
||||||
|
|
@ -432,22 +443,12 @@ impl LpWindow {
|
||||||
imp.toast_overlay.add_toast(&toast);
|
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();
|
let imp = self.imp();
|
||||||
|
|
||||||
log::debug!("Loading file: {}", file.uri().to_string());
|
log::debug!("Loading file: {path:?}");
|
||||||
match imp.image_view.set_image_from_file(file) {
|
imp.image_view.set_image_from_path(path);
|
||||||
Ok((/*width, height*/)) => {
|
self.set_actions_enabled(true);
|
||||||
// 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()),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_actions_enabled(&self, enabled: bool) {
|
pub fn set_actions_enabled(&self, enabled: bool) {
|
||||||
|
|
@ -462,6 +463,18 @@ impl LpWindow {
|
||||||
self.action_set_enabled("win.toggle-properties", enabled);
|
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) {
|
pub fn image_size_ready(&self) {
|
||||||
// if visible for whatever reason, don't do any resize
|
// if visible for whatever reason, don't do any resize
|
||||||
if self.is_visible() {
|
if self.is_visible() {
|
||||||
|
|
@ -472,7 +485,7 @@ impl LpWindow {
|
||||||
let image = self
|
let image = self
|
||||||
.imp()
|
.imp()
|
||||||
.image_view
|
.image_view
|
||||||
.current_page_strict()
|
.current_page()
|
||||||
.map(|page| page.image());
|
.map(|page| page.image());
|
||||||
|
|
||||||
if let Some(image) = image {
|
if let Some(image) = image {
|
||||||
|
|
@ -492,7 +505,7 @@ impl LpWindow {
|
||||||
return;
|
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 let Some(page) = current_page {
|
||||||
if page.error().is_some() {
|
if page.error().is_some() {
|
||||||
|
|
@ -555,10 +568,18 @@ impl LpWindow {
|
||||||
// have multiple `match` or `if let` branches, and without needing
|
// have multiple `match` or `if let` branches, and without needing
|
||||||
// to unwrap.
|
// to unwrap.
|
||||||
#[template_callback]
|
#[template_callback]
|
||||||
fn window_title(&self, file: Option<gio::File>) -> String {
|
fn window_title(&self, path: glib::variant::Variant) -> String {
|
||||||
file.and_then(|f| util::get_file_display_name(&f)) // If the file exists, get display name
|
// 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
|
.unwrap_or_else(|| i18n("Loupe")) // Return that or the default if there's nothing
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// We also have a closure that returns `adw::FlapFoldPolicy`.
|
// We also have a closure that returns `adw::FlapFoldPolicy`.
|
||||||
// if we aren't fullscreened, or if the properties are revealed,
|
// if we aren't fullscreened, or if the properties are revealed,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue