Loupe/src/file_model.rs

504 lines
16 KiB
Rust
Raw Normal View History

// Copyright (c) 2022-2025 Sophie Herold
2024-09-27 02:20:32 +02:00
// Copyright (c) 2022 Christopher Davis
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: GPL-3.0-or-later
use std::cell::{OnceCell, RefCell};
use std::collections::{HashMap, VecDeque};
2024-09-27 02:20:32 +02:00
use std::sync::LazyLock;
use std::time::Instant;
use anyhow::Context;
use gio::prelude::*;
use gio::subclass::prelude::*;
use glib::GString;
2025-12-04 06:14:49 +01:00
use glib::subclass::Signal;
use indexmap::IndexMap;
use crate::deps::*;
use crate::util;
use crate::util::gettext::*;
#[derive(Debug, Clone)]
struct Entry {
/// Determines the sort order
sort: VecDeque<glib::FilenameCollationKey>,
file: gio::File,
}
impl Entry {
async fn new(
file: gio::File,
info: Option<gio::FileInfo>,
collation_key_cache: &mut HashMap<glib::GString, glib::FilenameCollationKey>,
) -> Self {
let mut sort = VecDeque::new();
let mut frac_file = file.clone();
// Use already available info for the filename as performance optimization
let mut available_file_info = info.map(Ok);
// Determine sort order for all path/uri segments one by one
loop {
let uri = frac_file.uri();
let key = if let Some(cached_key) = collation_key_cache.get(&uri) {
cached_key.clone()
} else {
let file_info = if let Some(file_info) = available_file_info {
file_info
} else {
frac_file
.query_info_future(
gio::FILE_ATTRIBUTE_STANDARD_DISPLAY_NAME,
gio::FileQueryInfoFlags::NONE,
glib::Priority::LOW,
)
.await
};
match file_info {
Ok(parent) => {
let key = glib::FilenameCollationKey::from(parent.display_name());
// Using this cache since each file info query can take noticable time (1 ms
// on local SMB). Only cache keys resulting from queries in this function
// since these will be path segments that can be (and are likely to) be
// repeated.
collation_key_cache.insert(uri, key.clone());
key
}
Err(err) => {
log::error!("Failed to obtain information for sorting '{}': {err}", uri);
break;
}
}
};
sort.push_front(key);
if let Some(parent) = frac_file.parent() {
frac_file = parent;
available_file_info = None;
} else {
break;
}
}
Self { sort, file }
}
}
mod imp {
use super::*;
#[derive(Debug, Default)]
pub struct LpFileModel {
2023-11-29 21:15:36 +01:00
/// Use and IndexMap such that we can put the elements into an arbitrary
/// order while still having fast hash access via file URI.
pub(super) files: RefCell<IndexMap<GString, Entry>>,
pub(super) directory: OnceCell<gio::File>,
/// Track file changes.
pub(super) monitor: OnceCell<gio::FileMonitor>,
}
#[glib::object_subclass]
impl ObjectSubclass for LpFileModel {
const NAME: &'static str = "LpFileModel";
type Type = super::LpFileModel;
}
impl ObjectImpl for LpFileModel {
fn signals() -> &'static [Signal] {
static SIGNALS: LazyLock<Vec<Signal>> = LazyLock::new(|| {
2025-12-04 06:14:49 +01:00
vec![
Signal::builder("changed")
.param_types([glib::Type::VARIANT])
.build(),
]
});
SIGNALS.as_ref()
}
}
}
glib::wrapper! {
pub struct LpFileModel(ObjectSubclass<imp::LpFileModel>);
}
impl Default for LpFileModel {
fn default() -> Self {
2023-02-12 02:07:42 +01:00
glib::Object::new()
}
}
impl LpFileModel {
/// Create with a single element
pub async fn from_file(file: gio::File) -> Self {
Self::from_files(vec![file]).await
}
pub async fn from_files(files: Vec<gio::File>) -> Self {
let model = Self::default();
let mut collation_key_cache = Default::default();
let mut stored_files: IndexMap<GString, Entry> = IndexMap::new();
for file in files {
stored_files.insert(
file.uri(),
Entry::new(file, None, &mut collation_key_cache).await,
);
}
*model.imp().files.borrow_mut() = stored_files;
model
}
/// Load all file from the given directory
///
/// This can be used if there are already files present
pub async fn load_directory(&self, directory: gio::File) -> anyhow::Result<()> {
let instant = Instant::now();
self.imp().directory.set(directory.clone()).unwrap();
let monitor =
directory.monitor_directory(gio::FileMonitorFlags::WATCH_MOVES, gio::Cancellable::NONE);
match monitor {
Ok(monitor) => {
2024-06-18 00:37:38 +02:00
monitor.connect_changed(glib::clone!(
#[weak(rename_to = obj)]
self,
move |_monitor, file_a, file_b, event| {
obj.file_monitor_cb(event, file_a, file_b);
2024-06-18 00:37:38 +02:00
}
));
self.imp().monitor.set(monitor).unwrap();
}
Err(err) => {
log::info!("Cannot monitor directory: {err}");
}
}
let mut new_files = IndexMap::new();
let enumerator = directory
.enumerate_children_future(
&format!(
"{},{},{},{}",
gio::FILE_ATTRIBUTE_STANDARD_NAME,
gio::FILE_ATTRIBUTE_STANDARD_FAST_CONTENT_TYPE,
gio::FILE_ATTRIBUTE_STANDARD_IS_HIDDEN,
gio::FILE_ATTRIBUTE_STANDARD_DISPLAY_NAME,
),
gio::FileQueryInfoFlags::NONE,
glib::Priority::LOW,
)
.await
.context(gettext("Could not list other files in directory."))?;
let mut collation_key_cache = Default::default();
loop {
let info = enumerator.next_files_future(1, glib::Priority::LOW).await;
match info {
Err(err) => {
log::warn!("Unreadable entry in directory: {err}");
break;
}
Ok(mut info) => {
if let Some(info) = info.pop() {
// GVfs smb does not provide a CONTENT_TYPE if the content type is
// ambiguous. This happens for png/apng. Since we
// only need to know if something is probably an
// image, we can use the FAST_CONTENT_TYPE in these cases.
let content_type =
info.attribute_string(gio::FILE_ATTRIBUTE_STANDARD_FAST_CONTENT_TYPE);
if let Some(content_type) =
content_type.and_then(|x| gio::content_type_get_mime_type(&x))
{
let hidden =
if info.has_attribute(gio::FILE_ATTRIBUTE_STANDARD_IS_HIDDEN) {
info.is_hidden()
} else {
false
};
// Filter out non-images types. The final decision if images are
// supported/kept will later be done by glycin when starting to load the
// images. Usually by inspecting the magic bytes.
if content_type.starts_with("image/") && !hidden {
let file = directory.child(info.name());
let entry =
Entry::new(file, Some(info), &mut collation_key_cache).await;
new_files.insert(entry.file.uri(), entry);
}
}
} else {
break;
}
}
}
}
{
2023-11-29 21:15:36 +01:00
// Here we use a nested scope so that the mutable borrow only lasts as long as
// we need it
let mut files = self.imp().files.borrow_mut();
for (uri, file) in files.iter() {
new_files.insert(uri.clone(), file.clone());
}
*files = new_files;
// Then sort by name.
Self::sort(&mut files);
}
log::debug!(
"Completed loading list of files of '{}' after {:#?}",
directory.uri(),
instant.elapsed(),
);
Ok(())
}
pub fn directory(&self) -> Option<gio::File> {
self.imp().directory.get().cloned()
}
widgets: Rework image_view with AdwCarousel, add ImagePage Previously we loaded images synchronously and manually changed `LpImage`. This could cause freezes when loading large images and SVGs. We also had no proper feedback for when a corrupted or unsupported image was loaded. This commit introduces a major rework to how we handle images and the gallery. It introduces a swipeable gallery that loads files asynchronously via a new widget: LpImagePage. LpImagePage owns the file and information related to the file, and on creation it spawns a future that either loads an error page when it can't load something (file deleted, corrupted, or unsupported) or the actual image via LpImage. LpImagePage now holds the right-click popover and the associated gestures. The gestures needed to be moved to LpImagePage in order to rework LpImageView's overlay - instead of having an overlay child that's a box with a few buttons, we now have the buttons as individual overlay children. This could cause them to overlap each other at certain sizes, but it's required in order to allow input to reach the carousel below. Right now this comes with a few regressions: * Occasionally input gets blocked across the applications. It's hard to tell if this is caused by swipes or right clicking. This input issue can cause a crash - no Rust backtrace, so a debugger is needed. * The "current" page doesn't seem to be properly loaded by the time `set_image_from_file ()` finishes, so we need to rework resizing from image dimensions * We need to re-implement loading different directories and files within directories There also seems to be an issue on touchscreens, swiping between images on a touchscreen causes responsiveness issues not present when swiping on a touchpad. My guess is that it's related to the long press gesture.
2022-01-19 23:11:38 -08:00
pub fn index_of(&self, file: &gio::File) -> Option<usize> {
self.imp().files.borrow().get_index_of(&file.uri())
}
pub fn n_files(&self) -> usize {
self.imp().files.borrow().len()
}
pub fn contains_file(&self, file: &gio::File) -> bool {
self.imp().files.borrow().contains_key(&file.uri())
}
pub fn before(&self, file: &gio::File) -> Option<gio::File> {
let index = self.index_of(file)?;
self.imp()
.files
.borrow()
.get_index(index.checked_sub(1)?)
.map(|x| &x.1.file)
.cloned()
}
pub fn after(&self, file: &gio::File) -> Option<gio::File> {
let index = self.index_of(file)?;
self.imp()
.files
.borrow()
.get_index(index.checked_add(1)?)
.map(|x| &x.1.file)
.cloned()
}
/// Returns `n` elements before and after given file
pub fn files_around(&self, file: &gio::File, n: usize) -> IndexMap<GString, gio::File> {
let Some(index) = self.index_of(file) else {
log::error!("URI not in model: {}", file.uri());
return IndexMap::new();
};
2024-12-20 23:02:18 +01:00
let reduce = n.saturating_sub(index);
self.imp()
.files
.borrow()
.iter()
.skip(index.saturating_sub(n))
.take(2 * n + 1 - reduce)
.map(|(k, v)| (k.clone(), v.file.clone()))
.collect()
}
/// Return first file
pub fn first(&self) -> Option<gio::File> {
self.imp()
.files
.borrow()
.first()
.map(|x| &x.1.file)
.cloned()
}
/// Returns last file
pub fn last(&self) -> Option<gio::File> {
self.imp().files.borrow().last().map(|x| &x.1.file).cloned()
}
pub fn remove(&self, file: &gio::File) -> Option<gio::File> {
self.imp()
.files
.borrow_mut()
.shift_remove(&file.uri())
.map(|x| x.file)
}
/// Currently sorts by name
fn sort(files: &mut IndexMap<GString, Entry>) {
files.sort_by(|_, x, _, y| x.sort.cmp(&y.sort));
}
/// 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 {
2023-07-06 23:48:56 +02:00
let Ok(info) =
util::query_attributes(file, vec![gio::FILE_ATTRIBUTE_STANDARD_CONTENT_TYPE])
else {
log::warn!("Could not query file info: {}", file.uri());
return false;
};
Self::is_image(&info)
}
/// Signal that notifies about added/removed files
pub fn connect_changed(&self, f: impl Fn(&FileEvent) + 'static) {
self.connect_local("changed", false, move |args| {
if let Some(file_event) = args
.get(1)
.and_then(|x| x.get().ok())
.and_then(|x| FileEvent::from_variant(&x))
{
f(&file_event);
} else {
log::error!("Failed to read arguments from 'changed' signal.");
}
None
});
}
pub fn emmit_changed(&self, file_event: &FileEvent) {
self.emit_by_name::<()>("changed", &[&file_event.to_variant()]);
}
/// Insert a file
async fn insert(&self, file: gio::File) -> bool {
let obj = self.clone();
let entry = Entry::new(file.clone(), None, &mut Default::default()).await;
let mut files = obj.imp().files.borrow_mut();
let changed = files.insert(file.uri(), entry).is_none();
if changed {
Self::sort(&mut files);
drop(files);
}
changed
}
fn file_monitor_cb(
&self,
event: gio::FileMonitorEvent,
file_a: &gio::File,
file_b: Option<&gio::File>,
) {
let uri_a = file_a.uri();
match event {
gio::FileMonitorEvent::Created
| gio::FileMonitorEvent::MovedIn
| gio::FileMonitorEvent::ChangesDoneHint
if Self::is_image_file(file_a) =>
{
// ^^^^
// Changing file content could theoretically make it an image
// by adding a magic byte
log::debug!("File added: {uri_a}");
glib::spawn_future_local(glib::clone!(
#[strong(rename_to=obj)]
self,
#[strong]
file_a,
async move {
let changed = obj.insert(file_a.clone()).await;
if changed {
obj.emmit_changed(&FileEvent::New(uri_a.to_string()));
}
}
));
}
gio::FileMonitorEvent::Deleted
| gio::FileMonitorEvent::MovedOut
| gio::FileMonitorEvent::Unmounted => {
log::debug!("File removed: {}", file_a.uri());
let removed = self
.imp()
.files
.borrow_mut()
.shift_remove(&file_a.uri())
.is_some();
if removed {
self.emmit_changed(&FileEvent::Removed(file_a.uri().to_string()));
}
}
gio::FileMonitorEvent::Renamed => {
if let Some(file_b) = file_b {
{
let uri_b = file_b.uri();
log::debug!("File moved from '{uri_a}' to '{uri_b}'");
let mut changed =
self.imp().files.borrow_mut().shift_remove(&uri_a).is_some();
if Self::is_image_file(file_b) {
glib::spawn_future_local(glib::clone!(
#[strong(rename_to=obj)]
self,
#[strong]
file_b,
async move {
changed |= obj.insert(file_b.clone()).await;
if changed {
obj.emmit_changed(&FileEvent::Moved(
uri_a.to_string(),
uri_b.to_string(),
));
}
}
));
}
}
}
}
_ => {}
}
widgets: Rework image_view with AdwCarousel, add ImagePage Previously we loaded images synchronously and manually changed `LpImage`. This could cause freezes when loading large images and SVGs. We also had no proper feedback for when a corrupted or unsupported image was loaded. This commit introduces a major rework to how we handle images and the gallery. It introduces a swipeable gallery that loads files asynchronously via a new widget: LpImagePage. LpImagePage owns the file and information related to the file, and on creation it spawns a future that either loads an error page when it can't load something (file deleted, corrupted, or unsupported) or the actual image via LpImage. LpImagePage now holds the right-click popover and the associated gestures. The gestures needed to be moved to LpImagePage in order to rework LpImageView's overlay - instead of having an overlay child that's a box with a few buttons, we now have the buttons as individual overlay children. This could cause them to overlap each other at certain sizes, but it's required in order to allow input to reach the carousel below. Right now this comes with a few regressions: * Occasionally input gets blocked across the applications. It's hard to tell if this is caused by swipes or right clicking. This input issue can cause a crash - no Rust backtrace, so a debugger is needed. * The "current" page doesn't seem to be properly loaded by the time `set_image_from_file ()` finishes, so we need to rework resizing from image dimensions * We need to re-implement loading different directories and files within directories There also seems to be an issue on touchscreens, swiping between images on a touchscreen causes responsiveness issues not present when swiping on a touchpad. My guess is that it's related to the long press gesture.
2022-01-19 23:11:38 -08:00
}
}
#[derive(Debug, glib::Variant)]
pub enum FileEvent {
New(String),
Removed(String),
Moved(String, String),
}