cosmic-files/src/tab.rs

6512 lines
247 KiB
Rust
Raw Normal View History

2024-01-03 15:27:32 -07:00
use cosmic::{
Apply, Element, cosmic_theme, font,
iced::{
2025-09-03 23:24:38 +02:00
Alignment,
Border,
Color,
ContentFit,
Length,
Point,
Rectangle,
Size,
Subscription,
Vector,
advanced::{
graphics,
text::{self, Paragraph},
},
alignment::{Horizontal, Vertical},
2024-07-12 19:40:46 +02:00
clipboard::dnd::DndAction,
event,
futures::{self, SinkExt},
2024-10-02 14:53:24 -06:00
keyboard::Modifiers,
2024-10-21 13:51:10 -06:00
stream,
//TODO: export in cosmic::widget
2024-02-29 16:25:54 -07:00
widget::{
2024-10-25 13:24:17 +02:00
horizontal_rule, rule,
2024-08-20 13:26:10 -06:00
scrollable::{self, AbsoluteOffset, Viewport},
2024-02-29 16:25:54 -07:00
},
window,
},
iced_core::{mouse::ScrollDelta, widget::tree},
theme,
2024-07-12 19:40:46 +02:00
widget::{
2025-09-03 23:24:38 +02:00
self, DndDestination, DndSource, Id, Space, Widget,
2024-07-12 19:40:46 +02:00
menu::{action::MenuAction, key_bind::KeyBind},
},
2024-01-03 15:27:32 -07:00
};
2024-07-12 19:40:46 +02:00
2025-09-15 15:13:03 +02:00
use chrono::{Datelike, Timelike, Utc};
use i18n_embed::LanguageLoader;
2025-09-15 15:13:03 +02:00
use icu::{
datetime::{
DateTimeFormatter, DateTimeFormatterPreferences, fieldsets,
input::{Date, DateTime, Time},
options::TimePrecision,
},
locale::preferences::extensions::unicode::keywords::HourCycle,
};
use image::ImageDecoder;
use jxl_oxide::integration::JxlDecoder;
2025-09-03 23:24:38 +02:00
use mime_guess::{Mime, mime};
use rustc_hash::FxHashMap;
2024-03-13 23:23:00 -04:00
use serde::{Deserialize, Serialize};
2024-01-03 15:27:32 -07:00
use std::{
2025-06-18 11:22:48 -04:00
borrow::Cow,
cell::{Cell, RefCell},
cmp::{Ordering, Reverse},
2024-01-04 15:56:01 -07:00
collections::HashMap,
2025-02-07 09:11:20 -07:00
error::Error,
2024-09-15 16:00:20 -06:00
fmt::{self, Display},
2024-10-04 11:52:34 +02:00
fs::{self, File, Metadata},
hash::Hash,
2024-10-04 11:52:34 +02:00
io::{BufRead, BufReader},
os::unix::fs::MetadataExt,
2024-09-09 13:27:54 -06:00
path::{Path, PathBuf},
rc::Rc,
sync::{Arc, LazyLock, RwLock, atomic},
time::{Duration, Instant, SystemTime},
2024-01-03 15:27:32 -07:00
};
use tempfile::NamedTempFile;
2024-10-09 18:55:09 -06:00
use tokio::sync::mpsc;
use trash::TrashItemSize;
use walkdir::WalkDir;
2024-01-03 15:27:32 -07:00
use crate::{
FxOrderMap,
app::{Action, PreviewItem, PreviewKind},
2024-07-12 19:40:46 +02:00
clipboard::{ClipboardCopy, ClipboardKind, ClipboardPaste},
2025-09-03 23:24:38 +02:00
config::{DesktopConfig, ICON_SCALE_MAX, ICON_SIZE_GRID, IconSizes, TabConfig, ThumbCfg},
dialog::DialogKind,
2024-07-12 19:40:46 +02:00
fl,
localize::{LANGUAGE_SORTER, LOCALE},
menu, mime_app,
mime_icon::{mime_for_path, mime_icon},
mounter::MOUNTERS,
2024-02-26 12:05:29 -07:00
mouse_area,
operation::{Controller, OperationError},
thumbnail_cacher::{CachedThumbnail, ThumbnailCacher, ThumbnailSize},
thumbnailer::thumbnailer,
};
2024-08-27 07:26:47 -06:00
use uzers::{get_group_by_gid, get_user_by_uid};
pub const DOUBLE_CLICK_DURATION: Duration = Duration::from_millis(500);
pub const HOVER_DURATION: Duration = Duration::from_millis(1600);
//TODO: best limit for search items
2024-10-09 19:01:51 -06:00
const MAX_SEARCH_LATENCY: Duration = Duration::from_millis(20);
const MAX_SEARCH_RESULTS: usize = 200;
2024-10-10 10:05:48 -06:00
//TODO: configurable thumbnail size?
const THUMBNAIL_SIZE: u32 = (ICON_SIZE_GRID as u32) * (ICON_SCALE_MAX as u32);
2025-08-13 12:40:15 -06:00
pub static THUMB_SEMAPHORE: LazyLock<tokio::sync::Semaphore> =
LazyLock::new(|| tokio::sync::Semaphore::const_new(num_cpus::get()));
pub(crate) static SORT_OPTION_FALLBACK: LazyLock<FxHashMap<String, (HeadingOptions, bool)>> =
LazyLock::new(|| {
FxHashMap::from_iter(dirs::download_dir().into_iter().map(|dir| {
(
Location::Path(dir).normalize().to_string(),
(HeadingOptions::Modified, false),
)
}))
});
static MODE_NAMES: LazyLock<Vec<String>> = LazyLock::new(|| {
vec![
// Mode 0
fl!("none"),
// Mode 1
fl!("execute-only"),
// Mode 2
fl!("write-only"),
// Mode 3
fl!("write-execute"),
// Mode 4
fl!("read-only"),
// Mode 5
fl!("read-execute"),
// Mode 6
fl!("read-write"),
// Mode 7
fl!("read-write-execute"),
]
});
static SPECIAL_DIRS: LazyLock<FxHashMap<PathBuf, &'static str>> = LazyLock::new(|| {
let mut special_dirs = FxHashMap::default();
2024-01-24 09:30:06 -05:00
if let Some(dir) = dirs::document_dir() {
special_dirs.insert(dir, "folder-documents");
}
if let Some(dir) = dirs::download_dir() {
special_dirs.insert(dir, "folder-download");
}
if let Some(dir) = dirs::audio_dir() {
special_dirs.insert(dir, "folder-music");
}
if let Some(dir) = dirs::picture_dir() {
special_dirs.insert(dir, "folder-pictures");
}
if let Some(dir) = dirs::public_dir() {
special_dirs.insert(dir, "folder-publicshare");
}
if let Some(dir) = dirs::template_dir() {
special_dirs.insert(dir, "folder-templates");
}
if let Some(dir) = dirs::video_dir() {
special_dirs.insert(dir, "folder-videos");
}
if let Some(dir) = dirs::desktop_dir() {
special_dirs.insert(dir, "user-desktop");
}
if let Some(dir) = dirs::home_dir() {
special_dirs.insert(dir, "user-home");
}
special_dirs
});
2024-02-29 11:25:46 -07:00
fn button_appearance(
theme: &theme::Theme,
selected: bool,
2024-10-16 10:22:25 +11:00
highlighted: bool,
2025-04-28 23:35:27 +10:00
cut: bool,
2024-02-29 15:29:10 -07:00
focused: bool,
2024-02-29 11:25:46 -07:00
accent: bool,
condensed_radius: bool,
2024-08-20 13:26:10 -06:00
desktop: bool,
2024-10-21 13:51:10 -06:00
) -> widget::button::Style {
2024-02-29 11:25:46 -07:00
let cosmic = theme.cosmic();
2024-10-21 13:51:10 -06:00
let mut appearance = widget::button::Style::new();
2024-02-29 11:25:46 -07:00
if selected {
if accent {
appearance.background = Some(Color::from(cosmic.accent_color()).into());
appearance.icon_color = Some(Color::from(cosmic.on_accent_color()));
2025-04-28 23:35:27 +10:00
if cut {
appearance.text_color = Some(Color::from(cosmic.accent.on_disabled));
} else {
appearance.text_color = Some(Color::from(cosmic.on_accent_color()));
}
2024-02-29 11:25:46 -07:00
} else {
appearance.background = Some(Color::from(cosmic.bg_component_color()).into());
}
2024-10-16 10:22:25 +11:00
} else if highlighted {
if accent {
appearance.background = Some(Color::from(cosmic.bg_component_color()).into());
appearance.icon_color = Some(Color::from(cosmic.on_bg_component_color()));
appearance.text_color = Some(Color::from(cosmic.on_bg_component_color()));
2025-04-28 23:35:27 +10:00
if cut {
appearance.text_color = Some(Color::from(cosmic.background.component.on_disabled));
} else {
appearance.text_color = Some(Color::from(cosmic.on_bg_component_color()));
}
2024-10-16 10:22:25 +11:00
} else {
appearance.background = Some(Color::from(cosmic.bg_component_color()).into());
}
2024-08-20 13:26:10 -06:00
} else if desktop {
appearance.background = Some(Color::from(cosmic.bg_color()).into());
appearance.icon_color = Some(Color::from(cosmic.on_bg_color()));
2025-04-28 23:35:27 +10:00
if cut {
appearance.text_color = Some(Color::from(cosmic.background.component.disabled));
} else {
appearance.text_color = Some(Color::from(cosmic.on_bg_color()));
}
} else if cut {
appearance.text_color = Some(Color::from(cosmic.background.component.on_disabled));
2024-02-29 11:25:46 -07:00
}
2024-02-29 15:29:10 -07:00
if focused && accent {
appearance.outline_width = 1.0;
appearance.outline_color = Color::from(cosmic.accent_color());
appearance.border_width = 2.0;
appearance.border_color = Color::TRANSPARENT;
}
if condensed_radius {
appearance.border_radius = cosmic.radius_xs().into();
} else {
appearance.border_radius = cosmic.radius_s().into();
}
2024-02-29 11:25:46 -07:00
appearance
}
2024-08-20 13:26:10 -06:00
fn button_style(
selected: bool,
2024-10-16 10:22:25 +11:00
highlighted: bool,
2025-04-28 23:35:27 +10:00
cut: bool,
2024-08-20 13:26:10 -06:00
accent: bool,
condensed_radius: bool,
desktop: bool,
) -> theme::Button {
2024-02-29 11:25:46 -07:00
//TODO: move to libcosmic?
2024-01-05 09:36:16 -07:00
theme::Button::Custom {
active: Box::new(move |focused, theme| {
2024-10-16 10:22:25 +11:00
button_appearance(
theme,
selected,
highlighted,
2025-04-28 23:35:27 +10:00
cut,
2024-10-16 10:22:25 +11:00
focused,
accent,
condensed_radius,
desktop,
)
}),
disabled: Box::new(move |theme| {
2024-10-16 10:22:25 +11:00
button_appearance(
theme,
selected,
highlighted,
2025-04-28 23:35:27 +10:00
cut,
2024-10-16 10:22:25 +11:00
false,
accent,
condensed_radius,
desktop,
)
}),
2024-01-05 09:36:16 -07:00
hovered: Box::new(move |focused, theme| {
2024-10-16 10:22:25 +11:00
button_appearance(
theme,
selected,
highlighted,
2025-04-28 23:35:27 +10:00
cut,
2024-10-16 10:22:25 +11:00
focused,
accent,
condensed_radius,
desktop,
)
2024-01-05 09:36:16 -07:00
}),
pressed: Box::new(move |focused, theme| {
2024-10-16 10:22:25 +11:00
button_appearance(
theme,
selected,
highlighted,
2025-04-28 23:35:27 +10:00
cut,
2024-10-16 10:22:25 +11:00
focused,
accent,
condensed_radius,
desktop,
)
2024-01-05 09:36:16 -07:00
}),
}
}
2024-01-09 15:34:48 -07:00
pub fn folder_icon(path: &PathBuf, icon_size: u16) -> widget::icon::Handle {
2024-01-04 15:56:01 -07:00
widget::icon::from_name(SPECIAL_DIRS.get(path).map_or("folder", |x| *x))
.size(icon_size)
.handle()
2024-01-04 15:56:01 -07:00
}
2024-01-09 15:34:48 -07:00
pub fn folder_icon_symbolic(path: &PathBuf, icon_size: u16) -> widget::icon::Handle {
widget::icon::from_name(format!(
"{}-symbolic",
SPECIAL_DIRS.get(path).map_or("folder", |x| *x)
))
.size(icon_size)
.handle()
}
2025-02-07 09:11:20 -07:00
fn tab_complete(path: &Path) -> Result<Vec<(String, PathBuf)>, Box<dyn Error>> {
let parent = if path.exists() {
// Do not show completion if already on an existing path
return Ok(Vec::new());
2025-02-07 09:11:20 -07:00
} else {
path.parent()
.ok_or_else(|| format!("path has no parent {}", path.display()))?
2025-02-07 09:11:20 -07:00
};
2025-10-03 03:24:44 +02:00
let child_os = path.strip_prefix(parent)?;
2025-02-07 09:11:20 -07:00
let child = child_os
.to_str()
.ok_or_else(|| format!("invalid UTF-8 {}", child_os.display()))?;
2025-02-07 09:11:20 -07:00
2025-10-03 03:24:44 +02:00
let pattern = format!("^{}", regex::escape(child));
2025-02-07 09:11:20 -07:00
let regex = regex::RegexBuilder::new(&pattern)
.case_insensitive(true)
.build()?;
let mut completions = Vec::new();
2025-10-03 03:24:44 +02:00
for entry_res in fs::read_dir(parent)? {
2025-02-07 09:11:20 -07:00
let entry = entry_res?;
let file_name_os = entry.file_name();
let Some(file_name) = file_name_os.to_str() else {
continue;
};
2025-10-03 03:24:44 +02:00
if regex.is_match(file_name) {
2025-02-07 09:11:20 -07:00
completions.push((file_name.to_string(), entry.path()));
}
}
completions.sort_by(|a, b| LANGUAGE_SORTER.compare(&a.0, &b.0));
2025-02-07 10:21:49 -07:00
//TODO: make the list scrollable?
completions.truncate(8);
2025-02-07 09:11:20 -07:00
Ok(completions)
}
#[cfg(target_os = "macos")]
pub fn trash_entries() -> usize {
0
}
#[cfg(not(target_os = "macos"))]
pub fn trash_entries() -> usize {
match trash::os_limited::list() {
Ok(entries) => entries.len(),
Err(_err) => 0,
}
}
pub fn trash_icon(icon_size: u16) -> widget::icon::Handle {
widget::icon::from_name(if trash::os_limited::is_empty().unwrap_or(true) {
"user-trash"
} else {
"user-trash-full"
})
.size(icon_size)
.handle()
}
pub fn trash_icon_symbolic(icon_size: u16) -> widget::icon::Handle {
widget::icon::from_name(if trash::os_limited::is_empty().unwrap_or(true) {
2024-01-10 12:57:30 -07:00
"user-trash-symbolic"
} else {
"user-trash-full-symbolic"
2024-01-10 12:57:30 -07:00
})
.size(icon_size)
.handle()
}
2024-01-05 15:32:42 -07:00
//TODO: translate, add more levels?
fn format_size(size: u64) -> String {
2024-01-29 10:34:09 -07:00
const KB: u64 = 1000;
const MB: u64 = 1000 * KB;
const GB: u64 = 1000 * MB;
const TB: u64 = 1000 * GB;
2024-01-05 15:32:42 -07:00
2024-01-29 10:34:09 -07:00
if size >= TB {
format!("{:.1} TB", size as f64 / TB as f64)
} else if size >= GB {
format!("{:.1} GB", size as f64 / GB as f64)
} else if size >= MB {
format!("{:.1} MB", size as f64 / MB as f64)
} else if size >= KB {
format!("{:.1} KB", size as f64 / KB as f64)
2024-01-05 15:32:42 -07:00
} else {
format!("{size} B")
2024-01-05 15:32:42 -07:00
}
}
const MODE_SHIFT_USER: u32 = 6;
const MODE_SHIFT_GROUP: u32 = 3;
const MODE_SHIFT_OTHER: u32 = 0;
const fn get_mode_part(mode: u32, shift: u32) -> u32 {
(mode >> shift) & 0o7
}
fn set_mode_part(mode: u32, shift: u32, bits: u32) -> u32 {
assert!(bits <= 0o7);
(mode & !(0o7 << shift)) | (bits << shift)
}
2024-01-05 15:32:42 -07:00
2025-09-15 15:13:03 +02:00
fn date_time_formatter(military_time: bool) -> DateTimeFormatter<fieldsets::YMDT> {
let mut prefs = DateTimeFormatterPreferences::from(LOCALE.clone());
prefs.hour_cycle = Some(if military_time {
HourCycle::H23
} else {
HourCycle::H12
});
2025-09-15 15:13:03 +02:00
let mut fs = fieldsets::YMDT::medium();
fs = fs.with_time_precision(TimePrecision::Minute);
2025-09-15 15:13:03 +02:00
DateTimeFormatter::try_new(prefs, fs).expect("failed to create DateTimeFormatter")
}
2025-09-15 15:13:03 +02:00
fn time_formatter(military_time: bool) -> DateTimeFormatter<fieldsets::T> {
let mut prefs = DateTimeFormatterPreferences::from(LOCALE.clone());
prefs.hour_cycle = Some(if military_time {
HourCycle::H23
} else {
2025-09-15 15:13:03 +02:00
HourCycle::H12
});
let mut fs = fieldsets::T::medium();
fs = fs.with_time_precision(TimePrecision::Minute);
DateTimeFormatter::try_new(prefs, fs).expect("failed to create DateTimeFormatter")
}
struct FormatTime<'a> {
pub time: SystemTime,
2025-09-15 15:13:03 +02:00
pub date_time_formatter: &'a DateTimeFormatter<fieldsets::YMDT>,
pub time_formatter: &'a DateTimeFormatter<fieldsets::T>,
}
2024-09-15 16:00:20 -06:00
impl<'a> FormatTime<'a> {
fn from_secs(
secs: i64,
2025-09-15 15:13:03 +02:00
date_time_formatter: &'a DateTimeFormatter<fieldsets::YMDT>,
time_formatter: &'a DateTimeFormatter<fieldsets::T>,
) -> Option<Self> {
// This looks convoluted because we need to ensure the units match up
let secs: u64 = secs.try_into().ok()?;
let now = SystemTime::now();
let filetime_diff = now
.duration_since(SystemTime::UNIX_EPOCH)
.map(|from_epoch| from_epoch.as_secs())
.ok()
.and_then(|now_secs| now_secs.checked_sub(secs))
.map(Duration::from_secs)?;
now.checked_sub(filetime_diff).map(|time| Self {
time,
date_time_formatter,
time_formatter,
})
}
}
impl Display for FormatTime<'_> {
2024-09-15 16:00:20 -06:00
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let datetime = chrono::DateTime::<chrono::Local>::from(self.time);
2024-09-15 16:00:20 -06:00
let now = chrono::Local::now();
2025-09-15 15:13:03 +02:00
let icu_datetime = DateTime {
date: Date::try_new_gregorian(
datetime.year(),
datetime.month() as u8,
datetime.day() as u8,
)
.unwrap(),
time: Time::try_new(
datetime.hour() as u8,
datetime.minute() as u8,
datetime.second() as u8,
0,
)
.unwrap(),
};
if datetime.date_naive() == now.date_naive() {
f.write_str(fl!("today").as_str())?;
f.write_str(", ")?;
self.time_formatter.format(&icu_datetime).fmt(f)
2024-09-15 16:00:20 -06:00
} else {
self.date_time_formatter.format(&icu_datetime).fmt(f)
2024-09-15 16:00:20 -06:00
}
}
}
const fn format_time<'a>(
time: SystemTime,
2025-09-15 15:13:03 +02:00
date_time_formatter: &'a DateTimeFormatter<fieldsets::YMDT>,
time_formatter: &'a DateTimeFormatter<fieldsets::T>,
) -> FormatTime<'a> {
FormatTime {
time,
date_time_formatter,
time_formatter,
}
2024-09-15 16:00:20 -06:00
}
2024-01-04 16:08:22 -07:00
#[cfg(not(target_os = "windows"))]
2024-01-05 14:44:20 -07:00
fn hidden_attribute(_metadata: &Metadata) -> bool {
2024-01-04 16:08:22 -07:00
false
}
#[cfg(target_os = "windows")]
2024-01-05 14:44:20 -07:00
fn hidden_attribute(metadata: &Metadata) -> bool {
2024-01-04 16:08:22 -07:00
use std::os::windows::fs::MetadataExt;
2024-01-05 14:44:20 -07:00
// https://learn.microsoft.com/en-us/windows/win32/fileio/file-attribute-constants
const FILE_ATTRIBUTE_HIDDEN: u32 = 2;
metadata.file_attributes() & FILE_ATTRIBUTE_HIDDEN == FILE_ATTRIBUTE_HIDDEN
2024-01-04 16:08:22 -07:00
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FsKind {
Local,
Remote,
Gvfs,
}
#[cfg(target_os = "linux")]
pub fn fs_kind(metadata: &Metadata) -> FsKind {
//TODO: method to reload remote filesystems dynamically
//TODO: fix for https://github.com/eminence/procfs/issues/262
static DEVICES: LazyLock<FxHashMap<u64, FsKind>> = LazyLock::new(|| {
let mut devices = FxHashMap::default();
match procfs::process::Process::myself() {
Ok(process) => match process.mountinfo() {
Ok(mount_infos) => {
devices = FxHashMap::from_iter(mount_infos.iter().filter_map(|mount_info| {
let mut parts = mount_info.majmin.split(':');
let major_str = parts.next()?;
let minor_str = parts.next()?;
let major = major_str.parse::<libc::c_uint>().ok()?;
let minor = minor_str.parse::<libc::c_uint>().ok()?;
let dev = libc::makedev(major, minor);
//TODO: make sure this list is exhaustive
let kind = match mount_info.fs_type.as_str() {
"cifs" | "fuse.rclone" | "fuse.sshfs" | "nfs" | "nfs4" | "smb"
| "smb2" => FsKind::Remote,
"fuse.gvfsd-fuse" => FsKind::Gvfs,
_ => FsKind::Local,
};
Some((dev, kind))
}));
}
Err(err) => {
log::warn!("failed to get mount info: {err}");
}
},
Err(err) => {
log::warn!("failed to get process info: {err}");
}
}
devices
});
DEVICES.get(&metadata.dev()).map_or(FsKind::Local, |x| *x)
}
#[cfg(not(target_os = "linux"))]
pub fn fs_kind(_metadata: &Metadata) -> FsKind {
//TODO: support BSD, macOS, Windows?
FsKind::Local
}
2024-08-20 13:26:10 -06:00
pub fn parse_desktop_file(path: &Path) -> (Option<String>, Option<String>) {
let entry = match freedesktop_entry_parser::parse_entry(path) {
Ok(ok) => ok,
Err(err) => {
log::warn!("failed to parse {}: {}", path.display(), err);
2024-08-20 13:26:10 -06:00
return (None, None);
}
};
(
entry
.section("Desktop Entry")
.attr("Name")
.map(str::to_string),
2024-08-20 13:26:10 -06:00
entry
.section("Desktop Entry")
.attr("Icon")
.map(str::to_string),
2024-08-20 13:26:10 -06:00
)
}
#[cfg(feature = "gvfs")]
pub fn item_from_gvfs_info(path: PathBuf, file_info: gio::FileInfo, sizes: IconSizes) -> Item {
let file_name = file_info
.attribute_as_string(gio::FILE_ATTRIBUTE_STANDARD_NAME)
.unwrap_or_default();
let mtime = file_info.attribute_uint64(gio::FILE_ATTRIBUTE_TIME_MODIFIED);
let mut display_name = Item::display_name(&file_info.display_name());
let remote = file_info.boolean(gio::FILE_ATTRIBUTE_FILESYSTEM_REMOTE);
2025-10-03 03:24:44 +02:00
let is_dir = matches!(file_info.file_type(), gio::FileType::Directory);
let size_opt = (!is_dir).then_some(file_info.size() as u64);
let (mime, icon_handle_grid, icon_handle_list, icon_handle_list_condensed) = if is_dir {
(
//TODO: make this a static
"inode/directory".parse().unwrap(),
folder_icon(&path, sizes.grid()),
folder_icon(&path, sizes.list()),
folder_icon(&path, sizes.list_condensed()),
)
} else {
// ALWAYS assume we're remote for mime guessing here, since gvfs reading can be expensive
// @todo - expose this as a config option?
let mime = mime_for_path(&path, None, true);
//TODO: clean this up, implement for trash
let icon_name_opt = if mime == "application/x-desktop" {
let (desktop_name_opt, icon_name_opt) = parse_desktop_file(&path);
if let Some(desktop_name) = desktop_name_opt {
display_name = Item::display_name(&desktop_name);
}
icon_name_opt
} else {
None
};
if let Some(icon_name) = icon_name_opt {
(
mime,
widget::icon::from_name(&*icon_name)
.size(sizes.grid())
.handle(),
widget::icon::from_name(&*icon_name)
.size(sizes.list())
.handle(),
widget::icon::from_name(&*icon_name)
.size(sizes.list_condensed())
.handle(),
)
} else {
(
mime.clone(),
mime_icon(mime.clone(), sizes.grid()),
mime_icon(mime.clone(), sizes.list()),
mime_icon(mime, sizes.list_condensed()),
)
}
};
let mut children_opt = None;
let mut dir_size = DirSize::NotDirectory;
if is_dir && !remote {
dir_size = DirSize::Calculating(Controller::default());
//TODO: calculate children in the background (and make it cancellable?)
match fs::read_dir(&path) {
Ok(entries) => {
children_opt = Some(entries.count());
}
Err(err) => {
log::warn!("failed to read directory {}: {}", path.display(), err);
}
}
}
let hidden = file_name.starts_with('.');
Item {
name: file_name.into(),
display_name,
is_mount_point: false,
metadata: ItemMetadata::GvfsPath {
mtime,
size_opt,
children_opt,
},
hidden,
location_opt: Some(Location::Path(path)),
mime,
icon_handle_grid,
icon_handle_list,
icon_handle_list_condensed,
thumbnail_opt: if remote {
Some(ItemThumbnail::NotImage)
} else {
None
},
button_id: widget::Id::unique(),
pos_opt: Cell::new(None),
rect_opt: Cell::new(None),
selected: false,
highlighted: false,
overlaps_drag_rect: false,
dir_size,
cut: false,
}
}
2024-05-31 14:54:19 -06:00
pub fn item_from_entry(
path: PathBuf,
name: String,
metadata: fs::Metadata,
sizes: IconSizes,
) -> Item {
2024-08-20 13:26:10 -06:00
let mut display_name = Item::display_name(&name);
let hidden = name.starts_with('.') || hidden_attribute(&metadata);
2024-05-31 14:54:19 -06:00
let remote = match fs_kind(&metadata) {
FsKind::Local => false,
FsKind::Remote => true,
#[cfg(feature = "gvfs")]
FsKind::Gvfs => {
let file = gio::File::for_path(&path);
match gio::prelude::FileExt::query_info(
&file,
gio::FILE_ATTRIBUTE_STANDARD_DISPLAY_NAME,
gio::FileQueryInfoFlags::NONE,
gio::Cancellable::NONE,
) {
Ok(info) => {
display_name = Item::display_name(&info.display_name());
}
Err(err) => {
log::warn!("failed to get GIO info for {}: {}", path.display(), err);
}
}
match gio::prelude::FileExt::query_filesystem_info(
&file,
gio::FILE_ATTRIBUTE_FILESYSTEM_REMOTE,
gio::Cancellable::NONE,
) {
Ok(info) => info.boolean(gio::FILE_ATTRIBUTE_FILESYSTEM_REMOTE),
Err(err) => {
log::warn!(
"failed to get GIO filesystem info for {}: {}",
path.display(),
err
);
true
}
}
}
#[cfg(not(feature = "gvfs"))]
FsKind::Gvfs => {
log::info!(
"gvfs feature not enabled, info may be inaccurate for {}",
path.display()
);
true
}
};
2024-05-31 14:54:19 -06:00
let (mime, icon_handle_grid, icon_handle_list, icon_handle_list_condensed) =
if metadata.is_dir() {
(
//TODO: make this a static
"inode/directory".parse().unwrap(),
folder_icon(&path, sizes.grid()),
folder_icon(&path, sizes.list()),
folder_icon(&path, sizes.list_condensed()),
)
} else {
let mime = mime_for_path(&path, Some(&metadata), remote);
2024-08-20 13:26:10 -06:00
//TODO: clean this up, implement for trash
let icon_name_opt = if mime == "application/x-desktop" {
let (desktop_name_opt, icon_name_opt) = parse_desktop_file(&path);
if let Some(desktop_name) = desktop_name_opt {
display_name = Item::display_name(&desktop_name);
}
icon_name_opt
} else {
None
};
if let Some(icon_name) = icon_name_opt {
(
mime,
2024-08-20 13:26:10 -06:00
widget::icon::from_name(&*icon_name)
.size(sizes.grid())
.handle(),
widget::icon::from_name(&*icon_name)
.size(sizes.list())
.handle(),
widget::icon::from_name(icon_name)
2024-08-20 13:26:10 -06:00
.size(sizes.list_condensed())
.handle(),
)
} else {
(
mime.clone(),
mime_icon(mime.clone(), sizes.grid()),
mime_icon(mime.clone(), sizes.list()),
mime_icon(mime, sizes.list_condensed()),
)
}
2024-05-31 14:54:19 -06:00
};
let mut children_opt = None;
let mut dir_size = DirSize::NotDirectory;
if metadata.is_dir() && !remote {
dir_size = DirSize::Calculating(Controller::default());
2024-05-31 14:54:19 -06:00
//TODO: calculate children in the background (and make it cancellable?)
match fs::read_dir(&path) {
Ok(entries) => {
children_opt = Some(entries.count());
}
2024-05-31 14:54:19 -06:00
Err(err) => {
log::warn!("failed to read directory {}: {}", path.display(), err);
2024-05-31 14:54:19 -06:00
}
}
}
2024-05-31 14:54:19 -06:00
Item {
name,
2024-08-20 13:26:10 -06:00
display_name,
is_mount_point: false,
metadata: ItemMetadata::Path {
metadata,
children_opt,
},
2024-05-31 14:54:19 -06:00
hidden,
location_opt: Some(Location::Path(path)),
2024-05-31 14:54:19 -06:00
mime,
icon_handle_grid,
icon_handle_list,
icon_handle_list_condensed,
thumbnail_opt: remote.then_some(ItemThumbnail::NotImage),
2024-05-31 14:54:19 -06:00
button_id: widget::Id::unique(),
pos_opt: Cell::new(None),
rect_opt: Cell::new(None),
selected: false,
2024-10-16 10:22:25 +11:00
highlighted: false,
2024-05-31 14:54:19 -06:00
overlaps_drag_rect: false,
dir_size,
2025-04-28 23:35:27 +10:00
cut: false,
2024-05-31 14:54:19 -06:00
}
}
pub fn item_from_path<P: Into<PathBuf>>(path: P, sizes: IconSizes) -> Result<Item, String> {
let path = path.into();
let name = match path.file_name() {
Some(name_os) => name_os
.to_str()
.ok_or_else(|| {
format!(
"failed to parse file name for {}: {name_os:?} is not valid UTF-8",
path.display()
)
})?
.to_string(),
None => fl!("filesystem"),
};
let metadata = fs::metadata(&path)
.map_err(|err| format!("failed to read metadata for {}: {}", path.display(), err))?;
Ok(item_from_entry(path, name, metadata, sizes))
}
pub fn scan_path(tab_path: &PathBuf, sizes: IconSizes) -> Vec<Item> {
let mut items = Vec::new();
let mut hidden_files = Box::from([]);
let mut remote_scannable = false;
#[cfg(feature = "gvfs")]
{
if let Ok(path_meta) = fs::metadata(tab_path) {
if fs_kind(&path_meta) == FsKind::Gvfs {
2025-10-03 03:24:44 +02:00
let file = gio::File::for_path(tab_path);
// gio crate expects a comma delimited string
let attr_string = [
gio::FILE_ATTRIBUTE_STANDARD_DISPLAY_NAME.as_str(),
gio::FILE_ATTRIBUTE_FILESYSTEM_REMOTE.as_str(),
gio::FILE_ATTRIBUTE_TIME_MODIFIED.as_str(),
gio::FILE_ATTRIBUTE_STANDARD_SIZE.as_str(),
gio::FILE_ATTRIBUTE_STANDARD_TYPE.as_str(),
gio::FILE_ATTRIBUTE_STANDARD_NAME.as_str(),
]
.join(",");
match gio::prelude::FileExt::enumerate_children(
&file,
attr_string.as_str(),
gio::FileQueryInfoFlags::NONE,
gio::Cancellable::NONE,
) {
Ok(res) => {
remote_scannable = true;
items = res
.filter_map(|file| {
let file = file.ok()?;
Some(item_from_gvfs_info(tab_path.join(file.name()), file, sizes))
})
.collect();
}
Err(err) => {
log::warn!(
"could not enumerate {} via gio: {}",
tab_path.display(),
err
);
}
}
}
}
}
if !remote_scannable {
match fs::read_dir(tab_path) {
Ok(entries) => {
items = entries
.filter_map(|entry_res| {
let entry = entry_res
.inspect_err(|err| {
log::warn!(
"failed to read entry in {}: {}",
tab_path.display(),
err
)
})
.ok()?;
let path = entry.path();
let name = entry
.file_name()
.into_string()
.inspect_err(|name_os| {
log::warn!(
"failed to parse entry at {}: {:?} is not valid UTF-8",
path.display(),
name_os
)
})
.ok()?;
2024-01-05 14:44:20 -07:00
if name == ".hidden" && path.is_file() {
hidden_files = parse_hidden_file(&path);
}
let metadata = fs::metadata(&path)
.inspect_err(|err| {
log::warn!(
"failed to read metadata for entry at {}: {}",
path.display(),
err
)
})
.ok()?;
Some(item_from_entry(path, name, metadata, sizes))
})
.collect();
}
Err(err) => {
log::warn!("failed to read directory {}: {}", tab_path.display(), err);
}
}
}
items.sort_unstable_by(|a, b| match (a.metadata.is_dir(), b.metadata.is_dir()) {
2024-01-06 12:59:36 -07:00
(true, false) => Ordering::Less,
(false, true) => Ordering::Greater,
2024-08-20 13:26:10 -06:00
_ => LANGUAGE_SORTER.compare(&a.display_name, &b.display_name),
2024-01-06 12:59:36 -07:00
});
for item in &mut items {
if hidden_files.contains(&item.name) {
2024-10-04 19:02:22 +02:00
item.hidden = true;
}
}
2024-01-05 16:17:23 -07:00
items
}
2024-10-09 20:18:43 -06:00
pub fn scan_search<F: Fn(&Path, &str, Metadata) -> bool + Sync>(
tab_path: &PathBuf,
term: &str,
show_hidden: bool,
callback: F,
) {
if term.is_empty() {
return;
}
2024-05-31 15:19:47 -06:00
2025-01-28 23:52:11 -05:00
let pattern = regex::escape(term);
2024-05-31 15:19:47 -06:00
let regex = match regex::RegexBuilder::new(&pattern)
.case_insensitive(true)
.build()
{
Ok(ok) => ok,
Err(err) => {
log::warn!("failed to parse regex {pattern:?}: {err}");
return;
2024-05-31 15:19:47 -06:00
}
};
ignore::WalkBuilder::new(tab_path)
.standard_filters(false)
.hidden(!show_hidden)
2024-05-31 15:19:47 -06:00
//TODO: only use this on supported targets
.same_file_system(true)
.build_parallel()
.run(|| {
Box::new(|entry_res| {
let Ok(entry) = entry_res else {
// Skip invalid entries
return ignore::WalkState::Skip;
};
let Some(file_name) = entry.file_name().to_str() else {
// Skip anything with an invalid name
return ignore::WalkState::Skip;
};
if regex.is_match(file_name) {
let path = entry.path();
2024-10-09 20:18:43 -06:00
let metadata = match entry.metadata() {
2024-05-31 15:19:47 -06:00
Ok(ok) => ok,
Err(err) => {
log::warn!(
"failed to read metadata for entry at {}: {}",
path.display(),
err
);
2024-05-31 15:19:47 -06:00
return ignore::WalkState::Continue;
}
};
2024-10-09 20:18:43 -06:00
//TODO: use entry.into_path?
if !callback(path, file_name, metadata) {
return ignore::WalkState::Quit;
}
2024-05-31 15:19:47 -06:00
}
ignore::WalkState::Continue
})
});
}
2024-01-05 16:17:23 -07:00
// This config statement is from trash::os_limited, inverted
#[cfg(not(any(
target_os = "windows",
all(
unix,
not(target_os = "macos"),
not(target_os = "ios"),
not(target_os = "android")
)
)))]
pub fn scan_trash(_sizes: IconSizes) -> Vec<Item> {
2024-01-05 16:17:23 -07:00
log::warn!("viewing trash not supported on this platform");
Vec::new()
}
// This config statement is from trash::os_limited
#[cfg(any(
target_os = "windows",
all(
unix,
not(target_os = "macos"),
not(target_os = "ios"),
not(target_os = "android")
)
))]
pub fn scan_trash(sizes: IconSizes) -> Vec<Item> {
let entries = match trash::os_limited::list() {
Ok(entry) => entry,
2024-01-05 16:17:23 -07:00
Err(err) => {
log::warn!("failed to read trash items: {err}");
return Vec::new();
2024-01-05 16:17:23 -07:00
}
};
let mut items: Vec<_> = entries
.into_iter()
.filter_map(|entry| {
let metadata = trash::os_limited::metadata(&entry)
.inspect_err(|err| {
log::warn!("failed to get metadata for trash item {entry:?}: {err}")
})
.ok()?;
let original_path = entry.original_path();
let name = entry.name.to_string_lossy().into_owned();
let display_name = Item::display_name(&name);
let (mime, icon_handle_grid, icon_handle_list, icon_handle_list_condensed) =
match metadata.size {
trash::TrashItemSize::Entries(_) => (
//TODO: make this a static
"inode/directory".parse().unwrap(),
folder_icon(&original_path, sizes.grid()),
folder_icon(&original_path, sizes.list()),
folder_icon(&original_path, sizes.list_condensed()),
),
trash::TrashItemSize::Bytes(_) => {
// This passes remote = true so it does not read from the original path
let mime = mime_for_path(&original_path, None, true);
(
mime.clone(),
mime_icon(mime.clone(), sizes.grid()),
mime_icon(mime.clone(), sizes.list()),
mime_icon(mime, sizes.list_condensed()),
)
}
};
Some(Item {
name,
display_name,
is_mount_point: false,
metadata: ItemMetadata::Trash { metadata, entry },
hidden: false,
location_opt: None,
mime,
icon_handle_grid,
icon_handle_list,
icon_handle_list_condensed,
thumbnail_opt: Some(ItemThumbnail::NotImage),
button_id: widget::Id::unique(),
pos_opt: Cell::new(None),
rect_opt: Cell::new(None),
selected: false,
highlighted: false,
overlaps_drag_rect: false,
dir_size: DirSize::NotDirectory,
cut: false,
})
})
.collect();
2024-01-06 12:59:36 -07:00
items.sort_by(|a, b| match (a.metadata.is_dir(), b.metadata.is_dir()) {
(true, false) => Ordering::Less,
(false, true) => Ordering::Greater,
2024-08-20 13:26:10 -06:00
_ => LANGUAGE_SORTER.compare(&a.display_name, &b.display_name),
});
items
2024-01-03 15:27:32 -07:00
}
2024-08-16 14:42:09 +02:00
fn uri_to_path(uri: String) -> Option<PathBuf> {
uri.parse::<url::Url>().ok().and_then(|url| {
//TODO support for external drive or cloud?
if url.scheme() == "file" {
url.to_file_path().ok()
} else {
None
}
})
2024-08-16 14:42:09 +02:00
}
pub fn scan_recents(sizes: IconSizes) -> Vec<Item> {
let recent_files = match recently_used_xbel::parse_file() {
Ok(recent_files) => recent_files,
2024-08-16 14:42:09 +02:00
Err(err) => {
log::warn!("Error reading recent files: {err:?}");
return Vec::new();
2024-08-16 14:42:09 +02:00
}
};
let mut recents: Vec<_> = recent_files
.bookmarks
.into_iter()
.filter_map(|bookmark| {
let path = uri_to_path(bookmark.href)?;
let last_edit = bookmark.modified.parse::<chrono::DateTime<Utc>>().ok()?;
let last_visit = bookmark.visited.parse::<chrono::DateTime<Utc>>().ok()?;
if path.exists() {
let file_name = path.file_name()?;
let name = file_name.to_string_lossy().to_string();
let metadata = match path.metadata() {
Ok(ok) => ok,
Err(err) => {
log::warn!(
"failed to read metadata for entry at {}: {}",
path.display(),
err
);
return None;
}
};
2024-08-16 14:42:09 +02:00
let item = item_from_entry(path, name, metadata, sizes);
Some((item, last_edit.min(last_visit)))
} else {
log::warn!("recent file path not exist: {}", path.display());
None
}
})
.collect();
recents.sort_by_key(|recent| Reverse(recent.1));
2024-08-16 14:42:09 +02:00
2024-08-27 19:01:36 +02:00
recents.into_iter().take(50).map(|(item, _)| item).collect()
2024-08-16 14:42:09 +02:00
}
2025-07-15 10:55:21 -04:00
pub fn scan_network(uri: &str, sizes: IconSizes) -> Vec<Item> {
for mounter in MOUNTERS.values() {
2025-07-15 10:55:21 -04:00
match mounter.network_scan(uri, sizes) {
2024-09-13 15:13:37 -06:00
Some(Ok(items)) => return items,
Some(Err(err)) => {
log::warn!("failed to scan {uri:?}: {err}");
2024-09-13 15:13:37 -06:00
}
None => {}
}
}
Vec::new()
}
//TODO: organize desktop items based on display
pub fn scan_desktop(
tab_path: &PathBuf,
_display: &str,
desktop_config: DesktopConfig,
mut sizes: IconSizes,
) -> Vec<Item> {
sizes.grid = desktop_config.icon_size;
let mut items = Vec::new();
if desktop_config.show_content {
items.extend(scan_path(tab_path, sizes));
}
if desktop_config.show_mounted_drives {
for mounter in MOUNTERS.values() {
let Some(mounter_items) = mounter.items(sizes) else {
continue;
};
items.extend(mounter_items.into_iter().filter_map(|mounter_item| {
let path = mounter_item.path()?;
// Get most item data from path
let mut item = match item_from_path(&path, sizes) {
Ok(item) => item,
Err(err) => {
log::warn!(
"failed to get item from mounter item {}: {}",
path.display(),
err
);
return None;
}
};
//Override some data with mounter information
item.name = mounter_item.name();
item.display_name = Item::display_name(&item.name);
//TODO: use icon size for mounter item icon
if let Some(icon) = mounter_item.icon(false) {
item.icon_handle_grid.clone_from(&icon);
item.icon_handle_list.clone_from(&icon);
item.icon_handle_list_condensed = icon;
}
Some(item)
}));
}
}
if desktop_config.show_trash {
let name = fl!("trash");
let display_name = Item::display_name(&name);
let metadata = ItemMetadata::SimpleDir {
entries: trash_entries() as u64,
};
let (mime, icon_handle_grid, icon_handle_list, icon_handle_list_condensed) = {
(
"inode/directory".parse().unwrap(),
trash_icon(sizes.grid()),
trash_icon(sizes.list()),
trash_icon(sizes.list_condensed()),
)
};
items.push(Item {
name,
display_name,
is_mount_point: false,
metadata,
hidden: false,
location_opt: Some(Location::Trash),
mime,
icon_handle_grid,
icon_handle_list,
icon_handle_list_condensed,
thumbnail_opt: Some(ItemThumbnail::NotImage),
button_id: widget::Id::unique(),
pos_opt: Cell::new(None),
rect_opt: Cell::new(None),
selected: false,
2024-10-16 10:22:25 +11:00
highlighted: false,
overlaps_drag_rect: false,
dir_size: DirSize::NotDirectory,
2025-04-28 23:35:27 +10:00
cut: false,
});
}
items
}
2025-02-07 09:11:20 -07:00
#[derive(Clone, Debug)]
pub struct EditLocation {
pub location: Location,
pub completions: Option<Vec<(String, PathBuf)>>,
pub selected: Option<usize>,
}
impl EditLocation {
pub fn resolve(&self) -> Option<Location> {
let Some(selected) = self.selected else {
return Some(self.location.clone());
};
let completions = self.completions.as_ref()?;
let completion = completions.get(selected)?;
Some(self.location.with_path(completion.1.clone()))
}
pub fn select(&mut self, forwards: bool) {
if let Some(completions) = &self.completions {
if completions.is_empty() {
self.selected = None;
} else {
let mut selected = if forwards {
self.selected.and_then(|x| x.checked_add(1)).unwrap_or(0)
} else {
self.selected
.and_then(|x| x.checked_sub(1))
.unwrap_or(completions.len() - 1)
};
if selected >= completions.len() {
selected = 0;
}
self.selected = Some(selected);
}
} else {
self.selected = None;
}
}
}
impl From<Location> for EditLocation {
fn from(location: Location) -> Self {
Self {
location,
completions: None,
selected: None,
}
}
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
2024-01-05 16:17:23 -07:00
pub enum Location {
Desktop(PathBuf, String, DesktopConfig),
Network(String, String, Option<PathBuf>),
2024-01-05 16:17:23 -07:00
Path(PathBuf),
Recents,
Search(PathBuf, String, bool, Instant),
2024-01-05 16:17:23 -07:00
Trash,
}
impl std::fmt::Display for Location {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Desktop(path, display, ..) => {
write!(f, "{} on display {display}", path.display())
}
Self::Network(uri, ..) => write!(f, "{uri}"),
Self::Path(path) => write!(f, "{}", path.display()),
Self::Recents => write!(f, "recents"),
Self::Search(path, term, ..) => write!(f, "search {} for {}", path.display(), term),
Self::Trash => write!(f, "trash"),
}
}
}
2024-01-05 16:17:23 -07:00
impl Location {
pub fn normalize(&self) -> Self {
if let Some(mut path) = self.path_opt().cloned() {
// Add trailing slash if location is a path
path.push("");
self.with_path(path)
} else {
self.clone()
}
}
pub fn ancestors(&self) -> Vec<(Self, String)> {
self.path_opt().map_or_else(Default::default, |path| {
path.ancestors()
.scan(false, |found_home, ancestor| {
(!*found_home).then(|| {
let (name, is_home) = folder_name(ancestor);
*found_home = is_home;
(self.with_path(ancestor.to_path_buf()), name)
})
})
.collect()
})
}
pub const fn path_opt(&self) -> Option<&PathBuf> {
match self {
2025-01-28 23:52:11 -05:00
Self::Desktop(path, ..) => Some(path),
Self::Path(path) => Some(path),
Self::Search(path, ..) => Some(path),
Self::Network(_, _, path) => path.as_ref(),
_ => None,
}
}
pub(crate) fn into_path_opt(self) -> Option<PathBuf> {
match self {
Self::Desktop(path, ..) => Some(path),
Self::Path(path) => Some(path),
Self::Search(path, ..) => Some(path),
Self::Network(_, _, path) => path,
_ => None,
}
}
pub fn with_path(&self, path: PathBuf) -> Self {
match self {
Self::Desktop(_, display, desktop_config) => {
Self::Desktop(path, display.clone(), *desktop_config)
}
Self::Path(..) => Self::Path(path),
Self::Search(_, term, show_hidden, time) => {
Self::Search(path, term.clone(), *show_hidden, *time)
}
Self::Network(id, name, path) => Self::Network(id.clone(), name.clone(), path.clone()),
other => other.clone(),
}
}
pub fn scan(&self, sizes: IconSizes) -> (Option<Item>, Vec<Item>) {
let items = match self {
Self::Desktop(path, display, desktop_config) => {
scan_desktop(path, display, *desktop_config, sizes)
}
Self::Path(path) => scan_path(path, sizes),
Self::Search(..) => {
// Search is done incrementally
Vec::new()
}
Self::Trash => scan_trash(sizes),
2024-08-16 14:42:09 +02:00
Self::Recents => scan_recents(sizes),
2025-07-15 10:55:21 -04:00
Self::Network(uri, _, _) => scan_network(uri, sizes),
};
let parent_item_opt = match self.path_opt() {
Some(path) => match item_from_path(path, sizes) {
Ok(item) => Some(item),
Err(err) => {
log::warn!("failed to get item for {}: {}", path.display(), err);
None
}
},
//TODO: support other locations?
None => None,
};
(parent_item_opt, items)
2024-01-05 16:17:23 -07:00
}
pub fn title(&self) -> String {
match self {
Self::Desktop(path, _, _) => {
let (name, _) = folder_name(path);
name
}
Self::Path(path) => {
let (name, _) = folder_name(path);
name
}
Self::Search(path, term, ..) => {
//TODO: translate
let (name, _) = folder_name(path);
format!("Search \"{term}\": {name}")
}
Self::Trash => {
fl!("trash")
}
Self::Recents => {
fl!("recents")
}
Self::Network(display_name, ..) => display_name.clone(),
}
}
2024-01-05 16:17:23 -07:00
}
2024-10-21 13:51:10 -06:00
pub struct TaskWrapper(pub cosmic::Task<Message>);
impl From<cosmic::Task<Message>> for TaskWrapper {
fn from(task: cosmic::Task<Message>) -> Self {
Self(task)
}
}
impl fmt::Debug for TaskWrapper {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("TaskWrapper").finish()
}
}
2024-08-20 13:26:10 -06:00
#[derive(Debug)]
2024-02-26 12:51:22 -07:00
pub enum Command {
Action(Action),
AddNetworkDrive,
AddToSidebar(PathBuf),
AutoScroll(Option<f32>),
2024-11-19 20:17:58 -07:00
ChangeLocation(String, Location, Option<Vec<PathBuf>>),
ContextMenu(Option<Point>, Option<window::Id>),
Delete(Vec<PathBuf>),
2024-08-20 13:26:10 -06:00
DropFiles(PathBuf, ClipboardPaste),
2024-05-09 13:24:06 -06:00
EmptyTrash,
#[cfg(feature = "desktop")]
ExecEntryAction(cosmic::desktop::DesktopEntryData, usize),
2024-10-21 13:51:10 -06:00
Iced(TaskWrapper),
OpenFile(Vec<PathBuf>),
OpenInNewTab(PathBuf),
OpenInNewWindow(PathBuf),
OpenTrash,
Preview(PreviewKind),
SetOpenWith(Mime, String),
SetPermissions(PathBuf, u32),
SetSort(String, HeadingOptions, bool),
WindowDrag,
WindowToggleMaximize,
2024-02-26 12:51:22 -07:00
}
2024-01-09 15:34:48 -07:00
#[derive(Clone, Debug)]
2024-01-05 09:36:16 -07:00
pub enum Message {
AddNetworkDrive,
AutoScroll(Option<f32>),
Click(Option<usize>),
DoubleClick(Option<usize>),
ClickRelease(Option<usize>),
Config(TabConfig),
2024-02-26 12:05:29 -07:00
ContextAction(Action),
ContextMenu(Option<Point>, Option<window::Id>),
LocationContextMenuPoint(Option<Point>),
LocationContextMenuIndex(Option<Point>, Option<usize>),
LocationMenuAction(LocationMenuAction),
2024-02-29 11:25:46 -07:00
Drag(Option<Rectangle>),
DragEnd,
2025-02-07 09:11:20 -07:00
EditLocation(Option<EditLocation>),
EditLocationComplete(usize),
EditLocationEnable,
2025-02-07 09:11:20 -07:00
EditLocationSubmit,
OpenInNewTab(PathBuf),
2024-05-09 13:24:06 -06:00
EmptyTrash,
#[cfg(feature = "desktop")]
ExecEntryAction(Option<PathBuf>, usize),
Gallery(bool),
GalleryPrevious,
GalleryNext,
GalleryToggle,
GoNext,
GoPrevious,
2024-02-28 16:16:59 -07:00
ItemDown,
ItemLeft,
ItemRight,
ItemUp,
2024-01-09 15:34:48 -07:00
Location(Location),
LocationUp,
ModifiersChanged(Modifiers),
Open(Option<PathBuf>),
Reload,
RightClick(Option<Point>, Option<usize>),
MiddleClick(usize),
Resize(Rectangle),
2024-02-26 12:05:29 -07:00
Scroll(Viewport),
ScrollTab(f32),
2024-10-09 18:55:09 -06:00
SearchContext(Location, SearchContextWrapper),
SearchReady(bool),
2024-02-29 21:13:03 -07:00
SelectAll,
SelectFirst,
SelectLast,
SetOpenWith(Mime, String),
SetPermissions(PathBuf, u32),
2024-09-11 13:29:00 -06:00
SetSort(HeadingOptions, bool),
2025-02-07 09:11:20 -07:00
TabComplete(PathBuf, Vec<(String, PathBuf)>),
2024-03-04 13:41:18 -07:00
Thumbnail(PathBuf, ItemThumbnail),
2024-02-28 05:18:40 +01:00
ToggleSort(HeadingOptions),
Drop(Option<(Location, ClipboardPaste)>),
DndHover(Location),
DndEnter(Location),
DndLeave(Location),
WindowDrag,
WindowToggleMaximize,
2024-10-02 15:49:13 -06:00
ZoomIn,
ZoomOut,
2024-10-16 10:22:25 +11:00
HighlightDeactivate(usize),
HighlightActivate(usize),
DirectorySize(PathBuf, DirSize),
2024-01-05 09:36:16 -07:00
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum LocationMenuAction {
OpenInNewTab(usize),
OpenInNewWindow(usize),
Preview(usize),
AddToSidebar(usize),
}
impl MenuAction for LocationMenuAction {
type Message = Message;
fn message(&self) -> Self::Message {
Message::LocationMenuAction(*self)
}
}
#[derive(Clone, Debug)]
pub enum DirSize {
Calculating(Controller),
Directory(u64),
NotDirectory,
Error(String),
}
2024-01-06 12:59:36 -07:00
#[derive(Clone, Debug)]
pub enum ItemMetadata {
Path {
metadata: Metadata,
children_opt: Option<usize>,
},
Trash {
metadata: trash::TrashItemMetadata,
entry: trash::TrashItem,
},
2024-09-13 15:13:37 -06:00
SimpleDir {
entries: u64,
},
SimpleFile {
size: u64,
},
#[cfg(feature = "gvfs")]
GvfsPath {
mtime: u64,
size_opt: Option<u64>,
children_opt: Option<usize>,
},
2024-01-06 12:59:36 -07:00
}
impl ItemMetadata {
pub fn is_dir(&self) -> bool {
match self {
Self::Path { metadata, .. } => metadata.is_dir(),
Self::Trash { metadata, .. } => match metadata.size {
2024-01-10 08:53:22 -07:00
trash::TrashItemSize::Entries(_) => true,
trash::TrashItemSize::Bytes(_) => false,
},
2024-09-13 15:13:37 -06:00
Self::SimpleDir { .. } => true,
Self::SimpleFile { .. } => false,
#[cfg(feature = "gvfs")]
Self::GvfsPath { children_opt, .. } => children_opt.is_some(),
2024-01-06 12:59:36 -07:00
}
}
2024-10-09 20:18:43 -06:00
pub fn modified(&self) -> Option<SystemTime> {
match self {
Self::Path { metadata, .. } => metadata.modified().ok(),
#[cfg(feature = "gvfs")]
Self::GvfsPath { mtime, .. } => {
Some(SystemTime::UNIX_EPOCH + Duration::from_secs(*mtime))
}
_ => None,
}
}
pub fn file_size(&self) -> Option<u64> {
match self {
Self::Path { metadata, .. } => (!metadata.is_dir()).then_some(metadata.len()),
Self::Trash { metadata, .. } => match metadata.size {
TrashItemSize::Bytes(size) => Some(size),
TrashItemSize::Entries(_) => None,
},
#[cfg(feature = "gvfs")]
Self::GvfsPath { size_opt, .. } => *size_opt,
2024-10-09 20:18:43 -06:00
_ => None,
}
}
2024-01-06 12:59:36 -07:00
}
2024-10-10 10:05:48 -06:00
#[derive(Debug)]
2024-03-04 13:41:18 -07:00
pub enum ItemThumbnail {
NotImage,
Image(widget::image::Handle, Option<(u32, u32)>),
Svg(widget::svg::Handle),
2024-10-10 10:05:48 -06:00
Text(widget::text_editor::Content),
}
impl Clone for ItemThumbnail {
fn clone(&self) -> Self {
match self {
Self::NotImage => Self::NotImage,
Self::Image(handle, size_opt) => Self::Image(handle.clone(), *size_opt),
Self::Svg(handle) => Self::Svg(handle.clone()),
2024-10-10 11:15:23 -06:00
// Content cannot be cloned simply
Self::Text(content) => {
Self::Text(widget::text_editor::Content::with_text(&content.text()))
}
2024-10-10 10:05:48 -06:00
}
}
2024-03-04 13:41:18 -07:00
}
2024-09-25 13:28:14 -06:00
impl ItemThumbnail {
pub fn new(
path: &Path,
metadata: ItemMetadata,
mime: mime::Mime,
mut thumbnail_size: u32,
max_mem: u64,
jobs: usize,
max_size_mb: u64,
) -> Self {
let thumbnail_cacher =
ThumbnailCacher::new(path, ThumbnailSize::from_pixel_size(thumbnail_size));
match thumbnail_cacher.as_ref() {
Ok(cache) => match cache.get_cached_thumbnail() {
CachedThumbnail::Valid((path, size)) => {
return Self::Image(
widget::image::Handle::from_path(path),
size.map(|s| (s.pixel_size(), s.pixel_size())),
);
}
CachedThumbnail::Failed => {
if mime.type_() != mime::IMAGE {
return Self::NotImage;
}
}
CachedThumbnail::RequiresUpdate(size) => {
thumbnail_size = size.pixel_size();
}
},
Err(err) => {
log::warn!(
"failed to create ThumbnailCache for {}: {}",
path.display(),
err
);
}
}
let size = metadata.file_size().unwrap_or_default();
2024-10-10 10:05:48 -06:00
let check_size = |thumbnailer: &str, max_size| {
if size <= max_size {
true
} else {
log::warn!(
"skipping internal {} thumbnailer for {}: file size {} is larger than {}",
2024-10-10 10:05:48 -06:00
thumbnailer,
path.display(),
2024-10-10 10:05:48 -06:00
format_size(size),
format_size(max_size)
);
false
}
};
let mut tried_supported_file = false;
// First try built-in image thumbnailer
if mime.type_() == mime::IMAGE && check_size("image", max_size_mb * 1000 * 1000) {
tried_supported_file = true;
let dyn_img: Option<image::DynamicImage> = match mime.subtype().as_str() {
"jxl" => match File::open(path) {
Ok(file) => match JxlDecoder::new(file) {
Ok(mut decoder) => {
let mut limits = image::Limits::default();
let max_ram = max_mem * 1000 * 1000 / jobs as u64;
limits.max_alloc = Some(max_ram);
let _ = decoder.set_limits(limits);
match image::DynamicImage::from_decoder(decoder) {
Ok(img) => Some(img),
Err(err) => {
log::warn!("failed to decode jxl {}: {}", path.display(), err);
None
}
}
}
Err(err) => {
log::warn!("failed to create jxl decoder {}: {}", path.display(), err);
None
}
},
Err(err) => {
log::warn!("failed to open path {}: {}", path.display(), err);
None
}
},
_ => {
match image::ImageReader::open(path)
.and_then(image::ImageReader::with_guessed_format)
{
Ok(mut reader) => {
let mut limits = image::Limits::default();
let max_ram = max_mem * 1000 * 1000 / jobs as u64;
limits.max_alloc = Some(max_ram);
reader.limits(limits);
match reader.decode() {
Ok(reader) => Some(reader),
Err(err) => {
log::warn!("failed to decode {}: {}", path.display(), err);
None
}
}
}
Err(err) => {
log::warn!("failed to read {}: {}", path.display(), err);
None
}
}
}
};
2025-10-03 03:24:44 +02:00
if let Some(dyn_img) = dyn_img {
if let Ok(cacher) = thumbnail_cacher.as_ref() {
match cacher.update_with_image(dyn_img) {
Ok(path) => {
return Self::Image(widget::image::Handle::from_path(path), None);
2025-10-03 03:24:44 +02:00
}
Err(err) => {
log::warn!("cacher failed to decode {}: {}", path.display(), err);
}
}
2025-10-03 03:24:44 +02:00
} else {
// Fallback for when thumbnail cacher isn't available.
let thumbnail = dyn_img
.thumbnail(thumbnail_size, thumbnail_size)
.into_rgba8();
return Self::Image(
2025-10-03 03:24:44 +02:00
widget::image::Handle::from_rgba(
thumbnail.width(),
thumbnail.height(),
thumbnail.into_raw(),
),
Some((dyn_img.width(), dyn_img.height())),
);
}
}
}
// Try external thumbnailers.
let thumbnail_dir = thumbnail_cacher
.as_ref()
.ok()
.map(ThumbnailCacher::thumbnail_dir);
if let Some((item_thumbnail, temp_file)) =
Self::generate_thumbnail_external(path, &mime, thumbnail_size, thumbnail_dir)
{
if let Ok(cache) = thumbnail_cacher {
if let Err(err) = cache.update_with_temp_file(temp_file) {
log::warn!("failed to update cache for {}: {}", path.display(), err);
}
}
return item_thumbnail;
}
tried_supported_file = tried_supported_file || !thumbnailer(&mime).is_empty();
// Try internal thumbnailers that don't get cached.
2024-10-10 10:05:48 -06:00
//TODO: adjust limits for internal thumbnailers as desired
if mime.type_() == mime::IMAGE
&& mime.subtype() == mime::SVG
&& check_size("svg", 8 * 1000 * 1000)
{
tried_supported_file = true;
// Try built-in svg thumbnailer
2025-01-28 23:52:11 -05:00
match fs::read(path) {
2024-09-25 13:28:14 -06:00
Ok(data) => {
//TODO: validate SVG data
return Self::Svg(widget::svg::Handle::from_memory(data));
2024-09-25 13:28:14 -06:00
}
Err(err) => {
log::warn!("failed to read {}: {}", path.display(), err);
2024-09-25 13:28:14 -06:00
}
}
2024-10-10 10:05:48 -06:00
} else if mime.type_() == mime::TEXT && check_size("text", 8 * 1000 * 1000) {
/*TODO: fix performance issues, widget::text_editor::Content::with_text forces all text to shape, which blocks rendering
2024-10-10 10:05:48 -06:00
match fs::read_to_string(&path) {
Ok(data) => {
return ItemThumbnail::Text(widget::text_editor::Content::with_text(&data));
}
Err(err) => {
log::warn!("failed to read {}: {}", path.display(), err);
2024-10-10 10:05:48 -06:00
}
}
*/
}
// If we weren't able to create a thumbnail, but we should have
// been able to, create a fail marker so that it isn't tried the
// next time.
if let Ok(cacher) = thumbnail_cacher {
if tried_supported_file {
if let Err(err) = cacher.create_fail_marker() {
log::warn!(
"failed to create thumbnail fail marker for {}: {}",
path.display(),
err
);
}
}
}
Self::NotImage
}
fn generate_thumbnail_external(
path: &Path,
mime: &mime::Mime,
thumbnail_size: u32,
thumbnail_dir: Option<&Path>,
) -> Option<(Self, NamedTempFile)> {
// Try external thumbnailers
2025-10-03 03:24:44 +02:00
for thumbnailer in thumbnailer(mime) {
let is_evince = thumbnailer.exec.starts_with("evince-thumbnailer ");
let prefix = if is_evince {
2024-09-25 13:28:14 -06:00
//TODO: apparmor config for evince-thumbnailer does not allow /tmp/cosmic-files*
"gnome-desktop-"
2024-09-25 13:28:14 -06:00
} else {
"cosmic-files-"
2024-09-25 13:28:14 -06:00
};
// It's preferable to create the tempfile in the same directory as the final cached
2025-10-03 03:24:44 +02:00
// thumbnail to ensure that no copies across filesytems need to be made. However,
// the apparmor config for evince-thumbnailer does not allow this, so we need to
// fallback to the system tempdir.
2025-10-03 03:24:44 +02:00
let dir = if is_evince { None } else { thumbnail_dir };
let file = match dir {
Some(d) => tempfile::Builder::new().prefix(prefix).tempfile_in(d),
None => tempfile::Builder::new().prefix(prefix).tempfile(),
};
let file = match file {
Ok(ok) => ok,
2024-09-25 13:28:14 -06:00
Err(err) => {
log::warn!(
"failed to create temporary file for thumbnail of {}: {}",
path.display(),
2024-09-25 13:28:14 -06:00
err
);
continue;
}
};
2025-01-28 23:52:11 -05:00
let Some(mut command) = thumbnailer.command(path, file.path(), thumbnail_size) else {
continue;
};
match command.status() {
Ok(status) => {
if status.success() {
2024-11-11 09:14:03 -07:00
match image::ImageReader::open(file.path())
.and_then(image::ImageReader::with_guessed_format)
{
Ok(reader) => match reader.decode().map(image::DynamicImage::into_rgb8)
{
Ok(image) => {
return Some((
Self::Image(
widget::image::Handle::from_rgba(
image.width(),
image.height(),
image.into_raw(),
),
None,
),
file,
));
}
Err(err) => {
log::warn!("failed to decode {}: {}", path.display(), err);
}
},
Err(err) => {
log::warn!("failed to read {}: {}", path.display(), err);
}
}
} else {
log::warn!(
"failed to run {:?} for {}: {}",
thumbnailer,
path.display(),
status
);
}
}
Err(err) => {
log::warn!(
"failed to run {thumbnailer:?} for {}: {}",
path.display(),
err
);
2024-09-25 13:28:14 -06:00
}
}
}
None
2024-09-25 13:28:14 -06:00
}
}
#[derive(Clone, Debug)]
2024-01-05 09:36:16 -07:00
pub struct Item {
pub name: String,
pub is_mount_point: bool,
2024-08-20 13:26:10 -06:00
pub display_name: String,
2024-01-06 12:59:36 -07:00
pub metadata: ItemMetadata,
2024-01-05 09:36:16 -07:00
pub hidden: bool,
pub location_opt: Option<Location>,
pub mime: Mime,
2024-01-05 09:36:16 -07:00
pub icon_handle_grid: widget::icon::Handle,
pub icon_handle_list: widget::icon::Handle,
2024-02-29 19:47:27 -07:00
pub icon_handle_list_condensed: widget::icon::Handle,
2024-03-04 13:41:18 -07:00
pub thumbnail_opt: Option<ItemThumbnail>,
2024-02-29 15:38:03 -07:00
pub button_id: widget::Id,
pub pos_opt: Cell<Option<(usize, usize)>>,
2024-02-29 11:25:46 -07:00
pub rect_opt: Cell<Option<Rectangle>>,
2024-01-10 09:47:47 -07:00
pub selected: bool,
2024-10-16 10:22:25 +11:00
pub highlighted: bool,
2025-04-28 23:35:27 +10:00
pub cut: bool,
pub overlaps_drag_rect: bool,
pub dir_size: DirSize,
2024-01-05 09:36:16 -07:00
}
2024-01-05 14:44:20 -07:00
impl Item {
2024-08-20 13:26:10 -06:00
fn display_name(name: &str) -> String {
// In order to wrap at periods and underscores, add a zero width space after each one
name.replace('.', ".\u{200B}").replace('_', "_\u{200B}")
}
pub fn path_opt(&self) -> Option<&PathBuf> {
self.location_opt.as_ref()?.path_opt()
}
2024-10-10 10:05:48 -06:00
pub fn can_gallery(&self) -> bool {
self.mime.type_() == mime::IMAGE || self.mime.type_() == mime::TEXT
2024-10-09 18:55:09 -06:00
}
fn preview(&self) -> Element<'_, Message> {
let spacing = cosmic::theme::active().cosmic().spacing;
2024-03-04 13:41:18 -07:00
// This loads the image only if thumbnailing worked
2024-03-20 09:56:54 -06:00
let icon = widget::icon::icon(self.icon_handle_grid.clone())
.content_fit(ContentFit::Contain)
2025-04-27 02:12:31 +02:00
.size(IconSizes::default().grid())
2024-03-20 09:56:54 -06:00
.into();
2024-03-04 13:41:18 -07:00
match self
.thumbnail_opt
.as_ref()
.unwrap_or(&ItemThumbnail::NotImage)
{
2024-03-20 09:56:54 -06:00
ItemThumbnail::NotImage => icon,
ItemThumbnail::Image(handle, _) => {
if let Some(path) = self.path_opt() {
2024-10-10 10:05:48 -06:00
if self.mime.type_() == mime::IMAGE {
return widget::image(widget::image::Handle::from_path(path)).into();
}
2024-03-20 09:56:54 -06:00
}
widget::image(handle.clone()).into()
2024-03-04 13:41:18 -07:00
}
ItemThumbnail::Svg(handle) => widget::svg(handle.clone()).into(),
2025-10-03 03:24:44 +02:00
ItemThumbnail::Text(content) => widget::text_editor(content)
2024-11-11 23:53:25 +01:00
.class(cosmic::theme::iced::TextEditor::Custom(Box::new(
text_editor_class,
)))
.width(THUMBNAIL_SIZE as f32)
.height(Length::Fixed(THUMBNAIL_SIZE as f32))
.padding(spacing.space_xxs)
.into(),
2024-03-04 13:41:18 -07:00
}
}
pub fn preview_header(&self) -> Vec<Element<'_, Message>> {
let mut row = Vec::with_capacity(3);
row.push(
widget::button::icon(widget::icon::from_name("go-previous-symbolic"))
.on_press(Message::ItemLeft)
.into(),
);
row.push(
widget::button::icon(widget::icon::from_name("go-next-symbolic"))
.on_press(Message::ItemRight)
.into(),
);
if self.can_gallery() {
if let Some(_path) = self.path_opt() {
row.push(
widget::button::icon(widget::icon::from_name("view-fullscreen-symbolic"))
.on_press(Message::Gallery(true))
.into(),
);
}
}
row
}
pub fn preview_view<'a>(
&'a self,
mime_app_cache_opt: Option<&'a mime_app::MimeAppCache>,
military_time: bool,
) -> Element<'a, Message> {
let cosmic_theme::Spacing {
space_xxxs,
space_m,
..
} = theme::active().cosmic().spacing;
2024-02-22 15:04:37 -07:00
let mut column = widget::column().spacing(space_m);
column = column.push(
widget::container(self.preview())
.center_x(Length::Fill)
.max_height(THUMBNAIL_SIZE as f32),
);
2024-01-05 14:44:20 -07:00
let mut details = widget::column().spacing(space_xxxs);
details = details.push(widget::text::heading(self.name.clone()));
details = details.push(widget::text::body(fl!(
"type",
mime = self.mime.to_string()
)));
2024-09-23 12:50:46 -06:00
let mut settings = Vec::new();
if let Some(mime_app_cache) = mime_app_cache_opt {
let mime_apps = mime_app_cache.get(&self.mime);
if !mime_apps.is_empty() {
settings.push(
widget::settings::item::builder(fl!("open-with")).control(
2025-03-15 11:59:03 -04:00
Element::from(
widget::dropdown(
mime_apps,
mime_apps.iter().position(|x| x.is_default),
move |index| index,
)
2025-06-18 11:22:48 -04:00
.icons(Cow::Borrowed(mime_app_cache.icons(&self.mime))),
)
2025-03-15 11:59:03 -04:00
.map(|index| {
let mime_app = &mime_apps[index];
Message::SetOpenWith(self.mime.clone(), mime_app.id.clone())
}),
),
);
}
}
let mut file_metadata = None;
let mut dir_children_count = None;
2024-01-06 12:59:36 -07:00
match &self.metadata {
ItemMetadata::Path {
metadata,
children_opt,
} => {
file_metadata = Some(metadata.clone());
dir_children_count = *children_opt;
}
#[cfg(feature = "gvfs")]
ItemMetadata::GvfsPath { children_opt, .. } => {
// grab the fs::metadata object for gvfs paths since this is run on-demand
if let Some(path) = self.path_opt() {
file_metadata = fs::metadata(path).ok();
2024-01-06 12:59:36 -07:00
}
2024-01-05 14:44:20 -07:00
dir_children_count = *children_opt;
}
_ => {
//TODO: other metadata types
}
}
if let Some(metadata) = file_metadata {
if metadata.is_dir() {
if let Some(children) = dir_children_count {
details = details.push(widget::text::body(fl!("items", items = children)));
2024-01-06 12:59:36 -07:00
}
let size = match &self.dir_size {
DirSize::Calculating(_) => fl!("calculating"),
DirSize::Directory(size) => format_size(*size),
DirSize::NotDirectory => String::new(),
DirSize::Error(err) => err.clone(),
};
if !size.is_empty() {
details = details.push(widget::text::body(fl!("item-size", size = size)));
2024-01-06 12:59:36 -07:00
}
} else {
details = details.push(widget::text::body(fl!(
"item-size",
size = format_size(metadata.len())
)));
}
2024-01-05 14:44:20 -07:00
let date_time_formatter = date_time_formatter(military_time);
let time_formatter = time_formatter(military_time);
2024-09-23 12:50:46 -06:00
if let Ok(time) = metadata.created() {
details = details.push(widget::text::body(fl!(
"item-created",
created = format_time(time, &date_time_formatter, &time_formatter).to_string()
)));
}
if let Ok(time) = metadata.modified() {
details = details.push(widget::text::body(fl!(
"item-modified",
modified = format_time(time, &date_time_formatter, &time_formatter).to_string()
)));
}
if let Ok(time) = metadata.accessed() {
details = details.push(widget::text::body(fl!(
"item-accessed",
accessed = format_time(time, &date_time_formatter, &time_formatter).to_string()
)));
}
#[cfg(unix)]
if let Some(path) = self.path_opt() {
use std::os::unix::fs::MetadataExt;
let mode = metadata.mode();
let user_name = get_user_by_uid(metadata.uid())
.and_then(|user| user.name().to_str().map(ToOwned::to_owned))
.unwrap_or_default();
let user_path = path.clone();
settings.push(
widget::settings::item::builder(user_name)
.description(fl!("owner"))
.control(widget::dropdown(
2025-06-18 11:22:48 -04:00
Cow::Borrowed(MODE_NAMES.as_slice()),
Some(get_mode_part(mode, MODE_SHIFT_USER).try_into().unwrap()),
move |selected| {
Message::SetPermissions(
user_path.clone(),
set_mode_part(
mode,
MODE_SHIFT_USER,
selected.try_into().unwrap(),
),
)
},
)),
);
let group_name = get_group_by_gid(metadata.gid())
.and_then(|group| group.name().to_str().map(ToOwned::to_owned))
.unwrap_or_default();
let group_path = path.clone();
settings.push(
widget::settings::item::builder(group_name)
.description(fl!("group"))
.control(widget::dropdown(
Cow::Borrowed(MODE_NAMES.as_slice()),
Some(get_mode_part(mode, MODE_SHIFT_GROUP).try_into().unwrap()),
move |selected| {
Message::SetPermissions(
group_path.clone(),
set_mode_part(
mode,
MODE_SHIFT_GROUP,
selected.try_into().unwrap(),
),
)
},
)),
);
let other_path = path.clone();
settings.push(widget::settings::item::builder(fl!("other")).control(
widget::dropdown(
Cow::Borrowed(MODE_NAMES.as_slice()),
Some(get_mode_part(mode, MODE_SHIFT_OTHER).try_into().unwrap()),
move |selected| {
Message::SetPermissions(
other_path.clone(),
set_mode_part(mode, MODE_SHIFT_OTHER, selected.try_into().unwrap()),
)
},
),
));
2024-01-05 16:17:23 -07:00
}
2024-01-05 14:44:20 -07:00
}
if let Some(path) = self.path_opt() {
if let Ok(img) = image::image_dimensions(path) {
let (width, height) = img;
details = details.push(widget::text::body(format!("{width}x{height}")));
}
2024-09-23 12:55:46 -06:00
}
column = column.push(details);
if let Some(path) = self.path_opt() {
column = column.push(
widget::button::standard(fl!("open")).on_press(Message::Open(Some(path.clone()))),
);
}
2024-01-05 14:44:20 -07:00
2024-09-23 12:50:46 -06:00
if !settings.is_empty() {
let mut section = widget::settings::section();
section = section.extend(settings);
2024-09-23 12:50:46 -06:00
column = column.push(section);
}
2024-02-22 16:17:39 -07:00
column.into()
2024-01-05 14:44:20 -07:00
}
pub fn replace_view(&self, heading: String, military_time: bool) -> Element<'_, Message> {
let cosmic_theme::Spacing { space_xxxs, .. } = theme::active().cosmic().spacing;
let mut row = widget::row().spacing(space_xxxs);
row = row.push(self.preview());
let mut column = widget::column().spacing(space_xxxs);
column = column.push(widget::text::heading(heading));
//TODO: translate!
//TODO: correct display of folder size?
if let ItemMetadata::Path {
metadata,
children_opt,
} = &self.metadata
{
if metadata.is_dir() {
if let Some(children) = children_opt {
column = column.push(widget::text::body(format!("Items: {children}")));
}
} else {
column = column.push(widget::text::body(format!(
"Size: {}",
format_size(metadata.len())
)));
}
if let Ok(time) = metadata.modified() {
let date_time_formatter = date_time_formatter(military_time);
let time_formatter = time_formatter(military_time);
column = column.push(widget::text::body(format!(
"Last modified: {}",
format_time(time, &date_time_formatter, &time_formatter)
)));
}
} else {
//TODO: other metadata
}
row = row.push(column);
row.into()
}
2024-01-05 14:44:20 -07:00
}
2024-05-29 23:33:12 +02:00
#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)]
2024-01-05 09:36:16 -07:00
pub enum View {
Grid,
List,
}
2024-03-13 23:23:00 -04:00
#[derive(Clone, Copy, Debug, Hash, PartialEq, PartialOrd, Ord, Eq, Deserialize, Serialize)]
2024-02-27 22:28:06 -07:00
pub enum HeadingOptions {
Name = 0,
2024-02-28 05:18:40 +01:00
Modified,
Size,
TrashedOn,
2024-02-28 05:18:40 +01:00
}
2024-01-05 09:36:16 -07:00
impl fmt::Display for HeadingOptions {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Name => write!(f, "{}", fl!("name")),
Self::Modified => write!(f, "{}", fl!("modified")),
Self::Size => write!(f, "{}", fl!("size")),
Self::TrashedOn => write!(f, "{}", fl!("trashed-on")),
}
}
}
impl HeadingOptions {
pub fn names() -> Vec<String> {
vec![
Self::Name.to_string(),
Self::Modified.to_string(),
Self::Size.to_string(),
Self::TrashedOn.to_string(),
]
}
}
2024-08-20 13:26:10 -06:00
#[derive(Clone, Debug)]
pub enum Mode {
App,
Desktop,
Dialog(DialogKind),
}
impl Mode {
/// Whether multiple files can be selected in this mode
pub fn multiple(&self) -> bool {
match self {
Self::App | Self::Desktop => true,
Self::Dialog(dialog) => dialog.multiple(),
2024-08-20 13:26:10 -06:00
}
}
}
2024-10-09 18:55:09 -06:00
struct SearchContext {
results_rx: mpsc::Receiver<(PathBuf, String, Metadata)>,
ready: Arc<atomic::AtomicBool>,
2024-10-09 20:18:43 -06:00
last_modified_opt: Arc<RwLock<Option<SystemTime>>>,
2024-10-09 18:55:09 -06:00
}
pub struct SearchContextWrapper(Option<SearchContext>);
impl Clone for SearchContextWrapper {
fn clone(&self) -> Self {
Self(None)
}
}
impl fmt::Debug for SearchContextWrapper {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("SearchContextWrapper").finish()
}
}
// TODO when creating items, pass <Arc<SelectedItems>> to each item
// as a drag data, so that when dnd is initiated, they are all included
2024-01-05 09:36:16 -07:00
pub struct Tab {
2024-02-29 15:38:03 -07:00
//TODO: make more items private
2024-01-05 16:17:23 -07:00
pub location: Location,
pub location_ancestors: Vec<(Location, String)>,
pub location_title: String,
pub location_context_menu_point: Option<Point>,
pub location_context_menu_index: Option<usize>,
2024-01-05 09:36:16 -07:00
pub context_menu: Option<Point>,
2024-08-20 13:26:10 -06:00
pub mode: Mode,
2024-02-29 16:25:54 -07:00
pub scroll_opt: Option<AbsoluteOffset>,
pub size_opt: Cell<Option<Size>>,
pub viewport_opt: Option<Rectangle>,
pub item_view_size_opt: Cell<Option<Size>>,
2025-02-07 09:11:20 -07:00
pub edit_location: Option<EditLocation>,
2024-02-28 15:19:07 -07:00
pub edit_location_id: widget::Id,
pub history_i: usize,
pub history: Vec<Location>,
pub config: TabConfig,
pub thumb_config: ThumbCfg,
2024-10-02 14:53:24 -06:00
pub sort_name: HeadingOptions,
pub sort_direction: bool,
pub gallery: bool,
pub(crate) parent_item_opt: Option<Item>,
2024-03-20 16:27:00 -06:00
pub(crate) items_opt: Option<Vec<Item>>,
pub dnd_hovered: Option<(Location, Instant)>,
pub(crate) scrollable_id: widget::Id,
2024-02-29 15:21:59 -07:00
select_focus: Option<usize>,
2024-05-27 10:23:48 -06:00
select_range: Option<(usize, usize)>,
clicked: Option<usize>,
selected_clicked: bool,
modifiers: Modifiers,
last_right_click: Option<usize>,
2024-10-09 18:55:09 -06:00
search_context: Option<SearchContext>,
2025-09-15 15:13:03 +02:00
date_time_formatter: DateTimeFormatter<fieldsets::YMDT>,
time_formatter: DateTimeFormatter<fieldsets::T>,
watch_drag: bool,
window_id: Option<window::Id>,
2024-01-05 09:36:16 -07:00
}
async fn calculate_dir_size(path: &Path, controller: Controller) -> Result<u64, OperationError> {
let mut total = 0;
for entry_res in WalkDir::new(path) {
controller
.check()
.await
.map_err(|s| OperationError::from_state(s, &controller))?;
feat: use io_uring / IOCP when available for async file IO (#911) Spawns a single thread for handling async file IO on the [compio runtime](https://github.com/compio-rs/compio). It is a completion-based IO runtime that can dynamically select a polling mechanism at runtime. It defaults to io_uring on Linux, IOCP on Windows, and the polling crate everywhere else. On Linux systems where io_uring is unavailable or disabled, it will fall back to the polling crate. This eliminates most of the threads that were needed previously. It significantly reduced the amount of memory needed in the recursive Context to get a good transfer rate for each copy operation—from a 4 MB buffer to 128 KB. Copies on a nvme drive are somewhat faster with the async IO changes, and use less CPU than before. Although it uses a single thread for non-blocking tasks, it still manages to 100% max out my nvme drive's activity for the whole duration of multiple long transfers. But it would be possible to enable compio's dispatcher to spread operations across worker threads if necessary. All but the extract and compress operations were updated to be async. I had to switch the `CondVar` in the `Controller` to a `tokio::sync::Notify` to prevent the IO thread from being put to sleep when an operation is paused. Fixed a deadlock in the `operation_copy` test function that was performing an operation without concurrently pulling from the channel in the operation. Reduced the rate that `Message::None` is sent from a subscription to trigger a UI redraw, and fixed it to not run when operations are paused.
2025-04-09 23:15:07 +02:00
//TODO: report more errors?
if let Ok(entry) = entry_res {
if let Ok(metadata) = entry.metadata() {
if metadata.is_file() {
total += metadata.len();
}
}
}
feat: use io_uring / IOCP when available for async file IO (#911) Spawns a single thread for handling async file IO on the [compio runtime](https://github.com/compio-rs/compio). It is a completion-based IO runtime that can dynamically select a polling mechanism at runtime. It defaults to io_uring on Linux, IOCP on Windows, and the polling crate everywhere else. On Linux systems where io_uring is unavailable or disabled, it will fall back to the polling crate. This eliminates most of the threads that were needed previously. It significantly reduced the amount of memory needed in the recursive Context to get a good transfer rate for each copy operation—from a 4 MB buffer to 128 KB. Copies on a nvme drive are somewhat faster with the async IO changes, and use less CPU than before. Although it uses a single thread for non-blocking tasks, it still manages to 100% max out my nvme drive's activity for the whole duration of multiple long transfers. But it would be possible to enable compio's dispatcher to spread operations across worker threads if necessary. All but the extract and compress operations were updated to be async. I had to switch the `CondVar` in the `Controller` to a `tokio::sync::Notify` to prevent the IO thread from being put to sleep when an operation is paused. Fixed a deadlock in the `operation_copy` test function that was performing an operation without concurrently pulling from the channel in the operation. Reduced the rate that `Message::None` is sent from a subscription to trigger a UI redraw, and fixed it to not run when operations are paused.
2025-04-09 23:15:07 +02:00
// Yield in case this process takes a while.
tokio::task::yield_now().await;
}
Ok(total)
}
2024-09-09 13:27:54 -06:00
fn folder_name<P: AsRef<Path>>(path: P) -> (String, bool) {
let path = path.as_ref();
let mut found_home = false;
let name = match path.file_name() {
Some(name) => {
if path == crate::home_dir() {
found_home = true;
fl!("home")
} else {
// This is not optimized but it helps ensure the same display names
match item_from_path(path, IconSizes::default()) {
Ok(item) => item.display_name,
Err(_err) => name.to_string_lossy().into_owned(),
}
2024-09-09 13:27:54 -06:00
}
}
None => {
fl!("filesystem")
}
};
(name, found_home)
}
2024-10-04 11:52:34 +02:00
// parse .hidden file and return files path
fn parse_hidden_file(path: &PathBuf) -> Box<[String]> {
let Ok(file) = File::open(path) else {
return Default::default();
2024-10-04 11:52:34 +02:00
};
2025-01-28 23:52:11 -05:00
BufReader::new(file)
.lines()
.map_while(Result::ok)
.filter_map(|line| {
2025-01-28 23:52:11 -05:00
let line = line.trim();
(!line.is_empty()).then_some(line.to_owned())
})
.collect()
2024-10-04 11:52:34 +02:00
}
2024-01-03 15:27:32 -07:00
impl Tab {
pub fn new(
location: Location,
config: TabConfig,
thumb_config: ThumbCfg,
sorting_options: Option<&FxOrderMap<String, (HeadingOptions, bool)>>,
scrollable_id: widget::Id,
window_id: Option<window::Id>,
) -> Self {
let location_str = location.to_string();
let (sort_name, sort_direction) = sorting_options
.and_then(|opts| opts.get(&location_str))
.or_else(|| SORT_OPTION_FALLBACK.get(&location_str))
.copied()
2025-10-03 03:24:44 +02:00
.unwrap_or((HeadingOptions::Name, true));
let location = location.normalize();
let location_ancestors = location.ancestors();
let location_title = location.title();
let history = vec![location.clone()];
Self {
location,
location_ancestors,
location_title,
2024-01-03 15:27:32 -07:00
context_menu: None,
location_context_menu_point: None,
location_context_menu_index: None,
2024-08-20 13:26:10 -06:00
mode: Mode::App,
2024-02-26 12:05:29 -07:00
scroll_opt: None,
size_opt: Cell::new(None),
viewport_opt: None,
item_view_size_opt: Cell::new(None),
2024-01-29 11:58:36 -07:00
edit_location: None,
2024-02-28 15:19:07 -07:00
edit_location_id: widget::Id::unique(),
history_i: 0,
history,
config,
thumb_config,
sort_name,
sort_direction,
gallery: false,
parent_item_opt: None,
2024-02-29 13:42:13 -07:00
items_opt: None,
scrollable_id,
2024-02-29 15:21:59 -07:00
select_focus: None,
2024-05-27 10:23:48 -06:00
select_range: None,
clicked: None,
dnd_hovered: None,
selected_clicked: false,
modifiers: Modifiers::default(),
last_right_click: None,
2024-10-09 18:55:09 -06:00
search_context: None,
date_time_formatter: date_time_formatter(config.military_time),
time_formatter: time_formatter(config.military_time),
watch_drag: true,
window_id,
2024-01-03 15:27:32 -07:00
}
}
pub fn title(&self) -> String {
//TODO: is it possible to return a &str?
self.location_title.clone()
2024-01-03 15:27:32 -07:00
}
pub const fn items_opt(&self) -> Option<&Vec<Item>> {
2024-02-29 13:42:13 -07:00
self.items_opt.as_ref()
}
pub const fn items_opt_mut(&mut self) -> Option<&mut Vec<Item>> {
2024-08-20 13:26:10 -06:00
self.items_opt.as_mut()
}
pub fn set_items(&mut self, mut items: Vec<Item>) {
let selected = self.selected_locations();
for item in &mut items {
item.selected = false;
if let Some(location) = &item.location_opt {
if selected.contains(location) {
item.selected = true;
}
}
}
2024-02-29 13:42:13 -07:00
self.items_opt = Some(items);
}
2025-04-28 23:35:27 +10:00
pub fn cut_selected(&mut self) {
if let Some(ref mut items) = self.items_opt {
for item in items.iter_mut() {
item.cut = item.selected;
}
}
}
pub fn refresh_cut(&mut self, locations: &[PathBuf]) {
if let Some(ref mut items) = self.items_opt {
for item in items.iter_mut() {
item.cut = false;
if let Some(location_path) = item.location_opt.as_ref().and_then(Location::path_opt)
{
if locations.contains(location_path) {
2025-04-28 23:35:27 +10:00
item.cut = true;
}
}
}
}
}
pub fn selected_locations(&self) -> Vec<Location> {
if let Some(ref items) = self.items_opt {
items
.iter()
.filter_map(|item| {
if item.selected {
item.location_opt.clone()
} else {
None
}
})
.collect()
} else {
Vec::new()
}
}
2024-02-29 13:42:13 -07:00
pub fn select_all(&mut self) {
if let Some(ref mut items) = self.items_opt {
for item in items.iter_mut() {
if !self.config.show_hidden && item.hidden {
item.selected = false;
continue;
}
item.selected = true;
}
}
}
pub fn select_none(&mut self) -> bool {
2024-02-29 15:21:59 -07:00
self.select_focus = None;
2024-02-29 13:42:13 -07:00
let mut had_selection = false;
if let Some(ref mut items) = self.items_opt {
for item in items.iter_mut() {
if item.selected {
item.selected = false;
had_selection = true;
}
}
}
had_selection
}
2024-02-29 15:21:59 -07:00
pub fn select_name(&mut self, name: &str) {
self.select_focus = None;
2024-02-29 14:34:41 -07:00
if let Some(ref mut items) = self.items_opt {
for (i, item) in items.iter_mut().enumerate() {
item.selected = item.name == name;
if item.selected {
self.select_focus = Some(i);
}
}
}
}
2024-11-19 20:17:58 -07:00
pub fn select_paths(&mut self, paths: Vec<PathBuf>) {
self.select_focus = None;
if let Some(ref mut items) = self.items_opt {
for (i, item) in items.iter_mut().enumerate() {
2024-11-19 20:17:58 -07:00
item.selected = false;
if let Some(path) = item.path_opt() {
if paths.contains(path) {
item.selected = true;
self.select_focus = Some(i);
2024-11-19 20:17:58 -07:00
}
}
2024-02-29 14:34:41 -07:00
}
}
}
2024-02-29 15:21:59 -07:00
fn select_position(&mut self, row: usize, col: usize, mod_shift: bool) -> bool {
let mut start = (row, col);
let mut end = (row, col);
if mod_shift {
2024-05-27 10:23:48 -06:00
if self.select_focus.is_none() || self.select_range.is_none() {
// Set select range to initial state if necessary
self.select_range = self.select_focus.map(|i| (i, i));
2024-02-29 15:21:59 -07:00
}
2024-05-27 10:23:48 -06:00
if let Some(pos) = self.select_range_start_pos_opt() {
2024-02-29 15:21:59 -07:00
if pos.0 < row || (pos.0 == row && pos.1 < col) {
start = pos;
} else {
end = pos;
}
2024-02-29 15:21:59 -07:00
}
2024-05-27 10:23:48 -06:00
}
2024-02-29 15:21:59 -07:00
let mut found = false;
if let Some(ref mut items) = self.items_opt {
for (i, item) in items.iter_mut().enumerate() {
item.selected = false;
let pos = match item.pos_opt.get() {
Some(some) => some,
None => continue,
};
if pos.0 < start.0 || (pos.0 == start.0 && pos.1 < start.1) {
// Before start
continue;
}
if pos.0 > end.0 || (pos.0 == end.0 && pos.1 > end.1) {
// After end
continue;
}
if pos == (row, col) {
// Update focus if this is what we wanted to select
self.select_focus = Some(i);
2024-05-27 10:23:48 -06:00
self.select_range = if mod_shift {
self.select_range.map(|r| (r.0, i))
} else {
Some((i, i))
};
found = true;
}
2024-02-29 15:21:59 -07:00
item.selected = true;
}
}
2024-02-29 15:21:59 -07:00
found
}
pub fn select_rect(&mut self, rect: Rectangle, mod_ctrl: bool, mod_shift: bool) {
if let Some(ref mut items) = self.items_opt {
for item in items.iter_mut() {
let was_overlapped = item.overlaps_drag_rect;
item.overlaps_drag_rect = item.rect_opt.get().is_some_and(|r| r.intersects(&rect));
item.selected = if mod_ctrl || mod_shift {
if was_overlapped == item.overlaps_drag_rect {
item.selected
} else {
!item.selected
}
} else {
item.overlaps_drag_rect
2024-02-29 15:21:59 -07:00
};
}
}
2024-02-29 15:21:59 -07:00
}
2024-02-29 20:53:15 -07:00
pub fn select_focus_id(&self) -> Option<widget::Id> {
2024-02-29 15:38:03 -07:00
let items = self.items_opt.as_ref()?;
let item = items.get(self.select_focus?)?;
Some(item.button_id.clone())
}
2024-02-29 15:21:59 -07:00
fn select_focus_pos_opt(&self) -> Option<(usize, usize)> {
let items = self.items_opt.as_ref()?;
let item = items.get(self.select_focus?)?;
item.pos_opt.get()
}
2024-02-29 16:25:54 -07:00
fn select_focus_scroll(&mut self) -> Option<AbsoluteOffset> {
let items = self.items_opt.as_ref()?;
let item = items.get(self.select_focus?)?;
let rect = item.rect_opt.get()?;
//TODO: move to function
let visible_rect = {
let point = match self.scroll_opt {
Some(offset) => Point::new(0.0, offset.y),
None => Point::new(0.0, 0.0),
};
let size = self
.item_view_size_opt
.get()
.unwrap_or_else(|| Size::new(0.0, 0.0));
2024-02-29 16:25:54 -07:00
Rectangle::new(point, size)
};
if rect.y < visible_rect.y {
// Scroll up to rect
self.scroll_opt = Some(AbsoluteOffset { x: 0.0, y: rect.y });
self.scroll_opt
} else if (rect.y + rect.height) > (visible_rect.y + visible_rect.height) {
// Scroll down to rect
self.scroll_opt = Some(AbsoluteOffset {
x: 0.0,
y: rect.y + rect.height - visible_rect.height,
});
self.scroll_opt
} else {
// Do not scroll
None
}
}
2024-05-27 10:23:48 -06:00
fn select_range_start_pos_opt(&self) -> Option<(usize, usize)> {
2024-02-29 15:21:59 -07:00
let items = self.items_opt.as_ref()?;
2024-05-27 10:23:48 -06:00
let item = items.get(self.select_range.map(|r| r.0)?)?;
2024-02-29 15:21:59 -07:00
item.pos_opt.get()
}
2024-02-29 21:13:03 -07:00
fn select_first_pos_opt(&self) -> Option<(usize, usize)> {
let items = self.items_opt.as_ref()?;
let mut first = None;
for item in items {
2024-02-29 21:13:03 -07:00
if !item.selected {
continue;
}
let (row, col) = match item.pos_opt.get() {
Some(some) => some,
None => continue,
};
first = Some(match first {
2025-01-28 23:52:11 -05:00
Some((first_row, first_col)) => match row.cmp(&first_row) {
Ordering::Less => (row, col),
Ordering::Equal => (row, col.min(first_row)),
Ordering::Greater => (first_row, first_col),
},
2024-02-29 21:13:03 -07:00
None => (row, col),
});
}
first
}
fn select_last_pos_opt(&self) -> Option<(usize, usize)> {
let items = self.items_opt.as_ref()?;
let mut last = None;
for item in items {
2024-02-29 21:13:03 -07:00
if !item.selected {
continue;
}
let (row, col) = match item.pos_opt.get() {
Some(some) => some,
None => continue,
};
last = Some(match last {
2025-01-28 23:52:11 -05:00
Some((last_row, last_col)) => match row.cmp(&last_row) {
Ordering::Greater => (row, col),
Ordering::Equal => (row, col.max(last_row)),
Ordering::Less => (last_row, last_col),
},
2024-02-29 21:13:03 -07:00
None => (row, col),
});
}
last
}
pub fn change_location(&mut self, location: &Location, history_i_opt: Option<usize>) {
self.location = location.normalize();
self.location_ancestors = self.location.ancestors();
self.location_title = self.location.title();
self.context_menu = None;
self.edit_location = None;
self.items_opt = None;
//TODO: remember scroll by location?
self.scroll_opt = None;
self.select_focus = None;
2024-10-09 18:55:09 -06:00
self.search_context = None;
if let Some(history_i) = history_i_opt {
// Navigating in history
self.history_i = history_i;
} else {
// Truncate history to remove next entries
self.history.truncate(self.history_i + 1);
// Compact consecutive matching paths
{
let mut remove = false;
if let Some(last_location) = self.history.last() {
if let Some(last_path) = last_location.path_opt() {
if let Some(path) = location.path_opt() {
remove = last_path == path;
}
}
}
if remove {
self.history.pop();
}
}
// Push to the front of history
self.history_i = self.history.len();
self.history.push(location.clone());
}
}
2024-02-26 14:11:45 -07:00
pub fn update(&mut self, message: Message, modifiers: Modifiers) -> Vec<Command> {
let mut commands = Vec::new();
2024-01-03 15:27:32 -07:00
let mut cd = None;
let mut history_i_opt = None;
2024-08-20 13:26:10 -06:00
let mod_ctrl = modifiers.contains(Modifiers::CTRL) && self.mode.multiple();
let mod_shift = modifiers.contains(Modifiers::SHIFT) && self.mode.multiple();
let last_context_menu = self.context_menu;
2024-01-03 15:27:32 -07:00
match message {
Message::AddNetworkDrive => {
commands.push(Command::AddNetworkDrive);
}
Message::AutoScroll(auto_scroll) => {
commands.push(Command::AutoScroll(auto_scroll));
}
Message::ClickRelease(click_i_opt) => {
// Single click to open.
if !mod_ctrl && self.config.single_click {
let mut paths_to_open = Vec::new();
if let Some(ref mut items) = self.items_opt {
for (i, item) in items.iter_mut().enumerate() {
if Some(i) == click_i_opt {
if let Some(location) = &item.location_opt {
if item.metadata.is_dir() {
cd = Some(location.clone());
} else if let Some(path) = location.path_opt() {
paths_to_open.push(path.clone());
} else {
log::warn!("no path for item {item:?}");
}
} else {
log::warn!("no location for item {item:?}");
}
}
}
}
if !paths_to_open.is_empty() {
commands.push(Command::OpenFile(paths_to_open));
}
}
if click_i_opt != self.clicked.take() {
self.context_menu = None;
self.location_context_menu_index = None;
if let Some(ref mut items) = self.items_opt {
for (i, item) in items.iter_mut().enumerate() {
if mod_ctrl {
if Some(i) == click_i_opt && item.selected {
item.selected = false;
self.select_range = None;
}
} else if Some(i) != click_i_opt {
2024-05-26 19:50:41 -06:00
item.selected = false;
}
}
}
}
}
Message::DragEnd => {
self.clicked = None;
self.watch_drag = true;
}
Message::DoubleClick(click_i_opt) => {
if let Some(clicked_item) = self
.items_opt
.as_ref()
.and_then(|items| click_i_opt.and_then(|click_i| items.get(click_i)))
{
2024-09-13 15:13:37 -06:00
if let Some(location) = &clicked_item.location_opt {
if clicked_item.metadata.is_dir() {
2024-09-13 15:13:37 -06:00
cd = Some(location.clone());
2025-01-28 23:52:11 -05:00
} else if let Some(path) = location.path_opt() {
commands.push(Command::OpenFile(vec![path.clone()]));
} else {
log::warn!("no path for item {clicked_item:?}");
}
} else {
log::warn!("no location for item {clicked_item:?}");
}
} else {
log::warn!("no item for click index {click_i_opt:?}");
}
}
Message::Click(click_i_opt) => {
self.selected_clicked = false;
self.context_menu = None;
self.edit_location = None;
self.location_context_menu_index = None;
2024-05-26 19:50:41 -06:00
if click_i_opt.is_none() {
self.clicked = click_i_opt;
}
2024-05-27 10:23:48 -06:00
if mod_shift {
if let Some(click_i) = click_i_opt {
self.select_range = self
.select_range
.map_or(Some((click_i, click_i)), |r| Some((r.0, click_i)));
if let Some(range) = self.select_range {
let range_min = range.0.min(range.1);
let range_max = range.0.max(range.1);
// A sorted tab's items can't be linearly selected
// Let's say we have:
// index | file
// 0 | file0
// 1 | file1
// 2 | file2
// This is both the default sort and internal ordering
// When sorted it may be displayed as:
// 1 | file1
// 0 | file0
// 2 | file2
// However, the internal ordering is still the same thus
// linearly selecting items doesn't work. Shift selecting
// file0 and file2 would select indices 0 to 2 when it should
// select indices 0 AND 2 from items_opt
let indices: Vec<_> = self
.column_sort()
.map(|sorted| sorted.into_iter().map(|(i, _)| i).collect())
.unwrap_or_else(|| {
let len = self
.items_opt
.as_deref()
.map(<[Item]>::len)
.unwrap_or_default();
(0..len).collect()
});
// Find the true indices for the min and max element w.r.t.
// a sorted tab.
let min = indices
.iter()
.copied()
.position(|offset| offset == range_min)
.unwrap_or_default();
// We can't skip `min_real` elements here because the index of
// `max` may actually be before `min` in a sorted tab
let max = indices
.iter()
.copied()
.position(|offset| offset == range_max)
.unwrap_or(indices.len());
let min_real = min.min(max);
let max_real = max.max(min);
if let Some(ref mut items) = self.items_opt {
for index in indices
.into_iter()
.skip(min_real)
.take(max_real - min_real + 1)
{
if let Some(item) = items.get_mut(index) {
item.selected = true;
}
}
2024-02-26 14:31:50 -07:00
}
2024-05-27 10:23:48 -06:00
}
self.clicked = click_i_opt;
self.select_focus = click_i_opt;
self.selected_clicked = true;
}
} else {
let dont_unset = mod_ctrl
|| self.column_sort().is_some_and(|l| {
l.iter()
.any(|&(e_i, e)| Some(e_i) == click_i_opt && e.selected)
2024-05-27 10:23:48 -06:00
});
if let Some(ref mut items) = self.items_opt {
for (i, item) in items.iter_mut().enumerate() {
if Some(i) == click_i_opt {
// Filter out selection if it does not match dialog kind
2024-08-20 13:26:10 -06:00
if let Mode::Dialog(dialog) = &self.mode {
2024-05-27 10:23:48 -06:00
let item_is_dir = item.metadata.is_dir();
if item_is_dir != dialog.is_dir() {
// Allow selecting folder if dialog is for files to make it
// possible to double click
//TODO: clear any other selection when selecting a folder
if !item_is_dir {
continue;
}
}
}
if !item.selected {
self.clicked = click_i_opt;
item.selected = true;
}
self.select_range = Some((i, i));
self.select_focus = click_i_opt;
2024-05-27 10:23:48 -06:00
self.selected_clicked = true;
} else if !dont_unset && item.selected {
2024-05-26 19:50:41 -06:00
self.clicked = click_i_opt;
2024-05-27 10:23:48 -06:00
item.selected = false;
2024-05-26 19:50:41 -06:00
}
2024-01-03 15:27:32 -07:00
}
}
2024-02-29 21:13:03 -07:00
}
2024-01-03 15:27:32 -07:00
}
Message::Config(config) => {
2024-10-02 14:53:24 -06:00
// View is preserved for existing tabs
let view = self.config.view;
let military_time_changed = self.config.military_time != config.military_time;
let show_hidden_changed = self.config.show_hidden != config.show_hidden;
self.config = config;
2024-10-02 14:53:24 -06:00
self.config.view = view;
if military_time_changed {
self.date_time_formatter = date_time_formatter(self.config.military_time);
self.time_formatter = time_formatter(self.config.military_time);
}
if show_hidden_changed {
if let Location::Search(path, term, ..) = &self.location {
cd = Some(Location::Search(
path.clone(),
term.clone(),
self.config.show_hidden,
Instant::now(),
));
}
}
// Unhighlight all items when config changes
if let Some(ref mut items) = self.items_opt {
for item in items.iter_mut() {
item.highlighted = false;
}
}
}
2024-02-26 12:05:29 -07:00
Message::ContextAction(action) => {
// Close context menu
self.context_menu = None;
2024-02-26 14:11:45 -07:00
commands.push(Command::Action(action));
2024-02-26 12:05:29 -07:00
}
Message::ContextMenu(point_opt, _) => {
self.edit_location = None;
if point_opt.is_none() || !mod_shift {
self.context_menu = point_opt;
2025-10-09 19:17:55 -06:00
self.location_context_menu_index = None;
//TODO: hack for clearing selecting when right clicking empty space
if self.context_menu.is_some() && self.last_right_click.take().is_none() {
if let Some(ref mut items) = self.items_opt {
for item in items.iter_mut() {
item.selected = false;
}
}
}
}
2024-02-26 12:05:29 -07:00
}
Message::LocationContextMenuPoint(point_opt) => {
2025-10-09 19:17:55 -06:00
self.context_menu = None;
self.location_context_menu_point = point_opt;
}
Message::LocationContextMenuIndex(p, index_opt) => {
2025-10-09 19:17:55 -06:00
self.context_menu = None;
self.location_context_menu_point = p;
self.location_context_menu_index = index_opt;
}
Message::LocationMenuAction(action) => {
self.location_context_menu_index = None;
let path_for_index = |ancestor_index| {
self.location
.path_opt()
.and_then(|path| path.ancestors().nth(ancestor_index))
.map(Path::to_path_buf)
};
match action {
LocationMenuAction::OpenInNewTab(ancestor_index) => {
if let Some(path) = path_for_index(ancestor_index) {
commands.push(Command::OpenInNewTab(path));
}
}
LocationMenuAction::OpenInNewWindow(ancestor_index) => {
if let Some(path) = path_for_index(ancestor_index) {
commands.push(Command::OpenInNewWindow(path));
}
}
LocationMenuAction::Preview(ancestor_index) => {
if let Some(path) = path_for_index(ancestor_index) {
//TODO: blocking code, run in command
match item_from_path(&path, IconSizes::default()) {
Ok(item) => {
commands.push(Command::Preview(PreviewKind::Custom(
PreviewItem(item),
)));
}
Err(err) => {
log::warn!(
"failed to get item from path {}: {}",
path.display(),
err
);
}
}
}
}
LocationMenuAction::AddToSidebar(ancestor_index) => {
if let Some(path) = path_for_index(ancestor_index) {
commands.push(Command::AddToSidebar(path));
} else {
log::warn!(
"no ancestor {ancestor_index} for location {:?}",
self.location
);
}
}
}
}
2025-01-28 23:52:11 -05:00
Message::Drag(rect_opt) => {
self.watch_drag = false;
2025-01-28 23:52:11 -05:00
if let Some(rect) = rect_opt {
self.context_menu = None;
self.location_context_menu_index = None;
if self.mode.multiple() {
self.select_rect(rect, mod_ctrl, mod_shift);
}
2024-02-29 21:13:03 -07:00
if self.select_focus.take().is_some() {
// Unfocus currently focused button
2024-10-21 13:51:10 -06:00
commands.push(Command::Iced(
widget::button::focus(widget::Id::unique()).into(),
));
2024-02-29 21:13:03 -07:00
}
2024-02-26 12:05:29 -07:00
}
2025-01-28 23:52:11 -05:00
}
2024-01-29 11:58:36 -07:00
Message::EditLocation(edit_location) => {
2025-02-07 09:11:20 -07:00
self.edit_location = edit_location;
if self.edit_location.is_some() {
2024-10-21 13:51:10 -06:00
commands.push(Command::Iced(
widget::text_input::focus(self.edit_location_id.clone()).into(),
));
2024-02-28 15:19:07 -07:00
}
2025-02-07 09:11:20 -07:00
}
Message::EditLocationComplete(selected) => {
if let Some(mut edit_location) = self.edit_location.take() {
edit_location.selected = Some(selected);
cd = edit_location.resolve();
}
2024-01-29 11:58:36 -07:00
}
Message::EditLocationEnable => {
2024-10-21 13:51:10 -06:00
commands.push(Command::Iced(
widget::text_input::focus(self.edit_location_id.clone()).into(),
));
2025-02-07 09:11:20 -07:00
self.edit_location = Some(self.location.clone().into());
}
Message::EditLocationSubmit => {
if let Some(edit_location) = self.edit_location.take() {
cd = edit_location.resolve();
}
}
Message::OpenInNewTab(path) => {
commands.push(Command::OpenInNewTab(path));
}
2024-05-09 13:24:06 -06:00
Message::EmptyTrash => {
commands.push(Command::EmptyTrash);
}
#[cfg(feature = "desktop")]
Message::ExecEntryAction(path, action) => {
let lang_id = crate::localize::LANGUAGE_LOADER.current_language();
let language = lang_id.language.as_str();
match path.map_or_else(
|| {
let items = self.items_opt.as_deref()?;
items.iter().find(|&item| item.selected).and_then(|item| {
let location = item.location_opt.as_ref()?;
let path = location.path_opt()?;
2025-04-15 20:04:07 -04:00
cosmic::desktop::load_desktop_file(&[language.into()], path.into())
})
},
2025-04-15 20:04:07 -04:00
|path| cosmic::desktop::load_desktop_file(&[language.into()], path),
) {
Some(entry) => commands.push(Command::ExecEntryAction(entry, action)),
None => log::warn!("Invalid desktop entry path passed to ExecEntryAction"),
}
}
Message::Gallery(gallery) => {
self.gallery = gallery;
}
Message::GalleryPrevious | Message::GalleryNext => {
let mut pos_opt = None;
if let Some(mut indices) = self.column_sort() {
if matches!(message, Message::GalleryPrevious) {
indices.reverse();
}
let mut found = false;
for (index, item) in indices {
2025-01-28 23:52:11 -05:00
if self.select_focus.is_none() {
found = true;
}
if self.select_focus == Some(index) {
found = true;
continue;
}
2025-01-28 23:52:11 -05:00
if found && item.can_gallery() {
pos_opt = item.pos_opt.get();
if pos_opt.is_some() {
break;
}
}
}
}
if let Some((row, col)) = pos_opt {
// Should mod_shift be available?
self.select_position(row, col, mod_shift);
}
if let Some(offset) = self.select_focus_scroll() {
2024-10-21 13:51:10 -06:00
commands.push(Command::Iced(
scrollable::scroll_to(self.scrollable_id.clone(), offset).into(),
));
}
if let Some(id) = self.select_focus_id() {
2024-10-21 13:51:10 -06:00
commands.push(Command::Iced(widget::button::focus(id).into()));
}
}
Message::GalleryToggle => {
if let Some(indices) = self.column_sort() {
for (_, item) in &indices {
2024-10-10 10:05:48 -06:00
if item.selected && item.can_gallery() {
self.gallery = !self.gallery;
break;
}
}
}
}
Message::GoNext => {
if let Some(history_i) = self.history_i.checked_add(1) {
if let Some(location) = self.history.get(history_i) {
cd = Some(location.clone());
history_i_opt = Some(history_i);
}
}
}
Message::GoPrevious => {
if let Some(history_i) = self.history_i.checked_sub(1) {
if let Some(location) = self.history.get(history_i) {
cd = Some(location.clone());
history_i_opt = Some(history_i);
}
}
}
Message::ItemDown => {
2025-02-07 09:11:20 -07:00
if let Some(edit_location) = &mut self.edit_location {
edit_location.select(true);
} else if self.gallery {
commands.append(&mut self.update(Message::GalleryNext, modifiers));
2024-10-10 10:11:28 -06:00
} else {
if let Some((row, col)) =
self.select_focus_pos_opt().or(self.select_last_pos_opt())
{
if self.select_focus.is_none() {
// Select last item in current selection to focus it.
self.select_position(row, col, mod_shift);
}
2024-10-10 10:11:28 -06:00
//TODO: Shift modifier should select items in between
// Try to select item in next row
if !self.select_position(row + 1, col, mod_shift) {
// Ensure current item is still selected if there are no other items
self.select_position(row, col, mod_shift);
}
} else {
// Select first item
//TODO: select first in scroll
self.select_position(0, 0, mod_shift);
}
if let Some(offset) = self.select_focus_scroll() {
2024-10-21 13:51:10 -06:00
commands.push(Command::Iced(
scrollable::scroll_to(self.scrollable_id.clone(), offset).into(),
));
2024-10-10 10:11:28 -06:00
}
if let Some(id) = self.select_focus_id() {
2024-10-21 13:51:10 -06:00
commands.push(Command::Iced(widget::button::focus(id).into()));
2024-02-28 16:16:59 -07:00
}
2024-02-29 15:38:03 -07:00
}
2024-02-28 16:16:59 -07:00
}
Message::ItemLeft => {
2024-10-10 10:11:28 -06:00
if self.gallery {
commands.append(&mut self.update(Message::GalleryPrevious, modifiers));
2024-10-10 10:11:28 -06:00
} else {
if let Some((row, col)) =
self.select_focus_pos_opt().or(self.select_first_pos_opt())
{
2024-10-10 10:11:28 -06:00
if self.select_focus.is_none() {
// Select first item in current selection to focus it.
self.select_position(row, col, mod_shift);
}
// Try to select previous item in current row
if !col
.checked_sub(1)
2025-10-03 03:24:44 +02:00
.is_some_and(|col| self.select_position(row, col, mod_shift))
2024-10-10 10:11:28 -06:00
{
// Try to select last item in previous row
2025-10-03 03:24:44 +02:00
if !row.checked_sub(1).is_some_and(|row| {
2024-10-10 10:11:28 -06:00
let mut col = 0;
if let Some(ref items) = self.items_opt {
for item in items {
2024-10-10 10:11:28 -06:00
match item.pos_opt.get() {
Some((item_row, item_col)) if item_row == row => {
col = col.max(item_col);
}
_ => continue,
}
}
}
2024-10-10 10:11:28 -06:00
self.select_position(row, col, mod_shift)
}) {
// Ensure current item is still selected if there are no other items
self.select_position(row, col, mod_shift);
2024-02-28 16:16:59 -07:00
}
}
2024-10-10 10:11:28 -06:00
} else {
// Select first item
//TODO: select first in scroll
self.select_position(0, 0, mod_shift);
}
if let Some(offset) = self.select_focus_scroll() {
2024-10-21 13:51:10 -06:00
commands.push(Command::Iced(
scrollable::scroll_to(self.scrollable_id.clone(), offset).into(),
));
2024-10-10 10:11:28 -06:00
}
if let Some(id) = self.select_focus_id() {
2024-10-21 13:51:10 -06:00
commands.push(Command::Iced(widget::button::focus(id).into()));
2024-02-28 16:16:59 -07:00
}
2024-02-29 15:38:03 -07:00
}
}
Message::ItemRight => {
2024-10-10 10:11:28 -06:00
if self.gallery {
commands.append(&mut self.update(Message::GalleryNext, modifiers));
2024-10-10 10:11:28 -06:00
} else {
if let Some((row, col)) =
self.select_focus_pos_opt().or(self.select_last_pos_opt())
{
if self.select_focus.is_none() {
// Select last item in current selection to focus it.
self.select_position(row, col, mod_shift);
2024-02-28 16:16:59 -07:00
}
2024-10-10 10:11:28 -06:00
// Try to select next item in current row
if !self.select_position(row, col + 1, mod_shift) {
// Try to select first item in next row
if !self.select_position(row + 1, 0, mod_shift) {
// Ensure current item is still selected if there are no other items
self.select_position(row, col, mod_shift);
}
}
} else {
// Select first item
//TODO: select first in scroll
self.select_position(0, 0, mod_shift);
}
if let Some(offset) = self.select_focus_scroll() {
2024-10-21 13:51:10 -06:00
commands.push(Command::Iced(
scrollable::scroll_to(self.scrollable_id.clone(), offset).into(),
));
2024-10-10 10:11:28 -06:00
}
if let Some(id) = self.select_focus_id() {
2024-10-21 13:51:10 -06:00
commands.push(Command::Iced(widget::button::focus(id).into()));
2024-02-28 16:16:59 -07:00
}
2024-02-29 15:38:03 -07:00
}
}
Message::ItemUp => {
2025-02-07 09:11:20 -07:00
if let Some(edit_location) = &mut self.edit_location {
edit_location.select(false);
} else if self.gallery {
commands.append(&mut self.update(Message::GalleryPrevious, modifiers));
2024-10-10 10:11:28 -06:00
} else {
if let Some((row, col)) =
self.select_focus_pos_opt().or(self.select_first_pos_opt())
{
2024-10-10 10:11:28 -06:00
if self.select_focus.is_none() {
// Select first item in current selection to focus it.
self.select_position(row, col, mod_shift);
}
//TODO: Shift modifier should select items in between
// Try to select item in last row
if !row
.checked_sub(1)
2025-10-03 03:24:44 +02:00
.is_some_and(|row| self.select_position(row, col, mod_shift))
2024-10-10 10:11:28 -06:00
{
// Ensure current item is still selected if there are no other items
self.select_position(row, col, mod_shift);
}
} else {
// Select first item
//TODO: select first in scroll
self.select_position(0, 0, mod_shift);
}
if let Some(offset) = self.select_focus_scroll() {
2024-10-21 13:51:10 -06:00
commands.push(Command::Iced(
scrollable::scroll_to(self.scrollable_id.clone(), offset).into(),
));
2024-10-10 10:11:28 -06:00
}
if let Some(id) = self.select_focus_id() {
2024-10-21 13:51:10 -06:00
commands.push(Command::Iced(widget::button::focus(id).into()));
}
2024-02-29 15:38:03 -07:00
}
2024-02-28 16:16:59 -07:00
}
2024-01-09 15:34:48 -07:00
Message::Location(location) => {
2024-05-09 13:55:57 -06:00
// Workaround to support favorited files
match &location {
Location::Path(path) => {
if path.is_dir() {
cd = Some(location);
} else {
commands.push(Command::OpenFile(vec![path.clone()]));
2024-05-09 13:55:57 -06:00
}
}
_ => {
2024-05-31 15:19:47 -06:00
cd = Some(location);
}
2024-05-09 13:55:57 -06:00
}
2024-01-03 17:04:08 -07:00
}
Message::LocationUp => {
// Sets location to the path's parent
// Does nothing if path is root or location is Trash
if let Location::Path(ref path) = self.location {
if let Some(parent) = path.parent() {
cd = Some(Location::Path(parent.to_owned()));
}
}
}
Message::ModifiersChanged(modifiers) => {
self.modifiers = modifiers;
}
Message::Open(path_opt) => {
match path_opt {
Some(path) => {
if path.is_dir() {
cd = Some(Location::Path(path));
} else {
commands.push(Command::OpenFile(vec![path]));
}
}
None => {
if let Some(ref mut items) = self.items_opt {
let mut open_files = Vec::new();
for item in items.iter() {
if item.selected {
if let Some(location) = &item.location_opt {
if item.metadata.is_dir() {
//TODO: allow opening multiple tabs?
cd = Some(location.clone());
2025-01-28 23:52:11 -05:00
} else if let Some(path) = location.path_opt() {
open_files.push(path.clone());
}
} else {
//TODO: open properties?
2024-09-13 15:13:37 -06:00
}
}
}
commands.push(Command::OpenFile(open_files));
}
}
}
}
Message::Reload => {
//TODO: support keeping selected locations without paths
let selected_paths = self
.selected_locations()
.into_iter()
.filter_map(Location::into_path_opt)
.collect();
let location = self.location.clone();
self.change_location(&location, None);
commands.push(Command::ChangeLocation(
self.title(),
location,
Some(selected_paths),
));
}
Message::RightClick(_point_opt, click_i_opt) => {
if mod_ctrl || mod_shift {
self.update(Message::Click(click_i_opt), modifiers);
}
if let Some(ref mut items) = self.items_opt {
2025-10-03 03:24:44 +02:00
if !click_i_opt
.is_some_and(|click_i| items.get(click_i).is_some_and(|x| x.selected))
{
// If item not selected, clear selection on other items
for (i, item) in items.iter_mut().enumerate() {
item.selected = Some(i) == click_i_opt;
}
}
}
//TODO: hack for clearing selecting when right clicking empty space
self.last_right_click = click_i_opt;
}
Message::MiddleClick(click_i) => {
if mod_ctrl || mod_shift {
self.update(Message::Click(Some(click_i)), modifiers);
} else {
if let Some(ref mut items) = self.items_opt {
for (i, item) in items.iter_mut().enumerate() {
item.selected = i == click_i;
}
self.select_range = Some((click_i, click_i));
}
if let Some(clicked_item) =
self.items_opt.as_ref().and_then(|items| items.get(click_i))
{
if let Some(path) = clicked_item.path_opt() {
if clicked_item.metadata.is_dir() {
//cd = Some(Location::Path(path.clone()));
commands.push(Command::OpenInNewTab(path.clone()));
} else {
commands.push(Command::OpenFile(vec![path.clone()]));
}
} else {
log::warn!("no path for item {clicked_item:?}");
}
} else {
log::warn!("no item for click index {click_i:?}");
}
}
}
2024-10-16 10:22:25 +11:00
Message::HighlightDeactivate(i) => {
self.watch_drag = true;
2024-10-16 10:22:25 +11:00
if let Some(item) = self.items_opt.as_mut().and_then(|f| f.get_mut(i)) {
item.highlighted = false;
}
}
Message::HighlightActivate(i) => {
self.watch_drag = true;
2024-10-16 10:22:25 +11:00
if let Some(item) = self.items_opt.as_mut().and_then(|f| f.get_mut(i)) {
item.highlighted = true;
}
}
Message::Resize(viewport) => {
// Scroll to ensure focused item still in view
if self.viewport_opt.map(|v| v.size()) != Some(viewport.size()) {
if let Some(offset) = self.select_focus_scroll() {
commands.push(Command::Iced(
scrollable::scroll_to(self.scrollable_id.clone(), offset).into(),
));
}
}
self.viewport_opt = Some(viewport);
}
2024-02-26 12:05:29 -07:00
Message::Scroll(viewport) => {
2024-02-29 16:25:54 -07:00
self.scroll_opt = Some(viewport.absolute_offset());
2025-07-10 14:00:31 -06:00
self.watch_drag = true;
2024-02-26 12:05:29 -07:00
}
Message::ScrollTab(scroll_speed) => {
commands.push(Command::Iced(
scrollable::scroll_by(
self.scrollable_id.clone(),
AbsoluteOffset {
x: 0.0,
y: scroll_speed,
},
)
.into(),
));
}
2024-10-09 18:55:09 -06:00
Message::SearchContext(location, context) => {
if location == self.location {
2024-10-09 18:55:09 -06:00
self.search_context = context.0;
} else {
log::warn!(
2024-10-09 18:55:09 -06:00
"search context provided for {:?} instead of {:?}",
location,
self.location
);
}
2024-10-09 18:55:09 -06:00
}
Message::SearchReady(finished) => {
if let Some(context) = &mut self.search_context {
if let Some(items) = &mut self.items_opt {
if finished || context.ready.swap(false, atomic::Ordering::SeqCst) {
let duration = Instant::now();
while let Ok((path, name, metadata)) = context.results_rx.try_recv() {
2024-10-09 18:55:09 -06:00
//TODO: combine this with column_sort logic, they must match!
let item_modified = metadata.modified().ok();
let index = match items.binary_search_by(|other| {
2024-10-09 20:18:43 -06:00
item_modified.cmp(&other.metadata.modified())
2024-10-09 18:55:09 -06:00
}) {
Ok(index) => index,
Err(index) => index,
};
if index < MAX_SEARCH_RESULTS {
//TODO: use correct IconSizes
items.insert(
index,
item_from_entry(path, name, metadata, IconSizes::default()),
);
}
// Ensure that updates make it to the GUI in a timely manner
if !finished && duration.elapsed() >= MAX_SEARCH_LATENCY {
break;
}
}
}
if items.len() >= MAX_SEARCH_RESULTS {
items.truncate(MAX_SEARCH_RESULTS);
2024-10-09 20:18:43 -06:00
if let Some(last_modified) =
items.last().and_then(|item| item.metadata.modified())
{
*context.last_modified_opt.write().unwrap() = Some(last_modified);
}
2024-10-09 18:55:09 -06:00
}
} else {
log::warn!("search ready but items array is empty");
}
}
if finished {
self.search_context = None;
}
}
2024-02-29 21:13:03 -07:00
Message::SelectAll => {
self.select_all();
if self.select_focus.take().is_some() {
// Unfocus currently focused button
2024-10-21 13:51:10 -06:00
commands.push(Command::Iced(
widget::button::focus(widget::Id::unique()).into(),
));
2024-02-29 21:13:03 -07:00
}
}
Message::SelectFirst => {
if self.select_position(0, 0, mod_shift) {
if let Some(offset) = self.select_focus_scroll() {
commands.push(Command::Iced(
scrollable::scroll_to(self.scrollable_id.clone(), offset).into(),
));
}
if let Some(id) = self.select_focus_id() {
commands.push(Command::Iced(widget::button::focus(id).into()));
}
}
}
Message::SelectLast => {
if let Some(ref items) = self.items_opt {
if let Some(last_pos) = items.iter().filter_map(|item| item.pos_opt.get()).max()
{
if self.select_position(last_pos.0, last_pos.1, mod_shift) {
if let Some(offset) = self.select_focus_scroll() {
commands.push(Command::Iced(
scrollable::scroll_to(self.scrollable_id.clone(), offset)
.into(),
));
}
if let Some(id) = self.select_focus_id() {
commands.push(Command::Iced(widget::button::focus(id).into()));
}
}
}
}
}
Message::SetOpenWith(mime, id) => {
commands.push(Command::SetOpenWith(mime, id));
}
Message::SetPermissions(path, mode) => {
commands.push(Command::SetPermissions(path, mode));
}
2024-09-11 13:29:00 -06:00
Message::SetSort(heading_option, dir) => {
if !matches!(self.location, Location::Search(..)) {
self.sort_name = heading_option;
self.sort_direction = dir;
if !matches!(self.location, Location::Desktop(..)) {
commands.push(Command::SetSort(
self.location.normalize().to_string(),
heading_option,
self.sort_direction,
));
}
}
2024-09-11 13:29:00 -06:00
}
2025-02-07 09:11:20 -07:00
Message::TabComplete(path, completions) => {
if let Some(edit_location) = &mut self.edit_location {
if edit_location.location.path_opt() == Some(&path) {
edit_location.completions = Some(completions);
commands.push(Command::Iced(
widget::text_input::focus(self.edit_location_id.clone()).into(),
));
}
}
}
2024-03-04 13:41:18 -07:00
Message::Thumbnail(path, thumbnail) => {
2024-02-22 16:17:39 -07:00
if let Some(ref mut items) = self.items_opt {
let location = Location::Path(path);
2024-02-22 16:17:39 -07:00
for item in items.iter_mut() {
if item.location_opt.as_ref() == Some(&location) {
let handle_opt = match &thumbnail {
ItemThumbnail::NotImage => None,
ItemThumbnail::Image(handle, _) => Some(widget::icon::Handle {
symbolic: false,
data: widget::icon::Data::Image(handle.clone()),
}),
ItemThumbnail::Svg(handle) => Some(widget::icon::Handle {
symbolic: false,
data: widget::icon::Data::Svg(handle.clone()),
}),
2024-10-10 10:05:48 -06:00
//TODO: text thumbnails?
ItemThumbnail::Text(_text) => None,
};
if let Some(handle) = handle_opt {
item.icon_handle_grid.clone_from(&handle);
item.icon_handle_list.clone_from(&handle);
item.icon_handle_list_condensed = handle;
2024-02-22 16:17:39 -07:00
}
2024-03-04 13:41:18 -07:00
item.thumbnail_opt = Some(thumbnail);
2024-02-22 16:17:39 -07:00
break;
}
}
}
}
2024-02-28 05:18:40 +01:00
Message::ToggleSort(heading_option) => {
if !matches!(self.location, Location::Search(..)) {
let heading_sort = if self.sort_name == heading_option {
!self.sort_direction
} else {
// Default modified to descending, and others to ascending.
heading_option != HeadingOptions::Modified
};
if !matches!(self.location, Location::Desktop(..)) {
commands.push(Command::SetSort(
self.location.normalize().to_string(),
heading_option,
heading_sort,
));
}
self.sort_direction = heading_sort;
self.sort_name = heading_option;
}
2024-02-28 05:18:40 +01:00
}
Message::Drop(Some((to, mut from))) => {
self.dnd_hovered = None;
match to {
Location::Desktop(to, ..)
| Location::Path(to)
| Location::Network(_, _, Some(to)) => {
if let Ok(entries) = fs::read_dir(&to) {
for i in entries.into_iter().filter_map(Result::ok) {
let i = i.path();
from.paths.retain(|p| &i != p);
if from.paths.is_empty() {
log::info!("All dropped files already in target directory.");
return commands;
}
}
}
commands.push(Command::DropFiles(to, from));
}
Location::Trash if matches!(from.kind, ClipboardKind::Cut { .. }) => {
commands.push(Command::Delete(from.paths));
2024-04-10 13:56:43 -04:00
}
2024-09-13 15:13:37 -06:00
_ => {
log::warn!("{:?} to {:?} is not supported.", from.kind, to);
}
}
}
Message::Drop(None) => {
self.dnd_hovered = None;
}
Message::DndHover(loc) => {
if self
.dnd_hovered
.as_ref()
.is_some_and(|(l, i)| *l == loc && i.elapsed() > HOVER_DURATION)
{
cd = Some(loc);
}
}
Message::DndEnter(loc) => {
self.dnd_hovered = Some((loc.clone(), Instant::now()));
if loc != self.location {
2024-10-21 13:51:10 -06:00
commands.push(Command::Iced(
cosmic::Task::future(async move {
tokio::time::sleep(HOVER_DURATION).await;
Message::DndHover(loc)
})
2024-10-21 13:51:10 -06:00
.into(),
));
}
}
Message::DndLeave(loc) => {
if Some(&loc) == self.dnd_hovered.as_ref().map(|(l, _)| l) {
self.dnd_hovered = None;
}
}
Message::WindowDrag => {
commands.push(Command::WindowDrag);
}
Message::WindowToggleMaximize => {
commands.push(Command::WindowToggleMaximize);
}
2024-10-02 15:49:13 -06:00
Message::ZoomIn => {
commands.push(Command::Action(Action::ZoomIn));
}
Message::ZoomOut => {
commands.push(Command::Action(Action::ZoomOut));
}
Message::DirectorySize(path, dir_size) => {
let location = Location::Path(path);
if let Some(ref mut item) = self.parent_item_opt {
if item.location_opt.as_ref() == Some(&location) {
item.dir_size.clone_from(&dir_size);
}
}
if let Some(ref mut items) = self.items_opt {
for item in items.iter_mut() {
if item.location_opt.as_ref() == Some(&location) {
item.dir_size = dir_size;
break;
}
}
}
}
2024-01-03 15:27:32 -07:00
}
// Scroll to top if needed
if self.scroll_opt.is_none() {
let offset = AbsoluteOffset { x: 0.0, y: 0.0 };
self.scroll_opt = Some(offset);
2024-10-21 13:51:10 -06:00
commands.push(Command::Iced(
scrollable::scroll_to(self.scrollable_id.clone(), offset).into(),
));
}
// Change directory if requested
2025-02-07 09:11:20 -07:00
if let Some(mut location) = cd {
2024-08-20 13:26:10 -06:00
if matches!(self.mode, Mode::Desktop) {
match location {
Location::Path(path) => {
commands.push(Command::OpenFile(vec![path]));
2024-08-20 13:26:10 -06:00
}
Location::Trash => {
commands.push(Command::OpenTrash);
}
2024-08-20 13:26:10 -06:00
_ => {}
}
2025-02-07 09:11:20 -07:00
} else {
// Select parent if location is not directory
let mut selected_paths = None;
if let Some(path) = location.path_opt() {
if !path.is_dir() {
if let Some(parent) = path.parent() {
selected_paths = Some(vec![path.clone()]);
2025-02-07 09:11:20 -07:00
location = location.with_path(parent.to_path_buf());
}
}
}
2025-02-07 09:53:53 -07:00
if location != self.location || selected_paths.is_some() {
2025-10-03 03:24:44 +02:00
if location.path_opt().is_none_or(|path| path.is_dir()) {
2025-02-07 09:11:20 -07:00
if selected_paths.is_none() {
selected_paths =
self.location.path_opt().map(|path| vec![path.clone()]);
2025-02-07 09:11:20 -07:00
}
self.change_location(&location, history_i_opt);
commands.push(Command::ChangeLocation(
self.title(),
location,
selected_paths,
));
} else {
log::warn!("tried to cd to {location:?} which is not a directory");
2025-02-07 09:11:20 -07:00
}
}
}
2024-01-03 15:27:32 -07:00
}
// Update context menu popup
if self.context_menu != last_context_menu {
if last_context_menu.is_some() {
2025-10-03 03:24:44 +02:00
commands.push(Command::ContextMenu(None, self.window_id));
}
if let Some(point) = self.context_menu {
2025-10-03 03:24:44 +02:00
commands.push(Command::ContextMenu(Some(point), self.window_id));
}
}
2024-02-26 14:11:45 -07:00
commands
2024-01-03 15:27:32 -07:00
}
pub(crate) const fn sort_options(&self) -> (HeadingOptions, bool, bool) {
match self.location {
2024-10-09 18:55:09 -06:00
Location::Search(..) => (HeadingOptions::Modified, false, false),
_ => (
self.sort_name,
self.sort_direction,
self.config.folders_first,
),
}
}
2024-02-29 18:58:59 -07:00
fn column_sort(&self) -> Option<Vec<(usize, &Item)>> {
2024-02-29 03:46:14 +01:00
let check_reverse = |ord: Ordering, sort: bool| {
2025-09-03 23:24:38 +02:00
if sort { ord } else { ord.reverse() }
2024-02-29 03:46:14 +01:00
};
2024-02-29 18:58:59 -07:00
let mut items: Vec<_> = self.items_opt.as_ref()?.iter().enumerate().collect();
2024-10-09 18:55:09 -06:00
let (sort_name, sort_direction, folders_first) = self.sort_options();
match sort_name {
2024-02-29 03:46:14 +01:00
HeadingOptions::Size => {
items.sort_by(|a, b| {
2024-02-29 03:46:14 +01:00
// entries take precedence over size
let get_size = |x: &Item| match &x.metadata {
ItemMetadata::Path {
metadata,
children_opt,
} => {
2024-02-29 03:46:14 +01:00
if metadata.is_dir() {
(true, children_opt.unwrap_or_default() as u64)
2024-02-29 03:46:14 +01:00
} else {
(false, metadata.len())
}
}
ItemMetadata::Trash { metadata, .. } => match metadata.size {
trash::TrashItemSize::Entries(entries) => (true, entries as u64),
trash::TrashItemSize::Bytes(bytes) => (false, bytes),
},
2024-09-13 15:13:37 -06:00
ItemMetadata::SimpleDir { entries } => (true, *entries),
ItemMetadata::SimpleFile { size } => (false, *size),
#[cfg(feature = "gvfs")]
ItemMetadata::GvfsPath {
size_opt,
children_opt,
..
} => match children_opt {
Some(child_count) => (true, *child_count as u64),
None => (false, size_opt.unwrap_or_default()),
},
2024-02-29 03:46:14 +01:00
};
2024-02-29 18:58:59 -07:00
let (a_is_entry, a_size) = get_size(a.1);
let (b_is_entry, b_size) = get_size(b.1);
2024-02-29 03:46:14 +01:00
//TODO: use folders_first?
match (a_is_entry, b_is_entry) {
2024-02-29 03:46:14 +01:00
(true, false) => Ordering::Less,
(false, true) => Ordering::Greater,
_ => check_reverse(a_size.cmp(&b_size), sort_direction),
}
});
2024-02-29 03:46:14 +01:00
}
HeadingOptions::Name => items.sort_by(|a, b| {
2024-10-09 18:55:09 -06:00
if folders_first {
2024-05-28 10:40:36 -06:00
match (a.1.metadata.is_dir(), b.1.metadata.is_dir()) {
(true, false) => Ordering::Less,
(false, true) => Ordering::Greater,
_ => check_reverse(
2024-08-20 13:26:10 -06:00
LANGUAGE_SORTER.compare(&a.1.display_name, &b.1.display_name),
sort_direction,
),
2024-05-28 10:40:36 -06:00
}
} else {
2024-08-20 13:26:10 -06:00
check_reverse(
LANGUAGE_SORTER.compare(&a.1.display_name, &b.1.display_name),
sort_direction,
2024-08-20 13:26:10 -06:00
)
}
2024-02-29 03:46:14 +01:00
}),
HeadingOptions::Modified => {
items.sort_by(|a, b| {
2024-10-09 20:18:43 -06:00
let a_modified = a.1.metadata.modified();
let b_modified = b.1.metadata.modified();
2024-10-09 18:55:09 -06:00
if folders_first {
match (a.1.metadata.is_dir(), b.1.metadata.is_dir()) {
(true, false) => Ordering::Less,
(false, true) => Ordering::Greater,
_ => check_reverse(a_modified.cmp(&b_modified), sort_direction),
}
} else {
check_reverse(a_modified.cmp(&b_modified), sort_direction)
}
2024-02-29 03:46:14 +01:00
});
}
HeadingOptions::TrashedOn => {
let time_deleted = |x: &Item| match &x.metadata {
ItemMetadata::Trash { entry, .. } => Some(entry.time_deleted),
_ => None,
};
items.sort_by(|a, b| {
let a_time_deleted = time_deleted(a.1);
let b_time_deleted = time_deleted(b.1);
2024-10-09 18:55:09 -06:00
if folders_first {
match (a.1.metadata.is_dir(), b.1.metadata.is_dir()) {
(true, false) => Ordering::Less,
(false, true) => Ordering::Greater,
_ => check_reverse(a_time_deleted.cmp(&b_time_deleted), sort_direction),
}
} else {
check_reverse(b_time_deleted.cmp(&a_time_deleted), sort_direction)
}
});
}
2024-02-29 03:46:14 +01:00
}
Some(items)
2024-02-29 03:46:14 +01:00
}
fn dnd_dest<'a>(
&self,
location: &Location,
element: impl Into<Element<'a, Message>>,
) -> Element<'a, Message> {
let location1 = location.clone();
let location2 = location.clone();
let location3 = location.clone();
2025-01-28 23:52:11 -05:00
let is_dnd_hovered = self.dnd_hovered.as_ref().map(|(l, _)| l) == Some(location);
2024-10-21 13:51:10 -06:00
let mut container = widget::container(
DndDestination::for_data::<ClipboardPaste>(element, move |data, action| {
if let Some(mut data) = data {
if action == DndAction::Copy {
Message::Drop(Some((location1.clone(), data)))
} else if action == DndAction::Move {
data.kind = ClipboardKind::Cut { is_dnd: true };
Message::Drop(Some((location1.clone(), data)))
} else {
log::warn!("unsupported action: {action:?}");
Message::Drop(None)
}
} else {
Message::Drop(None)
}
})
.on_enter(move |_, _, _| Message::DndEnter(location2.clone()))
.on_leave(move || Message::DndLeave(location3.clone())),
2024-10-21 13:51:10 -06:00
);
// Desktop will not show DnD indicator
if is_dnd_hovered && !matches!(self.mode, Mode::Desktop) {
2024-10-21 13:51:10 -06:00
container = container.style(|t| {
let mut a = widget::container::Style::default();
let t = t.cosmic();
// todo use theme drop target color
let mut bg = t.accent_color();
bg.alpha = 0.2;
a.background = Some(Color::from(bg).into());
a.border = Border {
color: t.accent_color().into(),
width: 1.0,
radius: t.radius_s().into(),
};
a
2024-10-21 13:51:10 -06:00
});
}
container.into()
}
pub fn gallery_view(&self) -> Element<'_, Message> {
let cosmic_theme::Spacing {
space_xxs,
2024-09-23 19:30:19 -06:00
space_xs,
space_m,
..
} = theme::active().cosmic().spacing;
//TODO: display error messages when image not found?
let mut name_opt = None;
2024-10-10 10:05:48 -06:00
let mut element_opt: Option<Element<Message>> = None;
if let Some(index) = self.select_focus {
if let Some(items) = &self.items_opt {
if let Some(item) = items.get(index) {
name_opt = Some(widget::text::heading(&item.display_name));
match item
.thumbnail_opt
.as_ref()
.unwrap_or(&ItemThumbnail::NotImage)
{
ItemThumbnail::NotImage => {}
ItemThumbnail::Image(handle, _) => {
if let Some(path) = item.path_opt() {
2024-10-10 10:05:48 -06:00
element_opt = Some(
2024-10-21 15:39:57 -06:00
widget::container(
//TODO: use widget::image::viewer, when its zoom can be reset
widget::image(widget::image::Handle::from_path(path)),
)
.center(Length::Fill)
2024-10-21 15:39:57 -06:00
.into(),
);
} else {
2024-10-10 10:05:48 -06:00
element_opt = Some(
2024-10-21 15:39:57 -06:00
widget::container(
//TODO: use widget::image::viewer, when its zoom can be reset
widget::image(handle.clone()),
)
.center(Length::Fill)
2024-10-21 15:39:57 -06:00
.into(),
);
}
}
ItemThumbnail::Svg(handle) => {
2024-10-10 10:05:48 -06:00
element_opt = Some(
widget::svg(handle.clone())
.width(Length::Fill)
.height(Length::Fill)
.into(),
);
}
2024-10-10 10:05:48 -06:00
ItemThumbnail::Text(text) => {
element_opt = Some(
2024-11-11 23:53:25 +01:00
widget::container(
2025-10-03 03:24:44 +02:00
widget::text_editor(text).padding(space_xxs).class(
2024-11-11 23:53:25 +01:00
cosmic::theme::iced::TextEditor::Custom(Box::new(
text_editor_class,
)),
),
)
.center(Length::Fill)
.into(),
);
2024-10-10 10:05:48 -06:00
}
}
}
}
}
let mut column = widget::column::with_capacity(2);
2024-10-21 13:51:10 -06:00
column = column.push(widget::Space::with_height(Length::Fixed(space_m.into())));
{
2024-10-21 13:51:10 -06:00
let mut row = widget::row::with_capacity(5).align_y(Alignment::Center);
row = row.push(widget::horizontal_space());
if let Some(name) = name_opt {
row = row.push(name);
}
2024-10-21 13:51:10 -06:00
row = row.push(widget::horizontal_space());
row = row.push(
widget::button::icon(widget::icon::from_name("window-close-symbolic"))
2024-10-21 13:51:10 -06:00
.class(theme::Button::Standard)
.on_press(Message::Gallery(false)),
);
2024-10-21 13:51:10 -06:00
row = row.push(widget::Space::with_width(Length::Fixed(space_m.into())));
// This mouse area provides window drag while the header bar is hidden
let mouse_area = mouse_area::MouseArea::new(row)
.on_press(|_| Message::WindowDrag)
.on_double_click(|_| Message::WindowToggleMaximize);
column = column.push(mouse_area);
}
{
2024-10-21 13:51:10 -06:00
let mut row = widget::row::with_capacity(7).align_y(Alignment::Center);
row = row.push(widget::Space::with_width(Length::Fixed(space_m.into())));
row = row.push(
widget::button::icon(widget::icon::from_name("go-previous-symbolic"))
2024-09-23 19:30:19 -06:00
.padding(space_xs)
2024-10-21 13:51:10 -06:00
.class(theme::Button::Standard)
.on_press(Message::GalleryPrevious),
);
2024-10-21 13:51:10 -06:00
row = row.push(widget::Space::with_width(Length::Fixed(space_xxs.into())));
2024-10-10 10:05:48 -06:00
if let Some(element) = element_opt {
row = row.push(element);
} else {
//TODO: what to do when no image?
2024-09-23 19:30:19 -06:00
row = row.push(widget::Space::new(Length::Fill, Length::Fill));
}
2024-10-21 13:51:10 -06:00
row = row.push(widget::Space::with_width(Length::Fixed(space_xxs.into())));
row = row.push(
widget::button::icon(widget::icon::from_name("go-next-symbolic"))
2024-09-23 19:30:19 -06:00
.padding(space_xs)
2024-10-21 13:51:10 -06:00
.class(theme::Button::Standard)
.on_press(Message::GalleryNext),
);
2024-10-21 13:51:10 -06:00
row = row.push(widget::Space::with_width(Length::Fixed(space_m.into())));
column = column.push(row);
}
widget::container(column)
.width(Length::Fill)
.height(Length::Fill)
2024-10-21 13:51:10 -06:00
.style(|theme| {
let cosmic = theme.cosmic();
let mut bg = cosmic.bg_color();
bg.alpha = 0.75;
2024-10-21 13:51:10 -06:00
widget::container::Style {
background: Some(Color::from(bg).into()),
..Default::default()
}
2024-10-21 13:51:10 -06:00
})
.into()
}
pub fn location_view(&self) -> Element<'_, Message> {
//TODO: responsiveness is done in a hacky way, potentially move this to a custom widget?
fn text_width<'a>(
content: &'a str,
font: font::Font,
font_size: f32,
line_height: f32,
) -> f32 {
2024-10-21 13:51:10 -06:00
let text: text::Text<&'a str, font::Font> = text::Text {
content,
bounds: Size::INFINITY,
size: font_size.into(),
line_height: text::LineHeight::Absolute(line_height.into()),
font,
horizontal_alignment: Horizontal::Left,
vertical_alignment: Vertical::Top,
shaping: text::Shaping::default(),
2024-10-21 13:51:10 -06:00
wrapping: text::Wrapping::None,
};
graphics::text::Paragraph::with_text(text)
.min_bounds()
.width
}
2025-01-28 23:52:11 -05:00
fn text_width_body(content: &str) -> f32 {
//TODO: should libcosmic set the font when using widget::text::body?
2024-10-05 07:39:03 -06:00
text_width(content, font::default(), 14.0, 20.0)
}
2025-01-28 23:52:11 -05:00
fn text_width_heading(content: &str) -> f32 {
2024-10-05 07:39:03 -06:00
text_width(content, font::semibold(), 14.0, 20.0)
}
2024-01-29 11:58:36 -07:00
let cosmic_theme::Spacing {
space_xxxs,
space_xxs,
space_s,
2024-07-31 14:11:37 +02:00
space_m,
2024-01-29 11:58:36 -07:00
..
} = theme::active().cosmic().spacing;
2024-07-31 14:11:37 +02:00
let size = self.size_opt.get().unwrap_or(Size::new(0.0, 0.0));
2024-01-29 11:58:36 -07:00
2024-07-13 11:34:03 +02:00
let mut row = widget::row::with_capacity(5)
2024-10-21 13:51:10 -06:00
.align_y(Alignment::Center)
2024-07-13 11:34:03 +02:00
.padding([space_xxxs, 0]);
let mut w = 0.0;
let mut prev_button =
2024-09-20 09:24:03 -06:00
widget::button::custom(widget::icon::from_name("go-previous-symbolic").size(16))
.padding(space_xxs)
2024-10-21 13:51:10 -06:00
.class(theme::Button::Icon);
if self.history_i > 0 && !self.history.is_empty() {
prev_button = prev_button.on_press(Message::GoPrevious);
}
row = row.push(prev_button);
w += f32::from(space_xxs).mul_add(2.0, 16.0);
2024-09-20 09:24:03 -06:00
let mut next_button =
widget::button::custom(widget::icon::from_name("go-next-symbolic").size(16))
.padding(space_xxs)
2024-10-21 13:51:10 -06:00
.class(theme::Button::Icon);
if self.history_i + 1 < self.history.len() {
next_button = next_button.on_press(Message::GoNext);
}
row = row.push(next_button);
w += f32::from(space_xxs).mul_add(2.0, 16.0);
2024-10-21 13:51:10 -06:00
row = row.push(widget::Space::with_width(Length::Fixed(space_s.into())));
w += f32::from(space_s);
2024-07-31 14:11:37 +02:00
//TODO: allow resizing?
let name_width = 300.0;
let modified_width = 200.0;
let size_width = 100.0;
let condensed = size.width < (name_width + modified_width + size_width);
2024-10-09 18:55:09 -06:00
let (sort_name, sort_direction, _) = self.sort_options();
2024-07-31 14:11:37 +02:00
let heading_item = |name, width, msg| {
let mut row = widget::row::with_capacity(2)
2024-10-21 13:51:10 -06:00
.align_y(Alignment::Center)
2024-10-25 13:24:17 +02:00
.spacing(space_xxxs)
2024-07-31 14:11:37 +02:00
.width(width);
row = row.push(widget::text::heading(name));
match (sort_name == msg, sort_direction) {
2024-07-31 14:11:37 +02:00
(true, true) => {
row = row.push(widget::icon::from_name("pan-down-symbolic").size(16));
}
(true, false) => {
row = row.push(widget::icon::from_name("pan-up-symbolic").size(16));
}
_ => {}
}
//TODO: make it possible to resize with the mouse
2025-01-28 23:52:11 -05:00
mouse_area::MouseArea::new(row)
2024-07-31 14:11:37 +02:00
.on_press(move |_point_opt| Message::ToggleSort(msg))
2025-01-28 23:52:11 -05:00
.into()
2024-07-31 14:11:37 +02:00
};
let heading_row = widget::row::with_children([
2024-07-31 14:11:37 +02:00
heading_item(fl!("name"), Length::Fill, HeadingOptions::Name),
if self.location == Location::Trash {
heading_item(
fl!("trashed-on"),
Length::Fixed(modified_width),
HeadingOptions::TrashedOn,
)
} else {
heading_item(
fl!("modified"),
Length::Fixed(modified_width),
HeadingOptions::Modified,
)
},
2024-07-31 14:11:37 +02:00
heading_item(fl!("size"), Length::Fixed(size_width), HeadingOptions::Size),
])
2024-10-21 13:51:10 -06:00
.align_y(Alignment::Center)
2024-07-31 14:11:37 +02:00
.height(Length::Fixed((space_m + 4).into()))
2024-10-25 13:24:17 +02:00
.padding([0, space_xxs]);
let accent_rule =
horizontal_rule(1).class(theme::Rule::Custom(Box::new(|theme| rule::Style {
color: theme.cosmic().accent_color().into(),
width: 1,
radius: 0.0.into(),
fill_mode: rule::FillMode::Full,
})));
2024-11-11 23:53:25 +01:00
let heading_rule = widget::container(horizontal_rule(1))
2024-10-25 13:24:17 +02:00
.padding([0, theme::active().cosmic().corner_radii.radius_xs[0] as u16]);
2024-07-31 14:11:37 +02:00
2025-02-07 09:11:20 -07:00
if let Some(edit_location) = &self.edit_location {
if let Some(location) = edit_location.resolve() {
//TODO: allow editing other locations
if let Some(path) = location.path_opt() {
2025-02-07 09:11:20 -07:00
row = row.push(
widget::button::custom(
widget::icon::from_name("window-close-symbolic").size(16),
)
.on_press(Message::EditLocation(None))
.padding(space_xxs)
.class(theme::Button::Icon),
);
let text_input = widget::text_input("", path.to_string_lossy().into_owned())
.id(self.edit_location_id.clone())
2025-02-07 09:11:20 -07:00
.on_input(move |input| {
Message::EditLocation(Some(
location.with_path(PathBuf::from(input)).into(),
))
})
2025-03-15 11:59:03 -04:00
.on_submit(|_| Message::EditLocationSubmit)
2025-02-07 09:11:20 -07:00
.line_height(1.0);
let mut popover =
widget::popover(text_input).position(widget::popover::Position::Bottom);
if let Some(completions) = &edit_location.completions {
if !completions.is_empty() {
let mut column =
widget::column::with_capacity(completions.len()).padding(space_xxs);
for (i, (name, _path)) in completions.iter().enumerate() {
let selected = edit_location.selected == Some(i);
column = column.push(
widget::button::custom(widget::text::body(name))
2025-02-07 09:53:53 -07:00
//TODO: match to design
.class(if selected {
theme::Button::Standard
} else {
2025-02-07 11:01:20 -07:00
theme::Button::HeaderBar
2025-02-07 09:53:53 -07:00
})
2025-02-07 09:11:20 -07:00
.on_press(Message::EditLocationComplete(i))
.padding(space_xxs)
.width(Length::Fill),
);
}
popover = popover.popup(
2025-02-07 09:53:53 -07:00
widget::container(column)
.class(theme::Container::Dropdown)
2025-02-07 09:11:20 -07:00
//TODO: This is a hack to get the popover to be the right width
2025-02-07 11:01:20 -07:00
.max_width(size.width - 140.0),
2025-02-07 09:11:20 -07:00
);
}
}
row = row.push(popover);
let mut column = widget::column::with_capacity(4).padding([0, space_s]);
column = column.push(row);
column = column.push(accent_rule);
if self.config.view == View::List && !condensed {
column = column.push(heading_row);
column = column.push(heading_rule);
}
return column.into();
2024-01-29 11:58:36 -07:00
}
}
} else if let Some(path) = self.location.path_opt() {
row = row.push(
crate::mouse_area::MouseArea::new(
2024-09-20 09:24:03 -06:00
widget::button::custom(widget::icon::from_name("edit-symbolic").size(16))
.padding(space_xxs)
2024-10-21 13:51:10 -06:00
.class(theme::Button::Icon)
2025-02-07 09:11:20 -07:00
.on_press(Message::EditLocation(Some(self.location.clone().into()))),
)
.on_middle_press(move |_| Message::OpenInNewTab(path.clone())),
);
w += f32::from(space_xxs).mul_add(2.0, 16.0);
2024-01-29 11:58:36 -07:00
}
2024-01-10 12:57:30 -07:00
let mut children: Vec<Element<_>> = Vec::new();
match &self.location {
Location::Desktop(path, ..) | Location::Path(path) | Location::Search(path, ..) => {
let excess_str = "...";
let excess_width = text_width_body(excess_str);
for (index, ancestor) in path.ancestors().enumerate() {
2025-01-28 23:52:11 -05:00
let (name, found_home) = folder_name(ancestor);
let (name_width, name_text) = if children.is_empty() {
(
text_width_heading(&name),
2024-10-21 13:51:10 -06:00
widget::text::heading(name).wrapping(text::Wrapping::None),
)
2024-01-29 11:16:19 -07:00
} else {
children.push(
widget::icon::from_name("go-next-symbolic")
.size(16)
.icon()
.into(),
);
w += 16.0;
(
text_width_body(&name),
2024-10-21 13:51:10 -06:00
widget::text::body(name).wrapping(text::Wrapping::None),
)
};
// Add padding for mouse area
w += 2.0 * f32::from(space_xxxs);
let mut row = widget::row::with_capacity(2)
2024-10-21 13:51:10 -06:00
.align_y(Alignment::Center)
.spacing(space_xxxs);
//TODO: figure out why this hardcoded offset is needed after the first item is ellipsed
2024-10-11 08:29:22 -06:00
let overflow_offset = 64.0;
2024-09-09 13:14:41 -06:00
let overflow = w + name_width + overflow_offset > size.width && index > 0;
if overflow {
row = row.push(widget::text::body(excess_str));
w += excess_width;
} else {
row = row.push(name_text);
w += name_width;
2024-01-10 12:57:30 -07:00
}
let location = self.location.with_path(ancestor.to_path_buf());
let mut mouse_area = crate::mouse_area::MouseArea::new(
2024-09-20 09:24:03 -06:00
widget::button::custom(row)
2024-01-10 12:57:30 -07:00
.padding(space_xxxs)
2024-10-21 13:51:10 -06:00
.class(theme::Button::Link)
.on_press(if ancestor == path {
Message::EditLocation(Some(self.location.clone().into()))
} else {
Message::Location(location.clone())
}),
);
if self.location_context_menu_index.is_some() {
mouse_area = mouse_area
.on_right_press(move |point_opt| {
Message::LocationContextMenuIndex(point_opt, None)
})
.wayland_on_right_press_window_position();
} else {
mouse_area = mouse_area
.on_right_press_no_capture()
.on_right_press(move |point_opt| {
Message::LocationContextMenuIndex(point_opt, Some(index))
})
.wayland_on_right_press_window_position();
}
let mouse_area = if let Location::Path(_) = &self.location {
mouse_area
.on_middle_press(move |_| Message::OpenInNewTab(ancestor.to_path_buf()))
} else {
mouse_area
};
children.push(self.dnd_dest(&location, mouse_area));
2024-01-10 12:57:30 -07:00
if found_home || overflow {
2024-01-10 12:57:30 -07:00
break;
}
}
children.reverse();
}
Location::Trash => {
children.push(
2024-09-20 09:24:03 -06:00
widget::button::custom(widget::text::heading(fl!("trash")))
2024-01-10 12:57:30 -07:00
.padding(space_xxxs)
.on_press(Message::Location(Location::Trash))
2024-10-21 13:51:10 -06:00
.class(theme::Button::Text)
2024-01-10 12:57:30 -07:00
.into(),
);
}
2024-08-16 14:42:09 +02:00
Location::Recents => {
children.push(
2024-09-20 09:24:03 -06:00
widget::button::custom(widget::text::heading(fl!("recents")))
2024-08-16 14:42:09 +02:00
.padding(space_xxxs)
.on_press(Message::Location(Location::Recents))
2024-10-21 13:51:10 -06:00
.class(theme::Button::Text)
2024-08-16 14:42:09 +02:00
.into(),
);
}
Location::Network(uri, display_name, path) => {
children.push(
2024-09-20 09:24:03 -06:00
widget::button::custom(widget::text::heading(display_name))
.padding(space_xxxs)
2024-09-13 15:13:37 -06:00
.on_press(Message::Location(Location::Network(
uri.clone(),
display_name.clone(),
path.clone(),
2024-09-13 15:13:37 -06:00
)))
2024-10-21 13:51:10 -06:00
.class(theme::Button::Text)
.into(),
);
}
2024-01-10 12:57:30 -07:00
}
2024-01-29 11:58:36 -07:00
row = row.extend(children);
2024-07-31 14:11:37 +02:00
let mut column = widget::column::with_capacity(4).padding([0, space_s]);
2024-07-13 01:53:30 +02:00
column = column.push(row);
2024-10-25 13:24:17 +02:00
column = column.push(accent_rule);
2024-07-31 14:11:37 +02:00
if self.config.view == View::List && !condensed {
column = column.push(heading_row);
2024-10-25 13:24:17 +02:00
column = column.push(heading_rule);
2024-07-31 14:11:37 +02:00
}
2024-07-13 01:53:30 +02:00
let mouse_area = crate::mouse_area::MouseArea::new(column)
2025-10-09 19:17:55 -06:00
.on_right_press(Message::LocationContextMenuPoint);
let mut popover = widget::popover(mouse_area);
if let (Some(point), Some(index)) = (
self.location_context_menu_point,
self.location_context_menu_index,
) {
popover = popover
.popup(menu::location_context_menu(index))
.position(widget::popover::Position::Point(point));
}
popover.into()
2024-01-10 12:57:30 -07:00
}
pub fn empty_view(&self, has_hidden: bool) -> Element<'_, Message> {
let cosmic_theme::Spacing { space_xxs, .. } = theme::active().cosmic().spacing;
2024-01-03 15:27:32 -07:00
mouse_area::MouseArea::new(widget::column::with_children([widget::container(
match self.mode {
Mode::App | Mode::Dialog(_) => widget::column::with_children([
widget::icon::from_name("folder-symbolic")
.size(64)
.icon()
2024-08-20 13:26:10 -06:00
.into(),
widget::text::body(if has_hidden {
fl!("empty-folder-hidden")
} else if matches!(self.location, Location::Search(..)) {
fl!("no-results")
} else {
fl!("empty-folder")
})
.into(),
]),
Mode::Desktop => widget::column(),
}
.align_x(Alignment::Center)
.spacing(space_xxs),
)
.center(Length::Fill)
.into()]))
.on_press(|_| Message::Click(None))
2024-01-05 09:36:16 -07:00
.into()
}
pub fn grid_view(
&self,
) -> (
Option<Element<'static, Message>>,
Element<'_, Message>,
bool,
) {
2024-02-29 11:25:46 -07:00
let cosmic_theme::Spacing {
space_xxs,
space_xxxs,
..
} = theme::active().cosmic().spacing;
2024-01-05 09:36:16 -07:00
let TabConfig {
show_hidden,
mut icon_sizes,
2024-03-13 23:23:00 -04:00
..
} = self.config;
2024-01-29 11:25:43 -07:00
let mut grid_spacing = space_xxs;
if let Location::Desktop(_path, _output, desktop_config) = &self.location {
icon_sizes.grid = desktop_config.icon_size;
grid_spacing = desktop_config.grid_spacing_for(space_xxs);
}
let text_height = 3 * 20; // 3 lines of text
let item_width = (3 * space_xxs + icon_sizes.grid() + 3 * space_xxs) as usize;
2024-02-29 20:35:40 -07:00
let item_height =
(space_xxxs + icon_sizes.grid() + space_xxxs + text_height + space_xxxs) as usize;
let (width, height) = match self.size_opt.get() {
Some(size) => (
(size.width.floor() as usize)
.saturating_sub(2 * (space_xxs as usize))
2024-02-29 20:35:40 -07:00
.max(item_width),
(size.height.floor() as usize).max(item_height),
),
2024-02-29 20:35:40 -07:00
None => (item_width, item_height),
2024-02-29 11:40:31 -07:00
};
2024-02-29 11:25:46 -07:00
let (cols, column_spacing) = {
2025-01-28 23:52:11 -05:00
let width_m1 = width.saturating_sub(item_width);
let cols_m1 = width_m1 / (item_width + grid_spacing as usize);
2024-02-29 11:25:46 -07:00
let cols = cols_m1 + 1;
let spacing = width_m1
.checked_div(cols_m1)
.unwrap_or(0)
2025-01-28 23:52:11 -05:00
.saturating_sub(item_width);
2024-02-29 11:25:46 -07:00
(cols, spacing as u16)
};
2024-08-20 13:26:10 -06:00
let rows = {
2025-01-28 23:52:11 -05:00
let height_m1 = height.saturating_sub(item_height);
let rows_m1 = height_m1 / (item_height + grid_spacing as usize);
2024-08-20 13:26:10 -06:00
rows_m1 + 1
};
2025-07-10 10:52:57 -06:00
//TODO: move to function
let visible_rect = {
let point = match self.scroll_opt {
Some(offset) => Point::new(0.0, offset.y),
None => Point::new(0.0, 0.0),
};
let size = self.size_opt.get().unwrap_or_else(|| Size::new(0.0, 0.0));
Rectangle::new(point, size)
};
2024-02-29 11:25:46 -07:00
let mut grid = widget::grid()
.column_spacing(column_spacing)
.row_spacing(grid_spacing)
.padding(space_xxs.into());
let mut dnd_items: Vec<(usize, (usize, usize), &Item)> = Vec::new();
let mut drag_w_i = usize::MAX;
let mut drag_n_i = usize::MAX;
let mut drag_e_i = 0;
let mut drag_s_i = 0;
let mut column = widget::column::with_capacity(2);
if let Some(items) = self.column_sort() {
let mut count = 0;
2024-02-29 11:25:46 -07:00
let mut col = 0;
let mut row = 0;
2024-08-20 13:26:10 -06:00
let mut page_row = 0;
let mut hidden = 0;
2024-08-20 13:26:10 -06:00
let mut grid_elements = Vec::new();
for &(i, item) in &items {
if !show_hidden && item.hidden {
item.pos_opt.set(None);
2024-02-29 11:25:46 -07:00
item.rect_opt.set(None);
hidden += 1;
continue;
}
item.pos_opt.set(Some((row, col)));
2025-07-10 10:52:57 -06:00
let item_rect = Rectangle::new(
2024-02-29 11:25:46 -07:00
Point::new(
(col * (item_width + column_spacing as usize) + space_xxs as usize) as f32,
(row * (item_height + grid_spacing as usize) + space_xxs as usize) as f32,
2024-02-29 11:25:46 -07:00
),
2024-02-29 20:35:40 -07:00
Size::new(item_width as f32, item_height as f32),
2025-07-10 10:52:57 -06:00
);
item.rect_opt.set(Some(item_rect));
2025-07-10 10:52:57 -06:00
//TODO: error if the row or col is already set?
while grid_elements.len() <= row {
grid_elements.push(Vec::new());
2024-01-05 15:10:46 -07:00
}
2024-02-29 11:25:46 -07:00
2025-07-10 10:52:57 -06:00
// Only build elements if visible (for performance)
if item_rect.intersects(&visible_rect) {
//TODO: one focus group per grid item (needs custom widget)
let buttons: Vec<Element<Message>> = vec![
widget::button::custom(
widget::icon::icon(item.icon_handle_grid.clone())
.content_fit(ContentFit::Contain)
.size(icon_sizes.grid())
.width(Length::Shrink),
)
.padding(space_xxxs)
.class(button_style(
item.selected,
item.highlighted,
item.cut,
false,
false,
false,
))
.into(),
widget::tooltip(
widget::button::custom(widget::text::body(&item.display_name))
.id(item.button_id.clone())
.padding([0, space_xxxs])
.class(button_style(
item.selected,
item.highlighted,
item.cut,
true,
true,
matches!(self.mode, Mode::Desktop),
)),
widget::text::body(&item.name),
widget::tooltip::Position::Bottom,
)
.into(),
];
let mut column = widget::column::with_capacity(buttons.len())
.align_x(Alignment::Center)
.height(Length::Fixed(item_height as f32))
.width(Length::Fixed(item_width as f32));
for button in buttons {
if self.context_menu.is_some() {
column = column.push(button);
2025-07-10 10:52:57 -06:00
} else {
column = column.push(
mouse_area::MouseArea::new(button)
.on_right_press_no_capture()
.wayland_on_right_press_window_position()
.on_right_press(move |point_opt| {
Message::RightClick(point_opt, Some(i))
}),
2025-07-10 10:52:57 -06:00
);
}
}
2025-07-10 10:52:57 -06:00
let column: Element<Message> =
if item.metadata.is_dir() && item.location_opt.is_some() {
self.dnd_dest(&item.location_opt.clone().unwrap(), column)
} else {
column.into()
};
2024-08-20 13:26:10 -06:00
2025-07-10 10:52:57 -06:00
if item.selected {
dnd_items.push((i, (row, col), item));
drag_w_i = drag_w_i.min(col);
drag_n_i = drag_n_i.min(row);
drag_e_i = drag_e_i.max(col);
drag_s_i = drag_s_i.max(row);
}
let mouse_area = crate::mouse_area::MouseArea::new(column)
.on_press(move |_| Message::Click(Some(i)))
.on_double_click(move |_| Message::DoubleClick(Some(i)))
.on_release(move |_| Message::ClickRelease(Some(i)))
.on_middle_press(move |_| Message::MiddleClick(i))
.on_enter(move || Message::HighlightActivate(i))
.on_exit(move || Message::HighlightDeactivate(i));
grid_elements[row].push(Element::from(mouse_area));
} else {
// Add a spacer if the row is empty, so scroll works
if grid_elements[row].is_empty() {
grid_elements[row].push(Element::from(
widget::column()
.width(Length::Fill)
.height(Length::Fixed(item_height as f32)),
));
}
2024-08-20 13:26:10 -06:00
}
2024-02-29 11:25:46 -07:00
2024-01-05 09:36:16 -07:00
count += 1;
2024-08-20 13:26:10 -06:00
if matches!(self.mode, Mode::Desktop) {
2024-02-29 11:25:46 -07:00
row += 1;
2024-08-20 13:26:10 -06:00
if row >= page_row + rows {
row = 0;
col += 1;
}
if col >= cols {
col = 0;
page_row += rows;
row = page_row;
}
} else {
col += 1;
if col >= cols {
col = 0;
row += 1;
}
}
}
for row_elements in grid_elements {
for element in row_elements {
grid = grid.push(element);
2024-02-29 11:25:46 -07:00
}
2024-08-20 13:26:10 -06:00
grid = grid.insert_row();
2024-01-05 09:36:16 -07:00
}
if count == 0 {
2024-05-09 13:28:44 -06:00
return (None, self.empty_view(hidden > 0), false);
2024-01-05 09:36:16 -07:00
}
2024-02-29 11:40:31 -07:00
column = column.push(grid);
2024-02-29 11:40:31 -07:00
//TODO: HACK If we don't reach the bottom of the view, go ahead and add a spacer to do that
{
2024-02-29 11:40:31 -07:00
let mut max_bottom = 0;
for (_, item) in items {
if let Some(rect) = item.rect_opt.get() {
2024-02-29 11:40:31 -07:00
let bottom = (rect.y + rect.height).ceil() as usize;
if bottom > max_bottom {
max_bottom = bottom;
}
}
}
let top_deduct = 7 * (space_xxs as usize);
self.item_view_size_opt
.set(self.size_opt.get().map(|s| Size {
width: s.width,
height: s.height - top_deduct as f32,
}));
2025-01-28 23:52:11 -05:00
let spacer_height = height.saturating_sub(max_bottom + top_deduct);
2024-02-29 11:40:31 -07:00
if spacer_height > 0 {
column = column.push(widget::container(Space::with_height(Length::Fixed(
spacer_height as f32,
))));
2024-02-29 11:40:31 -07:00
}
}
2024-01-05 09:36:16 -07:00
}
2024-02-29 11:40:31 -07:00
let drag_list = (!dnd_items.is_empty()).then(|| {
let mut dnd_grid = widget::grid()
.column_spacing(column_spacing)
.row_spacing(grid_spacing)
.padding(space_xxs.into());
let mut dnd_item_i = 0;
for r in drag_n_i..=drag_s_i {
dnd_grid = dnd_grid.insert_row();
for c in drag_w_i..=drag_e_i {
let Some((i, (row, col), item)) = dnd_items.get(dnd_item_i) else {
break;
};
if *row == r && *col == c {
let buttons = vec![
widget::button::custom(
widget::icon::icon(item.icon_handle_grid.clone())
.content_fit(ContentFit::Contain)
.size(icon_sizes.grid()),
)
.on_press(Message::Click(Some(*i)))
.padding(space_xxxs)
.class(button_style(
item.selected,
item.highlighted,
item.cut,
false,
false,
false,
)),
widget::button::custom(widget::text::body(item.display_name.clone()))
.id(item.button_id.clone())
.on_press(Message::Click(Some(*i)))
.padding([0, space_xxxs])
.class(button_style(
item.selected,
item.highlighted,
2025-04-28 23:35:27 +10:00
item.cut,
true,
true,
false,
)),
];
let column =
widget::column::with_children(buttons.into_iter().map(Element::from))
.align_x(Alignment::Center)
.height(Length::Fixed(item_height as f32))
.width(Length::Fixed(item_width as f32));
dnd_grid = dnd_grid.push(column);
dnd_item_i += 1;
} else {
dnd_grid = dnd_grid.push(
widget::container(Space::with_height(item_width as f32))
.height(Length::Fixed(item_height as f32)),
);
}
}
}
Element::from(dnd_grid)
});
let mut mouse_area = mouse_area::MouseArea::new(column.width(Length::Fill))
.on_press(|_| Message::Click(None))
.on_auto_scroll(Message::AutoScroll)
.on_drag_end(|_| Message::DragEnd)
.show_drag_rect(self.mode.multiple())
.on_release(|_| Message::ClickRelease(None));
if self.watch_drag {
mouse_area = mouse_area.on_drag(Message::Drag);
}
(drag_list, mouse_area.into(), true)
2024-01-05 09:36:16 -07:00
}
pub fn list_view(
&self,
) -> (
Option<Element<'static, Message>>,
Element<'_, Message>,
bool,
) {
let cosmic_theme::Spacing {
space_s, space_xxs, ..
} = theme::active().cosmic().spacing;
2024-01-05 09:36:16 -07:00
2024-02-29 19:47:27 -07:00
let TabConfig {
show_hidden,
icon_sizes,
2024-05-28 10:40:36 -06:00
..
2024-02-29 19:47:27 -07:00
} = self.config;
let size = self.size_opt.get().unwrap_or_else(|| Size::new(0.0, 0.0));
2024-02-29 19:47:27 -07:00
//TODO: allow resizing?
let name_width = 300.0;
let modified_width = 200.0;
let size_width = 100.0;
let condensed = size.width < (name_width + modified_width + size_width);
let is_search = matches!(self.location, Location::Search(..));
let icon_size = if condensed || is_search {
2024-02-29 19:47:27 -07:00
icon_sizes.list_condensed()
} else {
icon_sizes.list()
};
let row_height = icon_size + 2 * space_xxs;
2024-01-29 10:39:12 -07:00
let mut column = widget::column::with_capacity(3);
let mut y: f32 = 0.0;
2024-01-05 15:32:42 -07:00
2024-10-25 13:24:17 +02:00
let rule_padding = theme::active().cosmic().corner_radii.radius_xs[0] as u16;
2025-07-10 10:52:57 -06:00
//TODO: move to function
let visible_rect = {
let point = match self.scroll_opt {
Some(offset) => Point::new(0.0, offset.y),
None => Point::new(0.0, 0.0),
};
let size = self.size_opt.get().unwrap_or_else(|| Size::new(0.0, 0.0));
Rectangle::new(point, size)
};
let mut drag_items = Vec::new();
2025-07-10 10:52:57 -06:00
if let Some(items) = self.column_sort() {
2024-01-05 09:36:16 -07:00
let mut count = 0;
let mut hidden = 0;
2024-02-29 18:58:59 -07:00
for (i, item) in items {
2024-10-04 11:52:34 +02:00
if item.hidden && !show_hidden {
item.pos_opt.set(None);
2024-02-29 11:25:46 -07:00
item.rect_opt.set(None);
2024-01-05 09:36:16 -07:00
hidden += 1;
continue;
}
if count > 0 {
column = column
.push(widget::container(horizontal_rule(1)).padding([0, rule_padding]));
y += 1.0;
}
item.pos_opt.set(Some((count, 0)));
let item_rect = Rectangle::new(
Point::new(f32::from(space_s), y),
Size::new(size.width - f32::from(2 * space_s), f32::from(row_height)),
);
item.rect_opt.set(Some(item_rect));
2025-07-10 10:52:57 -06:00
// Only build elements if visible (for performance)
let button_row = if item_rect.intersects(&visible_rect) {
let modified_text = match &item.metadata {
ItemMetadata::Path { metadata, .. } => match metadata.modified() {
Ok(time) => self.format_time(time).to_string(),
Err(_) => String::new(),
},
ItemMetadata::Trash { entry, .. } => FormatTime::from_secs(
entry.time_deleted,
&self.date_time_formatter,
&self.time_formatter,
)
.map(|t| t.to_string())
.unwrap_or_default(),
#[cfg(feature = "gvfs")]
ItemMetadata::GvfsPath { .. } => match item.metadata.modified() {
Some(mtime) => self.format_time(mtime).to_string(),
None => String::new(),
},
_ => String::new(),
};
2024-01-29 10:39:12 -07:00
2025-07-10 10:52:57 -06:00
let size_text = match &item.metadata {
ItemMetadata::Path {
metadata,
children_opt,
} => {
if metadata.is_dir() {
//TODO: translate
if let Some(children) = children_opt {
if *children == 1 {
format!("{children} item")
2025-07-10 10:52:57 -06:00
} else {
format!("{children} items")
2025-07-10 10:52:57 -06:00
}
} else {
2025-07-10 10:52:57 -06:00
String::new()
}
2024-09-13 15:13:37 -06:00
} else {
2025-07-10 10:52:57 -06:00
format_size(metadata.len())
2024-09-13 15:13:37 -06:00
}
2024-01-29 10:39:12 -07:00
}
2025-07-10 10:52:57 -06:00
ItemMetadata::Trash { metadata, .. } => match metadata.size {
trash::TrashItemSize::Entries(entries) => {
//TODO: translate
if entries == 1 {
format!("{entries} item")
2025-07-10 10:52:57 -06:00
} else {
format!("{entries} items")
2025-07-10 10:52:57 -06:00
}
}
trash::TrashItemSize::Bytes(bytes) => format_size(bytes),
},
ItemMetadata::SimpleDir { entries } => {
2024-01-29 10:39:12 -07:00
//TODO: translate
2025-07-10 10:52:57 -06:00
if *entries == 1 {
format!("{entries} item")
2024-01-29 10:39:12 -07:00
} else {
format!("{entries} items")
2024-01-29 10:39:12 -07:00
}
}
2025-07-10 10:52:57 -06:00
ItemMetadata::SimpleFile { size } => format_size(*size),
#[cfg(feature = "gvfs")]
ItemMetadata::GvfsPath {
size_opt,
children_opt,
..
} => match children_opt {
Some(child_count) => {
if *child_count == 1 {
format!("{child_count} item")
2025-07-10 10:52:57 -06:00
} else {
format!("{child_count} items")
2025-07-10 10:52:57 -06:00
}
}
2025-07-10 10:52:57 -06:00
None => format_size(size_opt.unwrap_or_default()),
},
};
2025-07-10 10:52:57 -06:00
let row = if condensed {
widget::row::with_children([
widget::icon::icon(item.icon_handle_list_condensed.clone())
.content_fit(ContentFit::Contain)
.size(icon_size)
.into(),
widget::column::with_children([
widget::text::body(item.display_name.clone()).into(),
//TODO: translate?
widget::text::caption(format!("{modified_text} - {size_text}"))
.into(),
])
2024-01-05 15:10:46 -07:00
.into(),
])
.height(Length::Fixed(f32::from(row_height)))
2024-10-21 13:51:10 -06:00
.align_y(Alignment::Center)
.spacing(space_xxs)
} else if is_search {
widget::row::with_children([
widget::icon::icon(item.icon_handle_list_condensed.clone())
.content_fit(ContentFit::Contain)
.size(icon_size)
.into(),
widget::column::with_children([
widget::text::body(item.display_name.clone()).into(),
widget::text::caption(match item.path_opt() {
Some(path) => path.display().to_string(),
None => String::new(),
})
.into(),
])
.width(Length::Fill)
.into(),
widget::text::body(modified_text.clone())
.width(Length::Fixed(modified_width))
.into(),
widget::text::body(size_text.clone())
.width(Length::Fixed(size_width))
.into(),
])
.height(Length::Fixed(f32::from(row_height)))
2024-10-21 13:51:10 -06:00
.align_y(Alignment::Center)
.spacing(space_xxs)
} else {
widget::row::with_children([
widget::icon::icon(item.icon_handle_list.clone())
.content_fit(ContentFit::Contain)
.size(icon_size)
.into(),
widget::text::body(item.display_name.clone())
2024-08-20 13:26:10 -06:00
.width(Length::Fill)
.into(),
2025-07-10 10:52:57 -06:00
widget::text::body(modified_text.clone())
.width(Length::Fixed(modified_width))
.into(),
2025-07-10 10:52:57 -06:00
widget::text::body(size_text.clone())
.width(Length::Fixed(size_width))
.into(),
])
.height(Length::Fixed(f32::from(row_height)))
2024-10-21 13:51:10 -06:00
.align_y(Alignment::Center)
.spacing(space_xxs)
};
2025-07-10 10:52:57 -06:00
let button = |row| {
let mouse_area = crate::mouse_area::MouseArea::new(
widget::button::custom(row)
.width(Length::Fill)
.id(item.button_id.clone())
.padding([0, space_xxs])
.class(button_style(
item.selected,
item.highlighted,
item.cut,
true,
true,
false,
)),
)
.on_press(move |_| Message::Click(Some(i)))
.on_double_click(move |_| Message::DoubleClick(Some(i)))
.on_release(move |_| Message::ClickRelease(Some(i)))
.on_middle_press(move |_| Message::MiddleClick(i))
.on_enter(move || Message::HighlightActivate(i))
.on_exit(move || Message::HighlightDeactivate(i));
if self.context_menu.is_some() {
mouse_area
} else {
mouse_area
.on_right_press_no_capture()
.wayland_on_right_press_window_position()
.on_right_press(move |point_opt| {
Message::RightClick(point_opt, Some(i))
})
2025-07-10 10:52:57 -06:00
}
};
let button_row = button(row.into());
let button_row: Element<_> =
if item.metadata.is_dir() && item.location_opt.is_some() {
self.dnd_dest(item.location_opt.as_ref().unwrap(), button_row)
} else {
button_row.into()
};
if item.selected || !drag_items.is_empty() {
let dnd_row = if !item.selected {
Element::from(Space::with_height(Length::Fixed(f32::from(row_height))))
2025-07-10 10:52:57 -06:00
} else if condensed {
widget::row::with_children([
2025-07-10 10:52:57 -06:00
widget::icon::icon(item.icon_handle_list_condensed.clone())
.content_fit(ContentFit::Contain)
.size(icon_size)
.into(),
widget::column::with_children([
2025-07-10 10:52:57 -06:00
widget::text::body(item.display_name.clone()).into(),
//TODO: translate?
widget::text::body(format!("{modified_text} - {size_text}"))
.into(),
2025-07-10 10:52:57 -06:00
])
.into(),
2025-07-10 10:52:57 -06:00
])
.align_y(Alignment::Center)
.spacing(space_xxs)
.into()
} else if is_search {
widget::row::with_children([
2025-07-10 10:52:57 -06:00
widget::icon::icon(item.icon_handle_list_condensed.clone())
.content_fit(ContentFit::Contain)
.size(icon_size)
.into(),
widget::column::with_children([
2025-07-10 10:52:57 -06:00
widget::text::body(item.display_name.clone()).into(),
widget::text::caption(match item.path_opt() {
Some(path) => path.display().to_string(),
None => String::new(),
})
.into(),
])
.width(Length::Fill)
.into(),
widget::text::body(modified_text.clone())
.width(Length::Fixed(modified_width))
.into(),
widget::text::body(size_text.clone())
.width(Length::Fixed(size_width))
.into(),
])
.align_y(Alignment::Center)
.spacing(space_xxs)
.into()
} else {
widget::row::with_children([
2025-07-10 10:52:57 -06:00
widget::icon::icon(item.icon_handle_list.clone())
.content_fit(ContentFit::Contain)
.size(icon_size)
.into(),
widget::text::body(item.display_name.clone())
.width(Length::Fill)
.into(),
widget::text(modified_text)
.width(Length::Fixed(modified_width))
.into(),
widget::text::body(size_text)
.width(Length::Fixed(size_width))
.into(),
])
.align_y(Alignment::Center)
.spacing(space_xxs)
.into()
};
if item.selected {
drag_items.push(
widget::container(button(dnd_row))
.width(Length::Shrink)
.into(),
);
} else {
drag_items.push(dnd_row);
}
}
2025-07-10 10:52:57 -06:00
button_row
} else {
widget::column()
.width(Length::Fill)
.height(Length::Fixed(f32::from(row_height)))
2025-07-10 10:52:57 -06:00
.into()
};
count += 1;
y += f32::from(row_height);
column = column.push(button_row);
2024-01-03 15:27:32 -07:00
}
if count == 0 {
2024-05-09 13:28:44 -06:00
return (None, self.empty_view(hidden > 0), false);
}
2024-01-03 15:27:32 -07:00
}
//TODO: HACK If we don't reach the bottom of the view, go ahead and add a spacer to do that
{
let top_deduct = (if condensed || is_search { 6 } else { 9 }) * space_xxs;
self.item_view_size_opt
.set(self.size_opt.get().map(|s| Size {
width: s.width,
height: s.height - f32::from(top_deduct),
}));
let spacer_height = size.height - y - f32::from(top_deduct);
if spacer_height > 0. {
column = column.push(widget::container(Space::with_height(spacer_height)));
}
}
2024-10-21 13:51:10 -06:00
let drag_col = (!drag_items.is_empty())
.then(|| Element::from(widget::column::with_children(drag_items)));
let mut mouse_area = mouse_area::MouseArea::new(column.padding([0, space_s]))
.with_id(Id::new("list-view"))
.on_press(|_| Message::Click(None))
.on_auto_scroll(Message::AutoScroll)
.on_drag_end(|_| Message::DragEnd)
.show_drag_rect(self.mode.multiple())
.on_release(|_| Message::ClickRelease(None));
if self.watch_drag {
mouse_area = mouse_area.on_drag(Message::Drag);
}
(drag_col, mouse_area.into(), true)
2024-01-05 09:36:16 -07:00
}
pub fn view_responsive(
&self,
key_binds: &HashMap<KeyBind, Action>,
size: Size,
) -> Element<'_, Message> {
// Update cached size
self.size_opt.set(Some(size));
let cosmic_theme::Spacing {
space_xxxs,
space_xxs,
space_xs,
..
} = theme::active().cosmic().spacing;
2024-08-20 13:26:10 -06:00
let location_view_opt = if matches!(self.mode, Mode::Desktop) {
None
} else {
Some(self.location_view())
};
2024-05-29 23:33:12 +02:00
let (drag_list, mut item_view, can_scroll) = match self.config.view {
View::Grid => self.grid_view(),
View::List => self.list_view(),
2024-02-26 12:05:29 -07:00
};
item_view = widget::container(item_view).width(Length::Fill).into();
let files = self
.items_opt
.as_ref()
.map(|items| {
items
.iter()
.filter_map(|item| {
if item.selected {
item.path_opt().cloned()
} else {
None
}
})
.collect::<Box<[PathBuf]>>()
})
.unwrap_or_default();
2024-10-21 13:51:10 -06:00
let item_view =
DndSource::<Message, ClipboardCopy>::with_id(item_view, Id::new("tab-view"));
let view = self.config.view;
let item_view = match drag_list {
Some(drag_list) if self.selected_clicked => {
let drag_list = RcElementWrapper::<Message>(Rc::new(RefCell::new(drag_list)));
item_view
.drag_content(move || {
ClipboardCopy::new(crate::clipboard::ClipboardKind::Copy, &files)
})
.drag_icon(move |_| {
2024-10-21 13:51:10 -06:00
let state: tree::State = Widget::<Message, _, _>::state(&drag_list);
(
Element::from(drag_list.clone()).map(|_m| ()),
state,
match view {
// offset by grid padding so that we grab the top left corner of the item in the drag grid.
View::Grid => Vector::new(
f32::from(space_xxs).mul_add(-3.0, -f32::from(space_xxxs)),
-4. * f32::from(space_xxxs),
),
View::List => Vector::ZERO,
},
)
})
}
_ => item_view,
};
let tab_location = self.location.clone();
let mouse_area = mouse_area::MouseArea::new(item_view)
.on_press(move |_point_opt| Message::Click(None))
.on_release(|_| Message::ClickRelease(None))
.on_resize(Message::Resize)
.on_back_press(move |_point_opt| Message::GoPrevious)
.on_forward_press(move |_point_opt| Message::GoNext)
.on_scroll(|delta| respond_to_scroll_direction(delta, self.modifiers))
.on_right_press(move |p| {
Message::ContextMenu(
if self.context_menu.is_some() { None } else { p },
2025-10-03 03:24:44 +02:00
self.window_id,
)
})
.wayland_on_right_press_window_position();
2024-02-27 09:52:39 -07:00
let mut popover = widget::popover(mouse_area);
if let Some(point) = self.context_menu {
if !cfg!(feature = "wayland") || !crate::is_wayland() {
let context_menu = menu::context_menu(self, key_binds, &self.modifiers);
popover = popover
.popup(context_menu)
.position(widget::popover::Position::Point(point));
}
2024-02-26 12:05:29 -07:00
}
2024-05-09 13:28:44 -06:00
let mut tab_column = widget::column::with_capacity(3);
2024-08-20 13:26:10 -06:00
if let Some(location_view) = location_view_opt {
tab_column = tab_column.push(location_view);
}
2024-05-09 13:28:44 -06:00
if can_scroll {
tab_column = tab_column.push(
widget::scrollable(popover)
.id(self.scrollable_id.clone())
.on_scroll(Message::Scroll)
.width(Length::Fill)
.height(Length::Fill),
2024-05-09 13:28:44 -06:00
);
} else {
tab_column = tab_column.push(popover);
}
match &self.location {
Location::Trash => {
if let Some(items) = self.items_opt() {
if !items.is_empty() {
tab_column = tab_column.push(
widget::layer_container(widget::row::with_children([
2024-10-21 13:51:10 -06:00
widget::horizontal_space().into(),
widget::button::standard(fl!("empty-trash"))
.on_press(Message::EmptyTrash)
.into(),
]))
.padding([space_xxs, space_xs])
.layer(cosmic_theme::Layer::Primary)
.apply(widget::container)
.padding([0, 0, 7, 0]),
);
}
2024-05-09 13:24:06 -06:00
}
}
Location::Network(uri, _display_name, _path) if uri == "network:///" => {
tab_column = tab_column.push(
widget::layer_container(widget::row::with_children([
2024-10-21 13:51:10 -06:00
widget::horizontal_space().into(),
widget::button::standard(fl!("add-network-drive"))
.on_press(Message::AddNetworkDrive)
.into(),
]))
.padding([space_xxs, space_xs])
.layer(cosmic_theme::Layer::Primary)
.apply(widget::container)
.padding([0, 0, 7, 0]),
);
}
_ => {}
2024-05-09 13:24:06 -06:00
}
let mut tab_view = widget::container(tab_column)
.height(Length::Fill)
.width(Length::Fill);
// Desktop will not show DnD indicator
if self.dnd_hovered.as_ref().map(|(l, _)| l) == Some(&tab_location)
&& !matches!(self.mode, Mode::Desktop)
{
2024-10-21 13:51:10 -06:00
tab_view = tab_view.style(|t| {
let mut a = widget::container::Style::default();
let c = t.cosmic();
a.border = cosmic::iced_core::Border {
color: (c.accent_color()).into(),
width: 1.,
radius: c.radius_0().into(),
};
a
2024-10-21 13:51:10 -06:00
});
}
let tab_location_2 = self.location.clone();
let tab_location_3 = self.location.clone();
let dnd_dest = DndDestination::for_data(tab_view, move |data, action| {
if let Some(mut data) = data {
if action == DndAction::Copy {
Message::Drop(Some((tab_location.clone(), data)))
} else if action == DndAction::Move {
data.kind = ClipboardKind::Cut { is_dnd: true };
Message::Drop(Some((tab_location.clone(), data)))
} else {
log::warn!("unsupported action: {action:?}");
Message::Drop(None)
}
} else {
Message::Drop(None)
}
})
.on_enter(move |_, _, _| Message::DndEnter(tab_location_2.clone()))
.on_leave(move || Message::DndLeave(tab_location_3.clone()));
dnd_dest.into()
2024-01-03 15:27:32 -07:00
}
2024-02-22 16:17:39 -07:00
pub fn view<'a>(&'a self, key_binds: &'a HashMap<KeyBind, Action>) -> Element<'a, Message> {
widget::responsive(|size| self.view_responsive(key_binds, size)).into()
}
pub fn subscription(&self, preview: bool) -> Subscription<Message> {
//TODO: how many thumbnail loads should be in flight at once?
2025-10-03 03:24:44 +02:00
let jobs = self.thumb_config.jobs.get() as usize;
2025-02-07 09:11:20 -07:00
let mut subscriptions = Vec::with_capacity(jobs + 3);
if let Some(items) = &self.items_opt {
//TODO: move to function
let visible_rect = {
let point = match self.scroll_opt {
Some(offset) => Point::new(0.0, offset.y),
None => Point::new(0.0, 0.0),
};
let size = self.size_opt.get().unwrap_or_else(|| Size::new(0.0, 0.0));
Rectangle::new(point, size)
};
for item in items {
2025-02-07 09:11:20 -07:00
if item.thumbnail_opt.is_some() {
// Skip items that already have a mime type and thumbnail
continue;
}
2025-02-07 09:11:20 -07:00
match item.rect_opt.get() {
Some(rect) => {
if !rect.intersects(&visible_rect) {
// Skip items that are not visible
continue;
}
}
None => {
// Skip items with no determined rect (this should include hidden items)
continue;
}
}
let Some(path) = item.path_opt().cloned() else {
2025-02-07 09:11:20 -07:00
continue;
};
2024-03-20 09:56:54 -06:00
let metadata = item.metadata.clone();
2025-06-18 20:54:58 -06:00
let can_thumbnail = match metadata {
ItemMetadata::Path { .. } => true,
#[cfg(feature = "gvfs")]
ItemMetadata::GvfsPath { .. } => true,
_ => false,
};
if can_thumbnail {
let mime = item.mime.clone();
2025-10-03 03:24:44 +02:00
let max_jobs = jobs;
let max_mb = u64::from(self.thumb_config.max_mem_mb.get());
let max_size = u64::from(self.thumb_config.max_size_mb.get());
2025-06-18 20:54:58 -06:00
subscriptions.push(Subscription::run_with_id(
("thumbnail", path.clone()),
stream::channel(1, move |mut output| async move {
2025-06-18 20:54:58 -06:00
let message = {
let path = path.clone();
_ = THUMB_SEMAPHORE.acquire().await;
2025-06-18 20:54:58 -06:00
tokio::task::spawn_blocking(move || {
let start = Instant::now();
let thumbnail = ItemThumbnail::new(
&path,
metadata,
mime,
THUMBNAIL_SIZE,
max_mb,
max_jobs,
max_size,
);
log::debug!(
"thumbnailed {} in {:?}",
path.display(),
start.elapsed()
);
Message::Thumbnail(path, thumbnail)
2025-06-18 20:54:58 -06:00
})
.await
.unwrap()
};
2024-02-22 16:17:39 -07:00
2025-06-18 20:54:58 -06:00
match output.send(message).await {
Ok(()) => {}
Err(err) => {
log::warn!(
"failed to send thumbnail for {}: {}",
path.display(),
err
);
}
2025-06-18 20:54:58 -06:00
}
2025-06-18 20:54:58 -06:00
std::future::pending().await
}),
));
}
2024-02-22 16:17:39 -07:00
2025-02-07 09:11:20 -07:00
if subscriptions.len() >= jobs {
break;
}
2024-02-22 16:17:39 -07:00
}
2024-10-09 18:55:09 -06:00
2025-02-07 09:11:20 -07:00
if preview {
// Load directory size for selected items
if let Some(item) = items
.iter()
.find(|&item| item.selected)
2025-02-07 09:11:20 -07:00
.or(self.parent_item_opt.as_ref())
{
// Item must have a path
if let Some(path) = item.path_opt().cloned() {
2025-02-07 09:11:20 -07:00
// Item must be calculating directory size
if let DirSize::Calculating(controller) = &item.dir_size {
let controller = controller.clone();
subscriptions.push(Subscription::run_with_id(
("dir_size", path.clone()),
stream::channel(1, |mut output| async move {
let message = {
feat: use io_uring / IOCP when available for async file IO (#911) Spawns a single thread for handling async file IO on the [compio runtime](https://github.com/compio-rs/compio). It is a completion-based IO runtime that can dynamically select a polling mechanism at runtime. It defaults to io_uring on Linux, IOCP on Windows, and the polling crate everywhere else. On Linux systems where io_uring is unavailable or disabled, it will fall back to the polling crate. This eliminates most of the threads that were needed previously. It significantly reduced the amount of memory needed in the recursive Context to get a good transfer rate for each copy operation—from a 4 MB buffer to 128 KB. Copies on a nvme drive are somewhat faster with the async IO changes, and use less CPU than before. Although it uses a single thread for non-blocking tasks, it still manages to 100% max out my nvme drive's activity for the whole duration of multiple long transfers. But it would be possible to enable compio's dispatcher to spread operations across worker threads if necessary. All but the extract and compress operations were updated to be async. I had to switch the `CondVar` in the `Controller` to a `tokio::sync::Notify` to prevent the IO thread from being put to sleep when an operation is paused. Fixed a deadlock in the `operation_copy` test function that was performing an operation without concurrently pulling from the channel in the operation. Reduced the rate that `Message::None` is sent from a subscription to trigger a UI redraw, and fixed it to not run when operations are paused.
2025-04-09 23:15:07 +02:00
let start = Instant::now();
match calculate_dir_size(&path, controller).await {
Ok(size) => {
log::debug!(
"calculated directory size of {} in {:?}",
path.display(),
feat: use io_uring / IOCP when available for async file IO (#911) Spawns a single thread for handling async file IO on the [compio runtime](https://github.com/compio-rs/compio). It is a completion-based IO runtime that can dynamically select a polling mechanism at runtime. It defaults to io_uring on Linux, IOCP on Windows, and the polling crate everywhere else. On Linux systems where io_uring is unavailable or disabled, it will fall back to the polling crate. This eliminates most of the threads that were needed previously. It significantly reduced the amount of memory needed in the recursive Context to get a good transfer rate for each copy operation—from a 4 MB buffer to 128 KB. Copies on a nvme drive are somewhat faster with the async IO changes, and use less CPU than before. Although it uses a single thread for non-blocking tasks, it still manages to 100% max out my nvme drive's activity for the whole duration of multiple long transfers. But it would be possible to enable compio's dispatcher to spread operations across worker threads if necessary. All but the extract and compress operations were updated to be async. I had to switch the `CondVar` in the `Controller` to a `tokio::sync::Notify` to prevent the IO thread from being put to sleep when an operation is paused. Fixed a deadlock in the `operation_copy` test function that was performing an operation without concurrently pulling from the channel in the operation. Reduced the rate that `Message::None` is sent from a subscription to trigger a UI redraw, and fixed it to not run when operations are paused.
2025-04-09 23:15:07 +02:00
start.elapsed()
);
Message::DirectorySize(
path.clone(),
DirSize::Directory(size),
)
}
Err(err) => {
log::warn!(
"failed to calculate directory size of {}: {}",
path.display(),
err
);
feat: use io_uring / IOCP when available for async file IO (#911) Spawns a single thread for handling async file IO on the [compio runtime](https://github.com/compio-rs/compio). It is a completion-based IO runtime that can dynamically select a polling mechanism at runtime. It defaults to io_uring on Linux, IOCP on Windows, and the polling crate everywhere else. On Linux systems where io_uring is unavailable or disabled, it will fall back to the polling crate. This eliminates most of the threads that were needed previously. It significantly reduced the amount of memory needed in the recursive Context to get a good transfer rate for each copy operation—from a 4 MB buffer to 128 KB. Copies on a nvme drive are somewhat faster with the async IO changes, and use less CPU than before. Although it uses a single thread for non-blocking tasks, it still manages to 100% max out my nvme drive's activity for the whole duration of multiple long transfers. But it would be possible to enable compio's dispatcher to spread operations across worker threads if necessary. All but the extract and compress operations were updated to be async. I had to switch the `CondVar` in the `Controller` to a `tokio::sync::Notify` to prevent the IO thread from being put to sleep when an operation is paused. Fixed a deadlock in the `operation_copy` test function that was performing an operation without concurrently pulling from the channel in the operation. Reduced the rate that `Message::None` is sent from a subscription to trigger a UI redraw, and fixed it to not run when operations are paused.
2025-04-09 23:15:07 +02:00
Message::DirectorySize(
path.clone(),
DirSize::Error(err.to_string()),
feat: use io_uring / IOCP when available for async file IO (#911) Spawns a single thread for handling async file IO on the [compio runtime](https://github.com/compio-rs/compio). It is a completion-based IO runtime that can dynamically select a polling mechanism at runtime. It defaults to io_uring on Linux, IOCP on Windows, and the polling crate everywhere else. On Linux systems where io_uring is unavailable or disabled, it will fall back to the polling crate. This eliminates most of the threads that were needed previously. It significantly reduced the amount of memory needed in the recursive Context to get a good transfer rate for each copy operation—from a 4 MB buffer to 128 KB. Copies on a nvme drive are somewhat faster with the async IO changes, and use less CPU than before. Although it uses a single thread for non-blocking tasks, it still manages to 100% max out my nvme drive's activity for the whole duration of multiple long transfers. But it would be possible to enable compio's dispatcher to spread operations across worker threads if necessary. All but the extract and compress operations were updated to be async. I had to switch the `CondVar` in the `Controller` to a `tokio::sync::Notify` to prevent the IO thread from being put to sleep when an operation is paused. Fixed a deadlock in the `operation_copy` test function that was performing an operation without concurrently pulling from the channel in the operation. Reduced the rate that `Message::None` is sent from a subscription to trigger a UI redraw, and fixed it to not run when operations are paused.
2025-04-09 23:15:07 +02:00
)
}
feat: use io_uring / IOCP when available for async file IO (#911) Spawns a single thread for handling async file IO on the [compio runtime](https://github.com/compio-rs/compio). It is a completion-based IO runtime that can dynamically select a polling mechanism at runtime. It defaults to io_uring on Linux, IOCP on Windows, and the polling crate everywhere else. On Linux systems where io_uring is unavailable or disabled, it will fall back to the polling crate. This eliminates most of the threads that were needed previously. It significantly reduced the amount of memory needed in the recursive Context to get a good transfer rate for each copy operation—from a 4 MB buffer to 128 KB. Copies on a nvme drive are somewhat faster with the async IO changes, and use less CPU than before. Although it uses a single thread for non-blocking tasks, it still manages to 100% max out my nvme drive's activity for the whole duration of multiple long transfers. But it would be possible to enable compio's dispatcher to spread operations across worker threads if necessary. All but the extract and compress operations were updated to be async. I had to switch the `CondVar` in the `Controller` to a `tokio::sync::Notify` to prevent the IO thread from being put to sleep when an operation is paused. Fixed a deadlock in the `operation_copy` test function that was performing an operation without concurrently pulling from the channel in the operation. Reduced the rate that `Message::None` is sent from a subscription to trigger a UI redraw, and fixed it to not run when operations are paused.
2025-04-09 23:15:07 +02:00
}
2025-02-07 09:11:20 -07:00
};
match output.send(message).await {
Ok(()) => {}
Err(err) => {
log::warn!(
"failed to send directory size for {}: {}",
path.display(),
2025-02-07 09:11:20 -07:00
err
);
}
}
2025-02-07 09:11:20 -07:00
std::future::pending().await
}),
));
}
}
}
}
}
2024-10-09 18:55:09 -06:00
// Load search items incrementally
if let Location::Search(path, term, show_hidden, start) = &self.location {
2024-10-09 18:55:09 -06:00
let location = self.location.clone();
let path = path.clone();
let term = term.clone();
let show_hidden = *show_hidden;
2025-01-28 23:52:11 -05:00
let start = *start;
2024-10-21 13:51:10 -06:00
subscriptions.push(Subscription::run_with_id(
2024-10-09 18:55:09 -06:00
location.clone(),
2024-10-21 13:51:10 -06:00
stream::channel(2, move |mut output| async move {
2024-10-09 18:55:09 -06:00
//TODO: optimal size?
let (results_tx, results_rx) = mpsc::channel(65536);
let ready = Arc::new(atomic::AtomicBool::new(false));
2024-10-09 20:18:43 -06:00
let last_modified_opt = Arc::new(RwLock::new(None));
2024-10-09 18:55:09 -06:00
output
.send(Message::SearchContext(
location.clone(),
SearchContextWrapper(Some(SearchContext {
results_rx,
ready: ready.clone(),
2024-10-09 20:18:43 -06:00
last_modified_opt: last_modified_opt.clone(),
2024-10-09 18:55:09 -06:00
})),
))
.await
.unwrap();
let output = Arc::new(tokio::sync::Mutex::new(output));
{
let output = output.clone();
tokio::task::spawn_blocking(move || {
scan_search(
&path,
&term,
show_hidden,
move |path, name, metadata| -> bool {
// Don't send if the result is too old
if let Some(last_modified) = *last_modified_opt.read().unwrap()
{
if let Ok(modified) = metadata.modified() {
if modified < last_modified {
return true;
}
} else {
2024-10-09 20:18:43 -06:00
return true;
}
}
match results_tx.blocking_send((
path.to_path_buf(),
name.to_string(),
metadata,
)) {
Ok(()) => {
if ready.swap(true, atomic::Ordering::SeqCst) {
true
} else {
// Wake up update method
futures::executor::block_on(async {
output
.lock()
.await
.send(Message::SearchReady(false))
.await
})
.is_ok()
}
2024-10-09 18:55:09 -06:00
}
Err(_) => false,
2024-10-09 18:55:09 -06:00
}
},
);
2024-10-09 18:55:09 -06:00
log::info!(
"searched for {:?} in {} in {:?}",
2024-10-09 18:55:09 -06:00
term,
path.display(),
2024-10-09 18:55:09 -06:00
start.elapsed(),
);
})
.await
.unwrap();
}
// Send final ready
let _ = output.lock().await.send(Message::SearchReady(true)).await;
std::future::pending().await
2024-10-21 13:51:10 -06:00
}),
2024-10-09 18:55:09 -06:00
));
}
2025-02-07 09:11:20 -07:00
if let Some(path) = self
.edit_location
.as_ref()
.and_then(|x| x.location.path_opt())
.cloned()
2025-02-07 09:11:20 -07:00
{
subscriptions.push(Subscription::run_with_id(
("tab_complete", path.to_string_lossy().into_owned()),
2025-02-07 09:11:20 -07:00
stream::channel(1, |mut output| async move {
let message = {
let path = path.clone();
tokio::task::spawn_blocking(move || {
let start = Instant::now();
match tab_complete(&path) {
Ok(completions) => {
log::info!(
"tab completed {} in {:?}",
path.display(),
start.elapsed()
);
2025-02-07 09:11:20 -07:00
Message::TabComplete(path.clone(), completions)
}
Err(err) => {
log::warn!(
"failed to tab complete {}: {}",
path.display(),
err
);
2025-02-07 09:11:20 -07:00
Message::TabComplete(path.clone(), Vec::new())
}
}
})
.await
.unwrap()
};
match output.send(message).await {
Ok(()) => {}
Err(err) => {
log::warn!(
"failed to send tab completion for {}: {}",
path.display(),
err
);
2025-02-07 09:11:20 -07:00
}
}
std::future::pending().await
}),
));
}
Subscription::batch(subscriptions)
2024-02-22 16:17:39 -07:00
}
const fn format_time(&self, time: SystemTime) -> FormatTime<'_> {
format_time(time, &self.date_time_formatter, &self.time_formatter)
}
2024-01-03 15:27:32 -07:00
}
pub fn respond_to_scroll_direction(delta: ScrollDelta, modifiers: Modifiers) -> Option<Message> {
if !modifiers.control() {
return None;
}
2024-09-12 02:00:50 -04:00
let delta_y = match delta {
ScrollDelta::Lines { y, .. } => y,
ScrollDelta::Pixels { y, .. } => y,
2024-09-12 02:00:50 -04:00
};
if delta_y > 0.0 {
return Some(Message::ZoomIn);
2024-09-12 02:00:50 -04:00
}
if delta_y < 0.0 {
return Some(Message::ZoomOut);
2024-09-12 02:00:50 -04:00
}
None
}
2025-01-28 23:52:11 -05:00
#[derive(Clone)]
pub struct RcElementWrapper<M>(pub Rc<RefCell<Element<'static, M>>>);
2025-01-28 23:52:11 -05:00
impl<M> Widget<M, cosmic::Theme, cosmic::Renderer> for RcElementWrapper<M> {
2025-01-28 23:52:11 -05:00
fn size(&self) -> Size<Length> {
self.0.borrow().as_widget().size()
2025-01-28 23:52:11 -05:00
}
fn size_hint(&self) -> Size<Length> {
self.0.borrow().as_widget().size_hint()
2025-01-28 23:52:11 -05:00
}
fn layout(
&self,
tree: &mut tree::Tree,
renderer: &cosmic::Renderer,
limits: &cosmic::iced_core::layout::Limits,
) -> cosmic::iced_core::layout::Node {
self.0.borrow().as_widget().layout(tree, renderer, limits)
2025-01-28 23:52:11 -05:00
}
fn draw(
&self,
tree: &tree::Tree,
renderer: &mut cosmic::Renderer,
theme: &cosmic::Theme,
style: &cosmic::iced_core::renderer::Style,
layout: cosmic::iced_core::Layout<'_>,
cursor: cosmic::iced_core::mouse::Cursor,
viewport: &Rectangle,
) {
self.0
.borrow()
2025-01-28 23:52:11 -05:00
.as_widget()
.draw(tree, renderer, theme, style, layout, cursor, viewport);
2025-01-28 23:52:11 -05:00
}
fn tag(&self) -> tree::Tag {
self.0.borrow().as_widget().tag()
2025-01-28 23:52:11 -05:00
}
fn state(&self) -> tree::State {
self.0.borrow().as_widget().state()
2025-01-28 23:52:11 -05:00
}
fn children(&self) -> Vec<tree::Tree> {
self.0.borrow().as_widget().children()
2025-01-28 23:52:11 -05:00
}
fn diff(&mut self, tree: &mut tree::Tree) {
self.0.borrow_mut().as_widget_mut().diff(tree);
2025-01-28 23:52:11 -05:00
}
fn operate(
&self,
state: &mut tree::Tree,
layout: cosmic::iced_core::Layout<'_>,
renderer: &cosmic::Renderer,
operation: &mut dyn widget::Operation,
) {
self.0
.borrow()
2025-01-28 23:52:11 -05:00
.as_widget()
.operate(state, layout, renderer, operation);
2025-01-28 23:52:11 -05:00
}
fn on_event(
&mut self,
_state: &mut tree::Tree,
_event: cosmic::iced::Event,
_layout: cosmic::iced_core::Layout<'_>,
_cursor: cosmic::iced_core::mouse::Cursor,
_renderer: &cosmic::Renderer,
_clipboard: &mut dyn cosmic::iced_core::Clipboard,
_shell: &mut cosmic::iced_core::Shell<'_, M>,
_viewport: &Rectangle,
) -> event::Status {
self.0.borrow_mut().as_widget_mut().on_event(
2025-01-28 23:52:11 -05:00
_state, _event, _layout, _cursor, _renderer, _clipboard, _shell, _viewport,
)
}
fn mouse_interaction(
&self,
_state: &tree::Tree,
_layout: cosmic::iced_core::Layout<'_>,
_cursor: cosmic::iced_core::mouse::Cursor,
_viewport: &Rectangle,
_renderer: &cosmic::Renderer,
) -> cosmic::iced_core::mouse::Interaction {
self.0
.borrow()
2025-01-28 23:52:11 -05:00
.as_widget()
.mouse_interaction(_state, _layout, _cursor, _viewport, _renderer)
}
fn overlay<'a>(
&'a mut self,
_state: &'a mut tree::Tree,
_layout: cosmic::iced_core::Layout<'_>,
_renderer: &cosmic::Renderer,
_translation: cosmic::iced_core::Vector,
) -> Option<cosmic::iced_core::overlay::Element<'a, M, cosmic::Theme, cosmic::Renderer>> {
// TODO
None
}
fn id(&self) -> Option<Id> {
self.0.borrow().as_widget().id()
2025-01-28 23:52:11 -05:00
}
fn set_id(&mut self, _id: Id) {
self.0.borrow_mut().as_widget_mut().set_id(_id);
2025-01-28 23:52:11 -05:00
}
fn drag_destinations(
&self,
_state: &tree::Tree,
_layout: cosmic::iced_core::Layout<'_>,
renderer: &cosmic::Renderer,
_dnd_rectangles: &mut cosmic::iced_core::clipboard::DndDestinationRectangles,
) {
self.0
.borrow()
.as_widget()
.drag_destinations(_state, _layout, renderer, _dnd_rectangles);
2025-01-28 23:52:11 -05:00
}
}
impl<Message: 'static> From<RcElementWrapper<Message>> for Element<'static, Message> {
fn from(wrapper: RcElementWrapper<Message>) -> Self {
2025-01-28 23:52:11 -05:00
Element::new(wrapper)
}
}
fn text_editor_class(
theme: &cosmic::Theme,
status: cosmic::widget::text_editor::Status,
) -> cosmic::iced_widget::text_editor::Style {
let cosmic = theme.cosmic();
let container = theme.current_container();
let mut background: cosmic::iced::Color = container.component.base.into();
background.a = 0.25;
let selection = cosmic.accent.base.into();
let value = cosmic.palette.neutral_9.into();
let mut placeholder = cosmic.palette.neutral_9;
placeholder.alpha = 0.7;
let placeholder = placeholder.into();
let icon = cosmic.background.on.into();
match status {
cosmic::iced_widget::text_editor::Status::Active
| cosmic::iced_widget::text_editor::Status::Disabled => {
cosmic::iced_widget::text_editor::Style {
background: background.into(),
border: cosmic::iced::Border {
radius: cosmic.corner_radii.radius_m.into(),
width: 2.0,
color: container.component.divider.into(),
},
icon,
placeholder,
value,
selection,
}
}
cosmic::iced_widget::text_editor::Status::Hovered
| cosmic::iced_widget::text_editor::Status::Focused => {
cosmic::iced_widget::text_editor::Style {
background: background.into(),
border: cosmic::iced::Border {
radius: cosmic.corner_radii.radius_m.into(),
width: 2.0,
color: cosmic::iced::Color::from(cosmic.accent.base),
},
icon,
placeholder,
value,
selection,
}
}
}
}
#[cfg(test)]
mod tests {
use std::{fs, io, path::PathBuf};
use cosmic::{iced::mouse::ScrollDelta, iced_runtime::keyboard::Modifiers, widget};
2024-02-05 20:58:17 -05:00
use log::{debug, trace};
use tempfile::TempDir;
use test_log::test;
2025-09-03 23:24:38 +02:00
use super::{Location, Message, Tab, respond_to_scroll_direction, scan_path};
use crate::{
app::test_utils::{
2025-09-03 23:24:38 +02:00
NAME_LEN, NUM_DIRS, NUM_FILES, NUM_HIDDEN, NUM_NESTED, assert_eq_tab_path, empty_fs,
eq_path_item, filter_dirs, read_dir_sorted, simple_fs, tab_click_new,
},
config::{IconSizes, TabConfig, ThumbCfg},
};
// Boilerplate for tab tests. Checks if simulated clicks selected items.
fn tab_selects_item(
clicks: &[usize],
modifiers: Modifiers,
expected_selected: &[bool],
) -> io::Result<()> {
let (_fs, mut tab) = tab_click_new(NUM_FILES, NUM_NESTED, NUM_DIRS, NUM_NESTED, NAME_LEN)?;
// Simulate clicks by triggering Message::Click
for &click in clicks {
debug!("Emitting Message::Click(Some({click})) with modifiers: {modifiers:?}");
tab.update(Message::Click(Some(click)), modifiers);
}
let items = tab
.items_opt
.as_deref()
.expect("tab should be populated with items");
2025-01-28 23:52:11 -05:00
for (i, (&expected, actual)) in expected_selected.iter().zip(items).enumerate() {
assert_eq!(
expected,
actual.selected,
"expected index {i} to be {}",
if expected {
"selected but it was deselected"
} else {
"deselected but it was selected"
}
);
}
Ok(())
}
2024-02-05 20:58:17 -05:00
fn tab_history() -> io::Result<(TempDir, Tab, Vec<PathBuf>)> {
let fs = simple_fs(NUM_FILES, NUM_NESTED, NUM_DIRS, NUM_NESTED, NAME_LEN)?;
let path = fs.path();
let mut tab = Tab::new(
Location::Path(path.into()),
TabConfig::default(),
ThumbCfg::default(),
None,
widget::Id::unique(),
None,
);
2024-02-05 20:58:17 -05:00
// All directories (simple_fs only produces one nested layer)
2025-09-03 23:24:38 +02:00
let dirs: Vec<PathBuf> = {
let top_level = filter_dirs(path)?;
let mut result = Vec::new();
for dir in top_level {
let nested_dirs = filter_dirs(&dir)?;
2025-09-03 23:24:38 +02:00
result.push(dir);
result.extend(nested_dirs);
}
result
};
2024-02-05 20:58:17 -05:00
assert!(
dirs.len() == NUM_DIRS + NUM_DIRS * NUM_NESTED,
"Sanity check: Have {} dirs instead of {}",
dirs.len(),
NUM_DIRS + NUM_DIRS * NUM_NESTED
);
debug!("Building history by emitting Message::Location");
for dir in &dirs {
debug!(
"Emitting Message::Location(Location::Path(\"{}\"))",
dir.display()
);
tab.update(
Message::Location(Location::Path(dir.clone())),
Modifiers::empty(),
);
}
trace!("Tab history: {:?}", tab.history);
Ok((fs, tab, dirs))
}
#[test]
fn scan_path_succeeds_on_valid_path() -> io::Result<()> {
2024-02-01 23:31:50 -05:00
let fs = simple_fs(NUM_FILES, NUM_HIDDEN, NUM_DIRS, NUM_NESTED, NAME_LEN)?;
let path = fs.path();
// Read directory entries and sort as cosmic-files does
let entries = read_dir_sorted(path)?;
debug!("Calling scan_path(\"{}\")", path.display());
let actual = scan_path(&path.to_owned(), IconSizes::default());
// scan_path shouldn't skip any entries
assert_eq!(entries.len(), actual.len());
// Correct files should be scanned
2025-09-03 23:24:38 +02:00
assert!(
entries
.into_iter()
.zip(actual.into_iter())
.all(|(path, item)| eq_path_item(&path, &item))
);
Ok(())
}
#[test]
fn scan_path_returns_empty_vec_for_invalid_path() -> io::Result<()> {
2024-02-01 23:31:50 -05:00
let fs = simple_fs(NUM_FILES, NUM_NESTED, NUM_DIRS, NUM_NESTED, NAME_LEN)?;
let path = fs.path();
// A nonexisting path within the temp dir
let invalid_path = path.join("ferris");
assert!(!invalid_path.exists());
debug!("Calling scan_path(\"{}\")", invalid_path.display());
let actual = scan_path(&invalid_path, IconSizes::default());
assert!(actual.is_empty());
Ok(())
}
#[test]
fn scan_path_empty_dir_returns_empty_vec() -> io::Result<()> {
let fs = empty_fs()?;
let path = fs.path();
debug!("Calling scan_path(\"{}\")", path.display());
let actual = scan_path(&path.to_owned(), IconSizes::default());
assert_eq!(0, path.read_dir()?.count());
assert!(actual.is_empty());
Ok(())
}
#[test]
fn tab_location_changes_location() -> io::Result<()> {
let fs = simple_fs(NUM_FILES, NUM_NESTED, NUM_DIRS, NUM_NESTED, NAME_LEN)?;
let path = fs.path();
// Next directory in temp directory
2024-02-05 20:58:17 -05:00
// This does not have to be sorted
let next_dir = filter_dirs(path)?
.next()
.expect("temp directory should have at least one directory");
let mut tab = Tab::new(
Location::Path(path.to_owned()),
TabConfig::default(),
ThumbCfg::default(),
None,
widget::Id::unique(),
None,
);
debug!(
"Emitting Message::Location(Location::Path(\"{}\"))",
next_dir.display()
);
tab.update(
Message::Location(Location::Path(next_dir.clone())),
Modifiers::empty(),
);
// Validate that the tab's path updated
// NOTE: `items_opt` is set to None with Message::Location so this ONLY checks for equal paths
// If item contents are NOT None then this needs to be reevaluated for correctness
assert_eq_tab_path(&tab, &next_dir);
assert!(
tab.items_opt.is_none(),
"Tab's `items` is not None which means this test needs to be updated"
);
Ok(())
}
#[test]
fn tab_click_single_selects_item() -> io::Result<()> {
// Select the second directory with no keys held down
tab_selects_item(&[1], Modifiers::empty(), &[false, true])
}
#[test]
fn tab_click_double_opens_folder() -> io::Result<()> {
let (fs, mut tab) = tab_click_new(NUM_FILES, NUM_NESTED, NUM_DIRS, NUM_NESTED, NAME_LEN)?;
let path = fs.path();
// Simulate double clicking second directory
2024-04-24 23:30:31 +02:00
debug!("Emitting double click Message::DoubleClick(Some(1))");
tab.update(Message::DoubleClick(Some(1)), Modifiers::empty());
// Path to second directory
let second_dir = read_dir_sorted(path)?
2024-02-05 20:58:17 -05:00
.into_iter()
.filter(|p| p.is_dir())
.nth(1)
.expect("should be at least two directories");
// Location should have changed to second_dir
assert_eq_tab_path(&tab, &second_dir);
Ok(())
}
#[test]
fn tab_click_ctrl_selects_multiple() -> io::Result<()> {
// Select the first and second directory by holding down ctrl
tab_selects_item(&[0, 1], Modifiers::CTRL, &[true, true])
}
#[test]
fn tab_gonext_moves_forward_in_history() -> io::Result<()> {
2024-02-05 20:58:17 -05:00
let (fs, mut tab, dirs) = tab_history()?;
let path = fs.path();
// Rewind to the start
for _ in 0..dirs.len() {
debug!("Emitting Message::GoPrevious to rewind to the start",);
tab.update(Message::GoPrevious, Modifiers::empty());
}
assert_eq_tab_path(&tab, path);
// Back to the future. Directories should be in the order they were opened.
for dir in dirs {
debug!("Emitting Message::GoNext",);
tab.update(Message::GoNext, Modifiers::empty());
assert_eq_tab_path(&tab, &dir);
}
Ok(())
}
#[test]
fn tab_goprev_moves_backward_in_history() -> io::Result<()> {
2024-02-05 20:58:17 -05:00
let (fs, mut tab, dirs) = tab_history()?;
let path = fs.path();
for dir in dirs.into_iter().rev() {
assert_eq_tab_path(&tab, &dir);
debug!("Emitting Message::GoPrevious",);
tab.update(Message::GoPrevious, Modifiers::empty());
}
assert_eq_tab_path(&tab, path);
Ok(())
}
2024-09-12 02:00:50 -04:00
#[test]
fn tab_scroll_up_with_ctrl_modifier_zooms() -> io::Result<()> {
2024-09-17 10:34:08 -06:00
let message_maybe =
respond_to_scroll_direction(ScrollDelta::Pixels { x: 0.0, y: 1.0 }, Modifiers::CTRL);
2025-01-28 23:52:11 -05:00
assert!(message_maybe.is_some());
assert!(matches!(message_maybe.unwrap(), Message::ZoomIn));
Ok(())
}
2024-09-12 08:49:28 -04:00
#[test]
fn tab_scroll_up_without_ctrl_modifier_does_not_zoom() -> io::Result<()> {
2024-09-17 10:34:08 -06:00
let message_maybe =
respond_to_scroll_direction(ScrollDelta::Pixels { x: 0.0, y: 1.0 }, Modifiers::empty());
assert!(message_maybe.is_none());
Ok(())
}
2024-09-12 02:00:50 -04:00
#[test]
fn tab_scroll_down_with_ctrl_modifier_zooms() -> io::Result<()> {
2024-09-17 10:34:08 -06:00
let message_maybe =
respond_to_scroll_direction(ScrollDelta::Pixels { x: 0.0, y: -1.0 }, Modifiers::CTRL);
2025-01-28 23:52:11 -05:00
assert!(message_maybe.is_some());
assert!(matches!(message_maybe.unwrap(), Message::ZoomOut));
2024-09-12 02:00:50 -04:00
Ok(())
}
#[test]
fn tab_scroll_down_without_ctrl_modifier_does_not_zoom() -> io::Result<()> {
2024-09-17 10:34:08 -06:00
let message_maybe = respond_to_scroll_direction(
ScrollDelta::Pixels { x: 0.0, y: -1.0 },
Modifiers::empty(),
);
assert!(message_maybe.is_none());
2024-09-12 02:00:50 -04:00
Ok(())
}
#[test]
fn tab_empty_history_does_nothing_on_prev_next() -> io::Result<()> {
2024-02-05 20:58:17 -05:00
let fs = simple_fs(0, NUM_NESTED, NUM_DIRS, 0, NAME_LEN)?;
let path = fs.path();
let mut tab = Tab::new(
Location::Path(path.into()),
TabConfig::default(),
ThumbCfg::default(),
None,
widget::Id::unique(),
None,
);
2024-02-05 20:58:17 -05:00
// Tab's location shouldn't change if GoPrev or GoNext is triggered
debug!("Emitting Message::GoPrevious",);
tab.update(Message::GoPrevious, Modifiers::empty());
assert_eq_tab_path(&tab, path);
debug!("Emitting Message::GoNext",);
tab.update(Message::GoNext, Modifiers::empty());
assert_eq_tab_path(&tab, path);
Ok(())
}
#[test]
fn tab_locationup_moves_up_hierarchy() -> io::Result<()> {
let fs = simple_fs(0, NUM_NESTED, NUM_DIRS, 0, NAME_LEN)?;
let path = fs.path();
let mut next_dir = filter_dirs(path)?
.next()
.expect("should be at least one directory");
let mut tab = Tab::new(
Location::Path(next_dir.clone()),
TabConfig::default(),
ThumbCfg::default(),
None,
widget::Id::unique(),
None,
);
// This will eventually yield false once root is hit
while next_dir.pop() {
debug!("Emitting Message::LocationUp",);
tab.update(Message::LocationUp, Modifiers::empty());
assert_eq_tab_path(&tab, &next_dir);
}
Ok(())
}
#[test]
fn sort_long_number_file_names() -> io::Result<()> {
let fs = empty_fs()?;
let path = fs.path();
// Create files with names 255 characters long that only contain a single number
// Example: 0000...0 for 255 characters
// https://en.wikipedia.org/wiki/Filename#Comparison_of_filename_limitations
2025-01-28 23:52:11 -05:00
let mut base_nums: Vec<_> = ('0'..='9').collect();
fastrand::shuffle(&mut base_nums);
debug!("Shuffled numbers for paths: {base_nums:?}");
let paths: Vec<_> = base_nums
.iter()
.copied()
.map(|base| path.join(std::iter::repeat_n(base, 255).collect::<String>()))
.collect();
for (file, base) in paths.iter().zip(base_nums.into_iter()) {
trace!("Creating long file name for {base}");
fs::File::create(file)?;
}
debug!("Creating tab for directory of long file names");
Tab::new(
Location::Path(path.into()),
TabConfig::default(),
ThumbCfg::default(),
None,
widget::Id::unique(),
None,
);
Ok(())
}
#[test]
fn mode_calculations() {
use super::{
2025-09-03 23:24:38 +02:00
MODE_SHIFT_GROUP, MODE_SHIFT_OTHER, MODE_SHIFT_USER, get_mode_part, set_mode_part,
};
for user in 0..=7 {
for group in 0..=7 {
for other in 0..=7 {
let mode = (user << MODE_SHIFT_USER)
| (group << MODE_SHIFT_GROUP)
| (other << MODE_SHIFT_OTHER);
assert_eq!(format!("{mode:03o}"), format!("{user:o}{group:o}{other:o}"),);
assert_eq!(get_mode_part(mode, MODE_SHIFT_USER), user);
assert_eq!(get_mode_part(mode, MODE_SHIFT_GROUP), group);
assert_eq!(get_mode_part(mode, MODE_SHIFT_OTHER), other);
let mode_no_user = (group << MODE_SHIFT_GROUP) | (other << MODE_SHIFT_OTHER);
assert_eq!(
format!("{mode_no_user:03o}"),
format!("0{group:o}{other:o}")
);
assert_eq!(set_mode_part(mode_no_user, MODE_SHIFT_USER, user), mode);
let mode_no_group = (user << MODE_SHIFT_USER) | (other << MODE_SHIFT_OTHER);
assert_eq!(
format!("{mode_no_group:03o}"),
format!("{user:o}0{other:o}")
);
assert_eq!(set_mode_part(mode_no_group, MODE_SHIFT_GROUP, group), mode);
let mode_no_other = (user << MODE_SHIFT_USER) | (group << MODE_SHIFT_GROUP);
assert_eq!(
format!("{mode_no_other:03o}"),
format!("{user:o}{group:o}0")
);
assert_eq!(set_mode_part(mode_no_other, MODE_SHIFT_OTHER, other), mode);
}
}
}
}
}