// Copyright (c) 2022-2024 Sophie Herold
// Copyright (c) 2022-2023 Christopher Davis
// Copyright (c) 2023 Julian Hofer
// Copyright (c) 2023 Gage Berz
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see .
//
// SPDX-License-Identifier: GPL-3.0-or-later
use std::cell::{OnceCell, RefCell};
use anyhow::Context;
use gio::prelude::*;
use gio::subclass::prelude::*;
use glib::subclass::Signal;
use glib::GString;
use indexmap::IndexMap;
use once_cell::sync::Lazy;
use crate::deps::*;
use crate::util;
use crate::util::gettext::*;
mod imp {
use super::*;
#[derive(Debug, Default)]
pub struct LpFileModel {
/// 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>,
pub(super) directory: OnceCell,
/// Track file changes.
pub(super) monitor: OnceCell,
}
#[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: Lazy> =
Lazy::new(|| vec![Signal::builder("changed").build()]);
SIGNALS.as_ref()
}
}
}
glib::wrapper! {
pub struct LpFileModel(ObjectSubclass);
}
impl Default for LpFileModel {
fn default() -> Self {
glib::Object::new()
}
}
impl LpFileModel {
/// Create with a single element
pub fn from_file(file: gio::File) -> Self {
Self::from_files(vec![file])
}
pub fn from_files(files: Vec) -> Self {
let model = Self::default();
{
let mut vec = model.imp().files.borrow_mut();
for file in files {
vec.insert(file.uri(), file);
}
}
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<()> {
self.imp().directory.set(directory.clone()).unwrap();
let monitor =
directory.monitor_directory(gio::FileMonitorFlags::WATCH_MOVES, gio::Cancellable::NONE);
match monitor {
Ok(monitor) => {
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();
}
Err(err) => {
log::info!("Cannot monitor directory: {err}");
}
}
let new_files_result = gio::spawn_blocking(move || {
let mut files = IndexMap::new();
let enumerator = directory
.enumerate_children(
&format!(
"{},{},{},{}",
gio::FILE_ATTRIBUTE_STANDARD_NAME,
gio::FILE_ATTRIBUTE_STANDARD_CONTENT_TYPE,
gio::FILE_ATTRIBUTE_STANDARD_FAST_CONTENT_TYPE,
gio::FILE_ATTRIBUTE_STANDARD_IS_HIDDEN,
),
gio::FileQueryInfoFlags::NONE,
gio::Cancellable::NONE,
)
.context(gettext("Could not list other files in directory."))?;
enumerator.for_each(|info| {
if let Ok(info) = info {
// 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 =
if info.has_attribute(gio::FILE_ATTRIBUTE_STANDARD_CONTENT_TYPE) {
info.content_type()
} else {
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))
{
// 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/") && !info.is_hidden() {
let file = directory.child(info.name());
files.insert(file.uri(), file);
}
}
}
});
Ok::<_, anyhow::Error>(files)
})
.await;
let Ok(new_files) = new_files_result else {
log::debug!("Thread listing directory canceled.");
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
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);
}
Ok(())
}
pub fn directory(&self) -> Option {
self.imp().directory.get().cloned()
}
pub fn index_of(&self, file: &gio::File) -> Option {
self.imp().files.borrow().get_index_of(&file.uri())
}
pub fn n_files(&self) -> usize {
self.imp().files.borrow().len()
}
pub fn contains(&self, file: &gio::File) -> bool {
self.imp().files.borrow().contains_key(&file.uri())
}
pub fn before(&self, file: &gio::File) -> Option {
let index = self.index_of(file)?;
self.imp()
.files
.borrow()
.get_index(index.checked_sub(1)?)
.map(|x| x.1)
.cloned()
}
pub fn after(&self, file: &gio::File) -> Option {
let index = self.index_of(file)?;
self.imp()
.files
.borrow()
.get_index(index.checked_add(1)?)
.map(|x| x.1)
.cloned()
}
/// Returns `n` elements before and after given file
pub fn files_around(&self, file: &gio::File, n: usize) -> IndexMap {
let Some(index) = self.index_of(file) else {
log::error!("URI not in model: {}", file.uri());
return IndexMap::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)
.map(|(k, v)| (k.clone(), v.clone()))
.collect()
}
/// Return first file
pub fn first(&self) -> Option {
self.imp().files.borrow().first().map(|x| x.1).cloned()
}
/// Returns last file
pub fn last(&self) -> Option {
self.imp().files.borrow().last().map(|x| x.1).cloned()
}
pub fn remove(&self, file: &gio::File) -> Option {
self.imp().files.borrow_mut().shift_remove(&file.uri())
}
/// Currently sorts by name
fn sort(files: &mut IndexMap) {
files.sort_by(|_, x, _, y| util::compare_by_name(&x.uri(), &y.uri()));
}
/// 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.uri());
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 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(file_a.uri(),file_a.clone()).is_none();
if changed {
Self::sort(&mut files);
}
changed
}
gio::FileMonitorEvent::Deleted | gio::FileMonitorEvent::MovedOut | gio::FileMonitorEvent::Unmounted => {
let mut files = self.imp().files.borrow_mut();
files.shift_remove(&file_a.uri()).is_some()
}
gio::FileMonitorEvent::Renamed => {
if let Some(file_b) = file_b {
let mut changed = false;
{
let mut files = self.imp().files.borrow_mut();
changed |= files.shift_remove(&file_a.uri()).is_some();
if Self::is_image_file(file_b) {
changed |= files.insert(file_b.uri(), file_b.clone()).is_none();
if changed {
Self::sort(&mut files);
}
}
}
changed
} else {
false
}
}
_ => false,
};
if changed {
self.emit_by_name::<()>("changed", &[]);
}
}
}