1#[cfg(feature = "desktop")]
2use cosmic::desktop::fde::{DesktopEntry, get_languages_from_env};
3use cosmic::iced::advanced::graphics;
4use cosmic::iced::advanced::text::{self, Paragraph};
5use cosmic::iced::alignment::Vertical;
6use cosmic::iced::clipboard::dnd::DndAction;
7use cosmic::iced::core::mouse::ScrollDelta;
8use cosmic::iced::core::widget::tree;
9use cosmic::iced::futures::{self, SinkExt};
10use cosmic::iced::keyboard::Modifiers;
11use cosmic::iced::widget::scrollable::{self, AbsoluteOffset, Viewport};
12use cosmic::iced::widget::{rule, stack};
13use cosmic::iced::{
14 Alignment, Border, Color, ContentFit, Length, Point, Rectangle, Size, Subscription, Vector,
15 padding, stream, window,
16};
17use cosmic::widget::menu::action::MenuAction;
18use cosmic::widget::menu::key_bind::KeyBind;
19use cosmic::widget::{self, DndDestination, DndSource, Id, RcElementWrapper, Widget, space};
20use cosmic::{Apply, Element, cosmic_theme, font, theme};
21use icu::datetime::input::DateTime;
22use icu::datetime::options::TimePrecision;
23use icu::datetime::{DateTimeFormatter, DateTimeFormatterPreferences, fieldsets};
24use icu::locale::preferences::extensions::unicode::keywords::HourCycle;
25use image::{DynamicImage, ImageReader};
26use jiff_icu::ConvertFrom;
27use mime_guess::{Mime, mime};
28use rustc_hash::FxHashMap;
29use serde::{Deserialize, Serialize};
30use std::borrow::Cow;
31use std::cell::Cell;
32use std::cmp::{Ordering, Reverse};
33use std::collections::{BTreeMap, BTreeSet, HashMap};
34use std::error::Error;
35use std::fmt::{self, Display};
36use std::fs::{self, File, Metadata};
37use std::hash::Hash;
38use std::io::{BufRead, BufReader, Read};
39#[cfg(unix)]
40use std::os::unix::fs::MetadataExt;
41use std::path::{self, Path, PathBuf};
42use std::sync::{Arc, LazyLock, RwLock, atomic};
43use std::time::{Duration, Instant, SystemTime};
44use tempfile::NamedTempFile;
45use tokio::sync::mpsc;
46use trash::{TrashItem, TrashItemMetadata, TrashItemSize};
47use walkdir::WalkDir;
48
49use crate::app::{Action, PreviewItem, PreviewKind};
50use crate::clipboard::{ClipboardCopy, ClipboardKind, ClipboardPaste};
51use crate::config::{
52 ContextActionPreset, DesktopConfig, ICON_SCALE_MAX, ICON_SIZE_GRID, IconSizes, TBConfig,
53 TabConfig, ThumbCfg,
54};
55use crate::dialog::DialogKind;
56use crate::large_image::{
57 LargeImageManager, decode_large_image, exceeds_memory_limit, should_use_dedicated_worker,
58 should_use_tiling,
59};
60use crate::localize::{LANGUAGE_SORTER, LOCALE};
61use crate::mime_icon::{mime_for_path, mime_icon};
62use crate::mounter::MOUNTERS;
63use crate::operation::{Controller, OperationError};
64use crate::russh::CLIENTS;
65use crate::sequencing::rrl::{
66 RrlPosition2058_2059, RrlSusceptibilityCalls, is_susceptible_rrl,
67 is_susceptible_rrl_by_snp_calls_rare,
68};
69use crate::sequencing::rrs::{
70 RrsSusceptibilityCalls, is_susceptible_rrs, is_susceptible_rrs_by_snp_calls_rare,
71};
72use crate::sequencing::{
73 Ab1Channels, SeqData, SeqIdHit, SusceptibilityCalls,
74 erm41::{Erm41Position28, Erm41SusceptibilityCalls, is_susceptible_erm41},
75 parse_ab1_quality, parse_ab1_sequence,
76 pnca::{PncaSusceptibilityCalls, is_susceptible_pnca},
77 tb_data::{DrVariant, TB_ECOLI_MAPPING, TbProfilerJson},
78 trim_to_min_quality,
79};
80use crate::thumbnail_cacher::{CachedThumbnail, ThumbnailCacher, ThumbnailSize};
81use crate::thumbnailer::thumbnailer;
82use crate::trash::{Trash, TrashExt};
83use crate::{FxOrderMap, fl, menu, mime_app, mouse_area};
84
85pub const DOUBLE_CLICK_DURATION: Duration = Duration::from_millis(500);
86pub const HOVER_DURATION: Duration = Duration::from_millis(1600);
87pub const TYPE_SELECT_TIMEOUT: Duration = Duration::from_millis(1000);
88const MAX_SEARCH_LATENCY: Duration = Duration::from_millis(20);
90const MAX_SEARCH_RESULTS: usize = 200;
91const THUMBNAIL_SIZE: u32 = (ICON_SIZE_GRID as u32) * (ICON_SCALE_MAX as u32);
93const TEXT_PREVIEW_MAX_BYTES: usize = 256 * 1024; const TEXT_PREVIEW_MAX_FILE_BYTES: u64 = 8 * 1000 * 1000; pub static THUMB_SEMAPHORE: LazyLock<tokio::sync::Semaphore> =
102 LazyLock::new(|| tokio::sync::Semaphore::const_new(num_cpus::get().min(4)));
103
104pub(crate) static SORT_OPTION_FALLBACK: LazyLock<FxHashMap<String, (HeadingOptions, bool)>> =
105 LazyLock::new(|| {
106 FxHashMap::from_iter(dirs::download_dir().into_iter().map(|dir| {
107 (
108 Location::Path(dir).normalize().to_string(),
109 (HeadingOptions::Modified, false),
110 )
111 }))
112 });
113
114#[cfg_attr(not(unix), allow(dead_code))]
115static MODE_NAMES: LazyLock<Vec<String>> = LazyLock::new(|| {
116 vec![
117 fl!("none"),
119 fl!("execute-only"),
121 fl!("write-only"),
123 fl!("write-execute"),
125 fl!("read-only"),
127 fl!("read-execute"),
129 fl!("read-write"),
131 fl!("read-write-execute"),
133 ]
134});
135
136static SPECIAL_DIRS: LazyLock<FxHashMap<PathBuf, &'static str>> = LazyLock::new(|| {
137 let mut special_dirs = FxHashMap::default();
138 if let Some(dir) = dirs::document_dir() {
139 special_dirs.insert(dir, "folder-documents");
140 }
141 if let Some(dir) = dirs::download_dir() {
142 special_dirs.insert(dir, "folder-download");
143 }
144 if let Some(dir) = dirs::audio_dir() {
145 special_dirs.insert(dir, "folder-music");
146 }
147 if let Some(dir) = dirs::picture_dir() {
148 special_dirs.insert(dir, "folder-pictures");
149 }
150 if let Some(dir) = dirs::public_dir() {
151 special_dirs.insert(dir, "folder-publicshare");
152 }
153 if let Some(dir) = dirs::template_dir() {
154 special_dirs.insert(dir, "folder-templates");
155 }
156 if let Some(dir) = dirs::video_dir() {
157 special_dirs.insert(dir, "folder-videos");
158 }
159 if let Some(dir) = dirs::desktop_dir() {
160 special_dirs.insert(dir, "user-desktop");
161 }
162 if let Some(dir) = dirs::home_dir() {
163 special_dirs.insert(dir, "user-home");
164 }
165 special_dirs
166});
167
168#[allow(clippy::too_many_arguments)]
169fn button_appearance(
170 theme: &theme::Theme,
171 selected: bool,
172 highlighted: bool,
173 cut: bool,
174 focused: bool,
175 accent: bool,
176 condensed_radius: bool,
177 desktop: bool,
178) -> widget::button::Style {
179 let cosmic = theme.cosmic();
180 let mut appearance = widget::button::Style::new();
181 if selected {
182 if accent {
183 appearance.background = Some(Color::from(cosmic.accent_color()).into());
184 appearance.icon_color = Some(Color::from(cosmic.on_accent_color()));
185 if cut {
186 appearance.text_color = Some(Color::from(cosmic.accent.on_disabled));
187 } else {
188 appearance.text_color = Some(Color::from(cosmic.on_accent_color()));
189 }
190 } else {
191 appearance.background = Some(Color::from(cosmic.bg_component_color()).into());
192 }
193 } else if highlighted {
194 if accent {
195 appearance.background = Some(Color::from(cosmic.bg_component_color()).into());
196 appearance.icon_color = Some(Color::from(cosmic.on_bg_component_color()));
197 appearance.text_color = Some(Color::from(cosmic.on_bg_component_color()));
198 if cut {
199 appearance.text_color = Some(Color::from(cosmic.background.component.on_disabled));
200 } else {
201 appearance.text_color = Some(Color::from(cosmic.on_bg_component_color()));
202 }
203 } else {
204 appearance.background = Some(Color::from(cosmic.bg_component_color()).into());
205 }
206 } else if desktop {
207 appearance.background = Some(Color::from(cosmic.bg_color()).into());
208 appearance.icon_color = Some(Color::from(cosmic.on_bg_color()));
209 if cut {
210 appearance.text_color = Some(Color::from(cosmic.background.component.disabled));
211 } else {
212 appearance.text_color = Some(Color::from(cosmic.on_bg_color()));
213 }
214 } else if cut {
215 appearance.text_color = Some(Color::from(cosmic.background.component.on_disabled));
216 }
217 if focused && accent {
218 appearance.outline_width = 1.0;
219 appearance.outline_color = Color::from(cosmic.accent_color());
220 appearance.border_width = 2.0;
221 appearance.border_color = Color::TRANSPARENT;
222 }
223 if condensed_radius {
224 appearance.border_radius = cosmic.radius_xs().into();
225 } else {
226 appearance.border_radius = cosmic.radius_s().into();
227 }
228 appearance
229}
230
231fn button_style(
232 selected: bool,
233 highlighted: bool,
234 cut: bool,
235 accent: bool,
236 condensed_radius: bool,
237 desktop: bool,
238) -> theme::Button {
239 theme::Button::Custom {
241 active: Box::new(move |focused, theme| {
242 button_appearance(
243 theme,
244 selected,
245 highlighted,
246 cut,
247 focused,
248 accent,
249 condensed_radius,
250 desktop,
251 )
252 }),
253 disabled: Box::new(move |theme| {
254 button_appearance(
255 theme,
256 selected,
257 highlighted,
258 cut,
259 false,
260 accent,
261 condensed_radius,
262 desktop,
263 )
264 }),
265 hovered: Box::new(move |focused, theme| {
266 button_appearance(
267 theme,
268 selected,
269 highlighted,
270 cut,
271 focused,
272 accent,
273 condensed_radius,
274 desktop,
275 )
276 }),
277 pressed: Box::new(move |focused, theme| {
278 button_appearance(
279 theme,
280 selected,
281 highlighted,
282 cut,
283 focused,
284 accent,
285 condensed_radius,
286 desktop,
287 )
288 }),
289 }
290}
291
292pub fn folder_icon(path: &PathBuf, icon_size: u16) -> widget::icon::Handle {
293 widget::icon::from_name(SPECIAL_DIRS.get(path).map_or("folder", |x| *x))
294 .prefer_svg(true)
295 .size(icon_size)
296 .handle()
297}
298
299pub fn folder_icon_symbolic(path: &PathBuf, icon_size: u16) -> widget::icon::Handle {
300 widget::icon::from_name(format!(
301 "{}-symbolic",
302 SPECIAL_DIRS.get(path).map_or("folder", |x| *x)
303 ))
304 .size(icon_size)
305 .handle()
306}
307
308fn has_trailing_sep(path: &Path) -> bool {
310 path.as_os_str()
311 .as_encoded_bytes()
312 .last()
313 .copied()
314 .is_some_and(|b| path::is_separator(b as char))
315}
316
317fn tab_complete(path: &Path) -> Result<Vec<(String, PathBuf)>, Box<dyn Error>> {
318 let parent = if has_trailing_sep(path) && path.is_dir() {
319 path
321 } else {
322 path.parent()
323 .ok_or_else(|| format!("path has no parent {}", path.display()))?
324 };
325
326 let child_os = path.strip_prefix(parent)?;
327 let child = child_os
328 .to_str()
329 .ok_or_else(|| format!("invalid UTF-8 {}", child_os.display()))?;
330
331 let pattern = format!("^{}", regex::escape(child));
332 let regex = regex::RegexBuilder::new(&pattern)
333 .case_insensitive(true)
334 .build()?;
335
336 let mut completions = Vec::new();
337 for entry_res in fs::read_dir(parent)? {
338 let entry = entry_res?;
339 let file_name_os = entry.file_name();
340 let Some(file_name) = file_name_os.to_str() else {
341 continue;
342 };
343 if pattern == "^" && file_name.starts_with('.') {
345 continue;
346 }
347 if regex.is_match(file_name) {
348 completions.push((file_name.to_string(), entry.path()));
349 }
350 }
351
352 completions.sort_by(|a, b| LANGUAGE_SORTER.compare(&a.0, &b.0));
353 completions.truncate(8);
355 Ok(completions)
356}
357
358fn format_size(size: u64) -> String {
360 const KB: u64 = 1000;
361 const MB: u64 = 1000 * KB;
362 const GB: u64 = 1000 * MB;
363 const TB: u64 = 1000 * GB;
364
365 if size >= TB {
366 format!("{:.1} TB", size as f64 / TB as f64)
367 } else if size >= GB {
368 format!("{:.1} GB", size as f64 / GB as f64)
369 } else if size >= MB {
370 format!("{:.1} MB", size as f64 / MB as f64)
371 } else if size >= KB {
372 format!("{:.1} KB", size as f64 / KB as f64)
373 } else {
374 format!("{size} B")
375 }
376}
377
378#[cfg_attr(not(unix), allow(dead_code))]
379const MODE_SHIFT_USER: u32 = 6;
380#[cfg_attr(not(unix), allow(dead_code))]
381const MODE_SHIFT_GROUP: u32 = 3;
382#[cfg_attr(not(unix), allow(dead_code))]
383const MODE_SHIFT_OTHER: u32 = 0;
384
385#[cfg_attr(not(unix), allow(dead_code))]
386const fn get_mode_part(mode: u32, shift: u32) -> u32 {
387 (mode >> shift) & 0o7
388}
389
390fn set_mode_part(mode: u32, shift: u32, bits: u32) -> u32 {
391 assert!(bits <= 0o7);
392 (mode & !(0o7 << shift)) | (bits << shift)
393}
394
395fn date_time_formatter(military_time: bool) -> DateTimeFormatter<fieldsets::YMDT> {
396 let mut prefs = DateTimeFormatterPreferences::from(LOCALE.clone());
397 prefs.hour_cycle = Some(if military_time {
398 HourCycle::H23
399 } else {
400 HourCycle::H12
401 });
402
403 let mut fs = fieldsets::YMDT::medium();
404 fs = fs.with_time_precision(TimePrecision::Minute);
405
406 DateTimeFormatter::try_new(prefs, fs).expect("failed to create DateTimeFormatter")
407}
408
409fn time_formatter(military_time: bool) -> DateTimeFormatter<fieldsets::T> {
410 let mut prefs = DateTimeFormatterPreferences::from(LOCALE.clone());
411 prefs.hour_cycle = Some(if military_time {
412 HourCycle::H23
413 } else {
414 HourCycle::H12
415 });
416
417 let mut fs = fieldsets::T::medium();
418 fs = fs.with_time_precision(TimePrecision::Minute);
419
420 DateTimeFormatter::try_new(prefs, fs).expect("failed to create DateTimeFormatter")
421}
422
423struct FormatTime<'a> {
424 pub time: SystemTime,
425 pub date_time_formatter: &'a DateTimeFormatter<fieldsets::YMDT>,
426 pub time_formatter: &'a DateTimeFormatter<fieldsets::T>,
427}
428
429impl<'a> FormatTime<'a> {
430 fn from_secs(
431 secs: i64,
432 date_time_formatter: &'a DateTimeFormatter<fieldsets::YMDT>,
433 time_formatter: &'a DateTimeFormatter<fieldsets::T>,
434 ) -> Option<Self> {
435 let secs: u64 = secs.try_into().ok()?;
437 let now = SystemTime::now();
438 let filetime_diff = now
439 .duration_since(SystemTime::UNIX_EPOCH)
440 .map(|from_epoch| from_epoch.as_secs())
441 .ok()
442 .and_then(|now_secs| now_secs.checked_sub(secs))
443 .map(Duration::from_secs)?;
444 now.checked_sub(filetime_diff).map(|time| Self {
445 time,
446 date_time_formatter,
447 time_formatter,
448 })
449 }
450}
451
452impl Display for FormatTime<'_> {
453 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
454 let zoned = jiff::Zoned::try_from(self.time).unwrap();
455 let now = jiff::Zoned::now();
456 let icu_datetime = DateTime::convert_from(zoned.datetime());
457 if zoned.date() == now.date() {
458 f.write_str(fl!("today").as_str())?;
459 f.write_str(", ")?;
460 self.time_formatter.format(&icu_datetime).fmt(f)
461 } else {
462 self.date_time_formatter.format(&icu_datetime).fmt(f)
463 }
464 }
465}
466
467const fn format_time<'a>(
468 time: SystemTime,
469 date_time_formatter: &'a DateTimeFormatter<fieldsets::YMDT>,
470 time_formatter: &'a DateTimeFormatter<fieldsets::T>,
471) -> FormatTime<'a> {
472 FormatTime {
473 time,
474 date_time_formatter,
475 time_formatter,
476 }
477}
478
479#[cfg(not(target_os = "windows"))]
480fn hidden_attribute(_metadata: &Metadata) -> bool {
481 false
482}
483
484#[cfg(target_os = "windows")]
485fn hidden_attribute(metadata: &Metadata) -> bool {
486 use std::os::windows::fs::MetadataExt;
487 const FILE_ATTRIBUTE_HIDDEN: u32 = 2;
489 metadata.file_attributes() & FILE_ATTRIBUTE_HIDDEN == FILE_ATTRIBUTE_HIDDEN
490}
491
492#[derive(Clone, Copy, Debug, PartialEq, Eq)]
493pub enum FsKind {
494 Local,
495 Remote,
496 Gvfs,
497}
498
499#[cfg(target_os = "linux")]
500pub fn fs_kind(metadata: &Metadata) -> FsKind {
501 static DEVICES: LazyLock<FxHashMap<u64, FsKind>> = LazyLock::new(|| {
504 let mut devices = FxHashMap::default();
505 match procfs::process::Process::myself() {
506 Ok(process) => match process.mountinfo() {
507 Ok(mount_infos) => {
508 devices = FxHashMap::from_iter(mount_infos.iter().filter_map(|mount_info| {
509 let mut parts = mount_info.majmin.split(':');
510 let major_str = parts.next()?;
511 let minor_str = parts.next()?;
512 let major = major_str.parse::<libc::c_uint>().ok()?;
513 let minor = minor_str.parse::<libc::c_uint>().ok()?;
514 let dev = libc::makedev(major, minor);
515 let kind = match mount_info.fs_type.as_str() {
518 "cifs" | "smb" | "smb2" | "smbfs" => FsKind::Remote,
520
521 "nfs" | "nfs4" => FsKind::Remote,
523
524 "fuse.rclone" | "fuse.sshfs" | "fuse.davfs2" | "fuse.ceph"
526 | "fuse.glusterfs" | "fuse.s3fs" | "fuse.goofys" | "fuse.gcsfuse"
527 | "fuse.afp" | "fuse.afpfs" => FsKind::Remote,
528
529 "afs" | "coda" | "ncpfs" | "davfs" | "davfs2" | "shfs" => {
531 FsKind::Remote
532 }
533
534 "ceph" | "glusterfs" | "lustre" | "gfs" | "gfs2" | "ocfs2" => {
536 FsKind::Remote
537 }
538
539 "fuse.gvfsd-fuse" => FsKind::Gvfs,
541
542 _ => FsKind::Local,
544 };
545 Some((dev, kind))
546 }));
547 }
548 Err(err) => {
549 log::warn!("failed to get mount info: {err}");
550 }
551 },
552 Err(err) => {
553 log::warn!("failed to get process info: {err}");
554 }
555 }
556 devices
557 });
558 DEVICES.get(&metadata.dev()).map_or(FsKind::Local, |x| *x)
559}
560
561#[cfg(not(target_os = "linux"))]
562pub fn fs_kind(_metadata: &Metadata) -> FsKind {
563 FsKind::Local
565}
566
567#[cfg(not(feature = "desktop"))]
568fn get_desktop_file_display_name(_path: &Path) -> Option<String> {
569 None
570}
571
572#[cfg(feature = "desktop")]
573fn get_desktop_file_display_name(path: &Path) -> Option<String> {
574 let locales = get_languages_from_env();
575 let entry = match DesktopEntry::from_path(path, Some(&locales)) {
576 Ok(ok) => ok,
577 Err(err) => {
578 log::warn!("failed to parse {}: {}", path.display(), err);
579 return None;
580 }
581 };
582
583 entry.name(&locales).map(|s| s.into_owned())
584}
585
586#[cfg(not(windows))]
587#[cfg(not(feature = "desktop"))]
588fn get_desktop_file_icon(path: &Path) -> Option<String> {
589 None
590}
591
592#[cfg(feature = "desktop")]
593fn get_desktop_file_icon(path: &Path) -> Option<String> {
594 let entry = match DesktopEntry::from_path::<&str>(path, None) {
595 Ok(ok) => ok,
596 Err(err) => {
597 log::warn!("failed to parse {}: {}", path.display(), err);
598 return None;
599 }
600 };
601
602 entry.icon().map(str::to_string)
603}
604
605#[cfg(windows)]
606fn get_desktop_file_icon(_path: &Path) -> Option<String> {
607 None
609}
610
611fn desktop_icon_handle(icon: &str, size: u16) -> widget::icon::Handle {
614 let icon_path = Path::new(icon);
615 if icon_path.is_absolute() && icon_path.exists() {
616 widget::icon::from_path(icon_path.to_path_buf())
617 } else {
618 widget::icon::from_name(icon)
619 .prefer_svg(true)
620 .size(size)
621 .handle()
622 }
623}
624
625#[cfg(feature = "desktop")]
626pub fn parse_desktop_file(path: &Path) -> (Option<String>, Option<String>) {
627 let locales = get_languages_from_env();
628 let entry = match DesktopEntry::from_path(path, Some(&locales)) {
629 Ok(ok) => ok,
630 Err(err) => {
631 log::warn!("failed to parse {}: {}", path.display(), err);
632 return (None, None);
633 }
634 };
635 (
636 entry.name(&locales).map(|s| s.into_owned()),
637 entry.icon().map(str::to_string),
638 )
639}
640
641#[cfg(windows)]
642pub fn parse_desktop_file(path: &Path) -> (Option<String>, Option<String>) {
643 let name = path
645 .file_stem()
646 .and_then(|s| s.to_str())
647 .map(|s| s.to_string());
648
649 (name, None)
650}
651
652fn display_name_for_file(path: &Path, name: &str, get_from_gvfs: bool, is_desktop: bool) -> String {
653 if is_desktop {
654 return get_desktop_file_display_name(path).map_or_else(
655 || Item::display_name(name),
656 |desktop_name| Item::display_name(desktop_name.as_str()),
657 );
658 } else if get_from_gvfs {
659 #[cfg(feature = "gvfs")]
660 {
661 let file = gio::File::for_path(path);
662 if let Ok(info) = gio::prelude::FileExt::query_info(
663 &file,
664 "standard::display-name",
665 gio::FileQueryInfoFlags::NONE,
666 gio::Cancellable::NONE,
667 ) {
668 return Item::display_name(info.display_name().as_str());
669 }
670 }
671 }
672 Item::display_name(name)
673}
674
675#[cfg(feature = "gvfs")]
676pub fn item_from_gvfs_info(path: PathBuf, file_info: gio::FileInfo, sizes: IconSizes) -> Item {
677 let file_name = file_info
678 .attribute_as_string(gio::FILE_ATTRIBUTE_STANDARD_NAME)
679 .unwrap_or_default();
680 let mtime = file_info.attribute_uint64(gio::FILE_ATTRIBUTE_TIME_MODIFIED);
681 let mut is_desktop = false;
682 let remote = file_info.boolean(gio::FILE_ATTRIBUTE_FILESYSTEM_REMOTE);
683 let is_dir = matches!(file_info.file_type(), gio::FileType::Directory);
684
685 let size_opt = (!is_dir).then_some(file_info.size() as u64);
686
687 let (mime, icon_handle_grid, icon_handle_list, icon_handle_list_condensed) = if is_dir {
688 (
689 "inode/directory".parse().unwrap(),
691 folder_icon(&path, sizes.grid()),
692 folder_icon(&path, sizes.list()),
693 folder_icon(&path, sizes.list_condensed()),
694 )
695 } else {
696 let mime = mime_for_path(&path, None, true);
699
700 let icon_name_opt = if mime == "application/x-desktop" {
702 is_desktop = true;
703 get_desktop_file_icon(&path)
704 } else {
705 None
706 };
707 if let Some(icon_name) = icon_name_opt {
708 (
709 mime,
710 desktop_icon_handle(&icon_name, sizes.grid()),
711 desktop_icon_handle(&icon_name, sizes.list()),
712 desktop_icon_handle(&icon_name, sizes.list_condensed()),
713 )
714 } else {
715 (
716 mime.clone(),
717 mime_icon(mime.clone(), sizes.grid()),
718 mime_icon(mime.clone(), sizes.list()),
719 mime_icon(mime, sizes.list_condensed()),
720 )
721 }
722 };
723
724 let mut children_opt = None;
725 let mut dir_size = DirSize::NotDirectory;
726 if is_dir && !remote {
727 dir_size = DirSize::Calculating(Controller::default());
728 match fs::read_dir(&path) {
730 Ok(entries) => {
731 children_opt = Some(entries.count());
732 }
733 Err(err) => {
734 log::warn!("failed to read directory {}: {}", path.display(), err);
735 }
736 }
737 }
738
739 let display_name = display_name_for_file(&path, &file_info.display_name(), false, is_desktop);
740 let hidden = file_name.starts_with('.');
741
742 Item {
743 name: file_name.into(),
744 display_name,
745 is_mount_point: false,
746 is_client_point: false,
747 metadata: ItemMetadata::GvfsPath {
748 mtime,
749 size_opt,
750 children_opt,
751 },
752 hidden,
753 image_dimensions: (!remote && mime.type_() == mime::IMAGE)
754 .then(|| image::image_dimensions(&path).ok())
755 .flatten(),
756 location_opt: Some(Location::Path(path)),
757 mime,
758 icon_handle_grid,
759 icon_handle_list,
760 icon_handle_list_condensed,
761 thumbnail_opt: if remote {
762 Some(ItemThumbnail::NotImage)
763 } else {
764 None
765 },
766 button_id: widget::Id::unique(),
767 pos_opt: Cell::new(None),
768 rect_opt: Cell::new(None),
769 selected: false,
770 highlighted: false,
771 overlaps_drag_rect: false,
772 dir_size,
773 cut: false,
774 }
775}
776
777pub fn item_from_search_item(search_item: SearchItem, sizes: IconSizes) -> Item {
778 match search_item {
779 SearchItem::Path(path, name, metadata) => item_from_entry(path, name, metadata, sizes),
780 SearchItem::Trash(entry, metadata) => item_from_trash_entry(entry, metadata, sizes),
781 }
782}
783
784pub fn item_from_entry(
785 path: PathBuf,
786 name: String,
787 metadata: fs::Metadata,
788 sizes: IconSizes,
789) -> Item {
790 let mut is_desktop = false;
791 let is_gvfs = false;
792
793 let hidden = name.starts_with('.') || hidden_attribute(&metadata);
794
795 let remote = match fs_kind(&metadata) {
796 FsKind::Local => false,
797 FsKind::Remote => true,
798 #[cfg(feature = "gvfs")]
799 FsKind::Gvfs => {
800 is_gvfs = true;
801 let file = gio::File::for_path(&path);
802
803 match gio::prelude::FileExt::query_filesystem_info(
804 &file,
805 gio::FILE_ATTRIBUTE_FILESYSTEM_REMOTE,
806 gio::Cancellable::NONE,
807 ) {
808 Ok(info) => info.boolean(gio::FILE_ATTRIBUTE_FILESYSTEM_REMOTE),
809 Err(err) => {
810 log::warn!(
811 "failed to get GIO filesystem info for {}: {}",
812 path.display(),
813 err
814 );
815 true
816 }
817 }
818 }
819 #[cfg(not(feature = "gvfs"))]
820 FsKind::Gvfs => {
821 log::info!(
822 "gvfs feature not enabled, info may be inaccurate for {}",
823 path.display()
824 );
825 true
826 }
827 };
828
829 let (mime, icon_handle_grid, icon_handle_list, icon_handle_list_condensed) =
830 if metadata.is_dir() {
831 (
832 "inode/directory".parse().unwrap(),
834 folder_icon(&path, sizes.grid()),
835 folder_icon(&path, sizes.list()),
836 folder_icon(&path, sizes.list_condensed()),
837 )
838 } else {
839 let mime = mime_for_path(&path, Some(&metadata), remote);
840 let icon_name_opt = if mime == "application/x-desktop" {
842 is_desktop = true;
843 get_desktop_file_icon(&path)
844 } else {
845 None
846 };
847 if let Some(icon_name) = icon_name_opt {
848 (
849 mime,
850 desktop_icon_handle(&icon_name, sizes.grid()),
851 desktop_icon_handle(&icon_name, sizes.list()),
852 desktop_icon_handle(&icon_name, sizes.list_condensed()),
853 )
854 } else {
855 (
856 mime.clone(),
857 mime_icon(mime.clone(), sizes.grid()),
858 mime_icon(mime.clone(), sizes.list()),
859 mime_icon(mime, sizes.list_condensed()),
860 )
861 }
862 };
863
864 let mut children_opt = None;
865 let mut dir_size = DirSize::NotDirectory;
866 if metadata.is_dir() && !remote {
867 dir_size = DirSize::Calculating(Controller::default());
868 match fs::read_dir(&path) {
870 Ok(entries) => {
871 children_opt = Some(entries.count());
872 }
873 Err(err) => {
874 log::warn!("failed to read directory {}: {}", path.display(), err);
875 }
876 }
877 }
878
879 let tbprofilerjson_opt = if !remote && !metadata.is_dir() && mime == mime::APPLICATION_JSON {
880 fs::read_to_string(&path)
881 .ok()
882 .and_then(|s| serde_json::from_str(&s).ok())
883 } else {
884 None
885 };
886 let is_ab1 = path
887 .extension()
888 .map(|e| e.eq_ignore_ascii_case("ab1"))
889 .unwrap_or(false);
890 let sequence_opt = if is_ab1 && !remote {
891 fs::read(&path).ok().map(|bytes| {
892 let ab1_seq = parse_ab1_sequence(&bytes);
893 let ab1_qual = parse_ab1_quality(&bytes);
894 let (length, trimmed_length, trimmed_avg_quality_opt) =
895 if let Some(seq) = ab1_seq.as_ref() {
896 let length = seq.len();
897 let trimmed: &[u8] = match &ab1_qual {
898 Some(qual) => match trim_to_min_quality(seq, qual, 20) {
899 Some(trimmed) => {
900 let removed = seq.len() - trimmed.len();
901 if removed > 0 {
902 log::info!(
903 "{}: quality trimming removed {} of {} bases",
904 path.display(),
905 removed,
906 seq.len()
907 );
908 } else {
909 log::info!(
910 "{}: quality trimming removed no bases, using full sequence",
911 path.display()
912 );
913 }
914 trimmed
915 }
916 None => {
917 log::warn!(
918 "{}: quality trimming found no bases above threshold, using full sequence",
919 path.display()
920 );
921 seq.as_slice()
922 }
923 },
924 None => seq.as_slice(),
925 };
926 let trimmed_length = trimmed.len();
927 let trimmed_avg_quality_opt = ab1_qual
928 .as_ref()
929 .filter(|_| trimmed_length > 0)
930 .map(|qual| {
931 let start = trimmed.as_ptr() as usize - seq.as_ptr() as usize;
932 let sum: u32 = qual[start..start + trimmed_length]
933 .iter()
934 .map(|&q| q as u32)
935 .sum();
936 sum as f32 / trimmed_length as f32
937 });
938 (length, trimmed_length, trimmed_avg_quality_opt)
939 } else {
940 (0, 0, None)
941 };
942 let seq_id_hits = crate::sequencing::batch::AB1_SEQ_CACHE
945 .read()
946 .ok()
947 .and_then(|guard| guard.get(&path).cloned())
948 .unwrap_or_default();
949 let chromatogram_opt = Ab1Channels::from_bytes(&bytes);
950 SeqData {
951 chromatogram_opt,
952 seq_id_hits,
953 length,
954 trimmed_length,
955 trimmed_avg_quality_opt,
956 }
957 })
958 } else {
959 None
960 };
961
962 let is_susceptible = sequence_opt.as_ref().and_then(|s| {
963 let hit = s.seq_id_hits.first()?;
964 let erm41_result =
965 is_susceptible_erm41(hit.erm41_position_28_opt.as_ref(), &hit.erm41_snp_calls);
966 if erm41_result.is_some() {
967 return erm41_result;
968 }
969 let rrl_result =
970 is_susceptible_rrl(hit.rrl_position_2058_2059_opt.as_ref(), &hit.rrl_snp_calls);
971 if rrl_result.is_some() {
972 return rrl_result;
973 }
974 let rrs_result = is_susceptible_rrs(&hit.rrs_snp_calls);
975 if rrs_result.is_some() {
976 return rrs_result;
977 }
978 is_susceptible_pnca(&hit.pnca_snp_calls)
979 });
980
981 let susceptibility_calls = sequence_opt
982 .as_ref()
983 .and_then(|s| s.seq_id_hits.first())
984 .map(|hit| SusceptibilityCalls {
985 erm41: Erm41SusceptibilityCalls {
986 position_28: hit.erm41_position_28_opt,
987 lof_snp_calls: hit.erm41_snp_calls.clone(),
988 is_susceptible: is_susceptible_erm41(
989 hit.erm41_position_28_opt.as_ref(),
990 &hit.erm41_snp_calls,
991 ),
992 },
993 rrl: RrlSusceptibilityCalls {
994 position_2058_2059: hit.rrl_position_2058_2059_opt,
995 snp_calls: hit.rrl_snp_calls.clone(),
996 is_susceptible: is_susceptible_rrl(
997 hit.rrl_position_2058_2059_opt.as_ref(),
998 &hit.rrl_snp_calls,
999 ),
1000 is_susceptible_rare: is_susceptible_rrl_by_snp_calls_rare(
1001 hit.rrl_position_2058_2059_opt.as_ref(),
1002 &hit.rrl_snp_calls,
1003 ),
1004 },
1005 rrs: RrsSusceptibilityCalls {
1006 snp_calls: hit.rrs_snp_calls.clone(),
1007 is_susceptible: is_susceptible_rrs(&hit.rrs_snp_calls),
1008 is_susceptible_rare: is_susceptible_rrs_by_snp_calls_rare(&hit.rrs_snp_calls),
1009 },
1010 pnca: PncaSusceptibilityCalls {
1011 snp_calls: hit.pnca_snp_calls.clone(),
1012 is_susceptible: is_susceptible_pnca(&hit.pnca_snp_calls),
1013 },
1014 })
1015 .unwrap_or_default();
1016
1017 let display_name = display_name_for_file(&path, &name, is_gvfs, is_desktop);
1018
1019 Item {
1020 name,
1021 display_name,
1022 is_mount_point: false,
1023 is_client_point: false,
1024 metadata: ItemMetadata::Path {
1025 metadata,
1026 children_opt,
1027 tbprofilerjson_opt,
1028 is_ab1,
1029 sequence_opt,
1030 is_tbprofiler_result_as_sample: false,
1031 is_tbprofiler_groupable_raw_result_file: false,
1032 sample_json_path_opt: None,
1033 sample_csv_path_opt: None,
1034 sample_docx_path_opt: None,
1035 is_susceptible,
1036 susceptibility_calls,
1037 },
1038 hidden,
1039 location_opt: Some(Location::Path(path)),
1040 image_dimensions: None,
1041 mime,
1042 icon_handle_grid,
1043 icon_handle_list,
1044 icon_handle_list_condensed,
1045 thumbnail_opt: remote.then_some(ItemThumbnail::NotImage),
1046 button_id: widget::Id::unique(),
1047 pos_opt: Cell::new(None),
1048 rect_opt: Cell::new(None),
1049 selected: false,
1050 highlighted: false,
1051 overlaps_drag_rect: false,
1052 dir_size,
1053 cut: false,
1054 }
1055}
1056
1057pub fn item_from_trash_entry(
1058 entry: TrashItem,
1059 metadata: TrashItemMetadata,
1060 sizes: IconSizes,
1061) -> Item {
1062 let original_path = entry.original_path();
1063 let name = entry.name.to_string_lossy().into_owned();
1064 let display_name = Item::display_name(&name);
1065
1066 let (mime, icon_handle_grid, icon_handle_list, icon_handle_list_condensed) = match metadata.size
1067 {
1068 trash::TrashItemSize::Entries(_) => (
1069 "inode/directory".parse().unwrap(),
1071 folder_icon(&original_path, sizes.grid()),
1072 folder_icon(&original_path, sizes.list()),
1073 folder_icon(&original_path, sizes.list_condensed()),
1074 ),
1075 trash::TrashItemSize::Bytes(_) => {
1076 let mime = mime_for_path(&original_path, None, true);
1078 (
1079 mime.clone(),
1080 mime_icon(mime.clone(), sizes.grid()),
1081 mime_icon(mime.clone(), sizes.list()),
1082 mime_icon(mime, sizes.list_condensed()),
1083 )
1084 }
1085 };
1086
1087 Item {
1088 name,
1089 display_name,
1090 is_mount_point: false,
1091 is_client_point: false,
1092 metadata: ItemMetadata::Trash { metadata, entry },
1093 hidden: false,
1094 location_opt: None,
1095 image_dimensions: (mime.type_() == mime::IMAGE)
1096 .then(|| image::image_dimensions(&original_path).ok())
1097 .flatten(),
1098 mime,
1099 icon_handle_grid,
1100 icon_handle_list,
1101 icon_handle_list_condensed,
1102 thumbnail_opt: Some(ItemThumbnail::NotImage),
1103 button_id: widget::Id::unique(),
1104 pos_opt: Cell::new(None),
1105 rect_opt: Cell::new(None),
1106 selected: false,
1107 highlighted: false,
1108 overlaps_drag_rect: false,
1109 dir_size: DirSize::NotDirectory,
1110 cut: false,
1111 }
1112}
1113
1114fn get_filename_from_path(path: &Path) -> Result<String, String> {
1115 Ok(match path.file_name() {
1116 Some(name_os) => name_os
1117 .to_str()
1118 .ok_or_else(|| {
1119 format!(
1120 "failed to parse file name for {}: {name_os:?} is not valid UTF-8",
1121 path.display()
1122 )
1123 })?
1124 .to_string(),
1125 None => fl!("filesystem"),
1126 })
1127}
1128
1129pub fn item_from_path<P: Into<PathBuf>>(path: P, sizes: IconSizes) -> Result<Item, String> {
1130 let path = path.into();
1131 let name = get_filename_from_path(&path)?;
1132 let metadata = fs::metadata(&path)
1133 .map_err(|err| format!("failed to read metadata for {}: {}", path.display(), err))?;
1134 Ok(item_from_entry(path, name, metadata, sizes))
1135}
1136
1137pub fn scan_path(tab_path: &PathBuf, sizes: IconSizes) -> Vec<Item> {
1138 let mut items = Vec::new();
1139 let mut hidden_files = Box::from([]);
1140 let remote_scannable = false;
1141
1142 #[cfg(feature = "gvfs")]
1143 {
1144 if let Ok(path_meta) = fs::metadata(tab_path)
1145 && fs_kind(&path_meta) == FsKind::Gvfs
1146 {
1147 let file = gio::File::for_path(tab_path);
1148
1149 let attr_string = [
1151 gio::FILE_ATTRIBUTE_STANDARD_DISPLAY_NAME.as_str(),
1152 gio::FILE_ATTRIBUTE_FILESYSTEM_REMOTE.as_str(),
1153 gio::FILE_ATTRIBUTE_TIME_MODIFIED.as_str(),
1154 gio::FILE_ATTRIBUTE_STANDARD_SIZE.as_str(),
1155 gio::FILE_ATTRIBUTE_STANDARD_TYPE.as_str(),
1156 gio::FILE_ATTRIBUTE_STANDARD_NAME.as_str(),
1157 ]
1158 .join(",");
1159
1160 match gio::prelude::FileExt::enumerate_children(
1161 &file,
1162 attr_string.as_str(),
1163 gio::FileQueryInfoFlags::NONE,
1164 gio::Cancellable::NONE,
1165 ) {
1166 Ok(res) => {
1167 remote_scannable = true;
1168 items = res
1169 .filter_map(|file| {
1170 let file = file.ok()?;
1171 Some(item_from_gvfs_info(tab_path.join(file.name()), file, sizes))
1172 })
1173 .collect();
1174 }
1175 Err(err) => {
1176 log::warn!(
1177 "could not enumerate {} via gio: {}",
1178 tab_path.display(),
1179 err
1180 );
1181 }
1182 }
1183 }
1184 }
1185
1186 if !remote_scannable {
1187 match fs::read_dir(tab_path) {
1188 Ok(entries) => {
1189 items = entries
1190 .filter_map(|entry_res| {
1191 let entry = entry_res
1192 .inspect_err(|err| {
1193 log::warn!(
1194 "failed to read entry in {}: {}",
1195 tab_path.display(),
1196 err
1197 )
1198 })
1199 .ok()?;
1200
1201 let path = entry.path();
1202
1203 let name = entry
1204 .file_name()
1205 .into_string()
1206 .inspect_err(|name_os| {
1207 log::warn!(
1208 "failed to parse entry at {}: {:?} is not valid UTF-8",
1209 path.display(),
1210 name_os
1211 )
1212 })
1213 .ok()?;
1214
1215 if name == ".hidden" && path.is_file() {
1216 hidden_files = parse_hidden_file(&path);
1217 }
1218
1219 let metadata = fs::metadata(&path)
1220 .inspect_err(|err| {
1221 log::warn!(
1222 "failed to read metadata for entry at {}: {}",
1223 path.display(),
1224 err
1225 )
1226 })
1227 .ok()?;
1228
1229 Some(item_from_entry(path, name, metadata, sizes))
1230 })
1231 .collect();
1232 }
1233 Err(err) => {
1234 log::warn!("failed to read directory {}: {}", tab_path.display(), err);
1235 }
1236 }
1237 }
1238 items.sort_unstable_by(|a, b| match (a.metadata.is_dir(), b.metadata.is_dir()) {
1239 (true, false) => Ordering::Less,
1240 (false, true) => Ordering::Greater,
1241 _ => LANGUAGE_SORTER.compare(&a.display_name, &b.display_name),
1242 });
1243 for item in &mut items {
1244 if hidden_files.contains(&item.name) {
1245 item.hidden = true;
1246 }
1247 }
1248
1249 struct LocalSampleFiles {
1252 json: Option<PathBuf>,
1253 csv: Option<PathBuf>,
1254 docx: Option<PathBuf>,
1255 }
1256
1257 let mut samples: HashMap<String, LocalSampleFiles> = HashMap::new();
1258 for item in &mut items {
1259 let name = item.name.clone();
1260 let path = item.path_opt().cloned();
1261 if let ItemMetadata::Path {
1262 is_tbprofiler_groupable_raw_result_file,
1263 ..
1264 } = &mut item.metadata
1265 && let Some((sample_id, suffix)) = name.split_once(".results.")
1266 {
1267 let entry = samples
1268 .entry(sample_id.to_string())
1269 .or_insert(LocalSampleFiles {
1270 json: None,
1271 csv: None,
1272 docx: None,
1273 });
1274 match suffix {
1275 "json" => entry.json = path,
1276 "csv" => entry.csv = path,
1277 "docx" => entry.docx = path,
1278 _ => {}
1279 }
1280 *is_tbprofiler_groupable_raw_result_file = true;
1281 }
1282 }
1283 for item in &mut items {
1284 let name = item.name.clone();
1285 if let ItemMetadata::Path {
1286 is_tbprofiler_groupable_raw_result_file,
1287 ..
1288 } = &mut item.metadata
1289 && *is_tbprofiler_groupable_raw_result_file
1290 {
1291 let sample_id = name.find('.').map(|i| &name[..i]);
1292 if sample_id.is_none_or(|id| samples.get(id).is_none_or(|f| f.json.is_none())) {
1293 *is_tbprofiler_groupable_raw_result_file = false;
1294 }
1295 }
1296 }
1297 let sample_susceptibility: HashMap<String, bool> = samples
1299 .iter()
1300 .filter_map(|(id, files)| {
1301 let p = files.json.as_ref()?;
1302 let json: TbProfilerJson = fs::read_to_string(p)
1303 .ok()
1304 .and_then(|s| serde_json::from_str(&s).ok())?;
1305 Some((
1306 id.clone(),
1307 json.dr_variants.iter().all(|v| v.is_susceptible()),
1308 ))
1309 })
1310 .collect();
1311 for item in &mut items {
1312 let name = item.name.clone();
1313 if let ItemMetadata::Path {
1314 is_tbprofiler_groupable_raw_result_file,
1315 is_susceptible,
1316 ..
1317 } = &mut item.metadata
1318 && *is_tbprofiler_groupable_raw_result_file
1319 && let Some(id) = name.find('.').map(|i| &name[..i])
1320 && let Some(&sus) = sample_susceptibility.get(id)
1321 {
1322 *is_susceptible = Some(sus);
1323 }
1324 }
1325
1326 for (sample_id, files) in samples {
1327 if files.json.is_none() {
1328 continue;
1329 }
1330 let representative = files.json.as_deref().unwrap_or(tab_path);
1331 let Ok(fs_meta) = fs::metadata(representative) else {
1332 continue;
1333 };
1334 let tbprofilerjson_opt: Option<TbProfilerJson> = files.json.as_ref().and_then(|p| {
1335 fs::read_to_string(p)
1336 .ok()
1337 .and_then(|s| serde_json::from_str(&s).ok())
1338 });
1339 let is_susceptible = tbprofilerjson_opt
1340 .as_ref()
1341 .map(|j| j.dr_variants.iter().all(|v| v.is_susceptible()));
1342 let location = Location::Path(tab_path.join(&sample_id));
1343 let metadata = ItemMetadata::Path {
1344 metadata: fs_meta,
1345 children_opt: None,
1346 tbprofilerjson_opt,
1347 is_ab1: false,
1348 sequence_opt: None,
1349 is_tbprofiler_result_as_sample: true,
1350 is_tbprofiler_groupable_raw_result_file: false,
1351 sample_json_path_opt: files.json,
1352 sample_csv_path_opt: files.csv,
1353 sample_docx_path_opt: files.docx,
1354 is_susceptible,
1355 susceptibility_calls: SusceptibilityCalls::default(),
1356 };
1357 items.push(Item {
1358 name: sample_id.clone(),
1359 display_name: sample_id,
1360 metadata,
1361 hidden: false,
1362 location_opt: Some(location),
1363 image_dimensions: None,
1364 mime: mime::APPLICATION_JSON,
1365 icon_handle_grid: mime_icon(mime::APPLICATION_JSON, sizes.grid()),
1366 icon_handle_list: mime_icon(mime::APPLICATION_JSON, sizes.list()),
1367 icon_handle_list_condensed: mime_icon(mime::APPLICATION_JSON, sizes.list_condensed()),
1368 thumbnail_opt: Some(ItemThumbnail::NotImage),
1369 button_id: widget::Id::unique(),
1370 pos_opt: Cell::new(None),
1371 rect_opt: Cell::new(None),
1372 selected: false,
1373 highlighted: false,
1374 overlaps_drag_rect: false,
1375 dir_size: DirSize::NotDirectory,
1376 cut: false,
1377 is_mount_point: false,
1378 is_client_point: false,
1379 });
1380 }
1381
1382 items
1383}
1384
1385pub fn scan_search<F: Fn(SearchItem) -> bool + Sync>(
1386 search_location: &SearchLocation,
1387 term: &str,
1388 show_hidden: bool,
1389 callback: F,
1390) {
1391 if term.is_empty() {
1392 return;
1393 }
1394
1395 let pattern = regex::escape(term);
1396 let regex = match regex::RegexBuilder::new(&pattern)
1397 .case_insensitive(true)
1398 .build()
1399 {
1400 Ok(ok) => ok,
1401 Err(err) => {
1402 log::warn!("failed to parse regex {pattern:?}: {err}");
1403 return;
1404 }
1405 };
1406
1407 match search_location {
1408 SearchLocation::Path(tab_path) => {
1409 ignore::WalkBuilder::new(tab_path)
1410 .standard_filters(false)
1411 .hidden(!show_hidden)
1412 .same_file_system(true)
1414 .build_parallel()
1415 .run(|| {
1416 Box::new(|entry_res| {
1417 let Ok(entry) = entry_res else {
1418 return ignore::WalkState::Skip;
1420 };
1421
1422 let Some(file_name) = entry.file_name().to_str() else {
1423 return ignore::WalkState::Skip;
1425 };
1426
1427 if regex.is_match(file_name) {
1428 let path = entry.path();
1429
1430 let metadata = match entry.metadata() {
1431 Ok(ok) => ok,
1432 Err(err) => {
1433 log::warn!(
1434 "failed to read metadata for entry at {}: {}",
1435 path.display(),
1436 err
1437 );
1438 return ignore::WalkState::Continue;
1439 }
1440 };
1441
1442 if !callback(SearchItem::Path(
1443 path.to_path_buf(),
1444 file_name.to_string(),
1445 metadata,
1446 )) {
1447 return ignore::WalkState::Quit;
1448 }
1449 }
1450
1451 ignore::WalkState::Continue
1452 })
1453 });
1454 }
1455 SearchLocation::Recents => {
1456 let recent_files = match recently_used_xbel::parse_file() {
1457 Ok(recent_files) => recent_files,
1458 Err(err) => {
1459 log::warn!("Error reading recent files: {err:?}");
1460 return;
1461 }
1462 };
1463
1464 for bookmark in recent_files.bookmarks {
1465 let path = uri_to_path(bookmark.href);
1466 if let Some(path) = path
1467 && path.exists()
1468 {
1469 let file_name = path.file_name();
1470 if let Some(file_name) = file_name {
1471 let file_name = file_name.to_string_lossy();
1472 if regex.is_match(&file_name) {
1473 match path.metadata() {
1474 Ok(metadata) => {
1475 if !callback(SearchItem::Path(
1476 path.to_path_buf(),
1477 file_name.to_string(),
1478 metadata,
1479 )) {
1480 break;
1481 }
1482 }
1483 Err(err) => {
1484 log::warn!(
1485 "failed to read metadata for entry at {}: {}",
1486 path.display(),
1487 err
1488 );
1489 }
1490 };
1491 }
1492 }
1493 }
1494 }
1495 }
1496 SearchLocation::Trash => {
1497 Trash::scan_search(callback, ®ex);
1498 }
1499 }
1500}
1501
1502fn uri_to_path(uri: String) -> Option<PathBuf> {
1503 uri.parse::<url::Url>().ok().and_then(|url| {
1504 if url.scheme() == "file" {
1506 url.to_file_path().ok()
1507 } else {
1508 None
1509 }
1510 })
1511}
1512
1513pub fn has_recents() -> bool {
1514 match recently_used_xbel::parse_file() {
1515 Ok(recent_files) => !recent_files.bookmarks.is_empty(),
1516 Err(_) => false,
1517 }
1518}
1519
1520pub fn scan_recents(sizes: IconSizes) -> Vec<Item> {
1521 let recent_files = match recently_used_xbel::parse_file() {
1522 Ok(recent_files) => recent_files,
1523 Err(err) => {
1524 log::warn!("Error reading recent files: {err:?}");
1525 return Vec::new();
1526 }
1527 };
1528 let mut recents: Vec<_> = recent_files
1529 .bookmarks
1530 .into_iter()
1531 .filter_map(|bookmark| {
1532 let path = uri_to_path(bookmark.href)?;
1533 let last_edit = bookmark.modified.parse::<jiff::Timestamp>().ok()?;
1534 let last_visit = bookmark.visited.parse::<jiff::Timestamp>().ok()?;
1535
1536 if path.exists() {
1537 let file_name = path.file_name()?;
1538 let name = file_name.to_string_lossy().to_string();
1539
1540 let metadata = match path.metadata() {
1541 Ok(ok) => ok,
1542 Err(err) => {
1543 log::warn!(
1544 "failed to read metadata for entry at {}: {}",
1545 path.display(),
1546 err
1547 );
1548 return None;
1549 }
1550 };
1551
1552 let item = item_from_entry(path, name, metadata, sizes);
1553 Some((item, last_edit.min(last_visit)))
1554 } else {
1555 log::warn!("recent file path does not exist: {}", path.display());
1556 None
1557 }
1558 })
1559 .collect();
1560
1561 recents.sort_by_key(|recent| Reverse(recent.1));
1562
1563 recents.into_iter().take(50).map(|(item, _)| item).collect()
1564}
1565
1566pub fn scan_network(uri: &str, sizes: IconSizes) -> Vec<Item> {
1567 for mounter in MOUNTERS.values() {
1568 match mounter.network_scan(uri, sizes) {
1569 Some(Ok(items)) => return items,
1570 Some(Err(err)) => {
1571 log::warn!("failed to scan {uri:?}: {err}");
1572 }
1573 None => {}
1574 }
1575 }
1576 Vec::new()
1577}
1578
1579pub fn scan_remote(uri: &str, sizes: IconSizes) -> Vec<Item> {
1580 for (_key, client) in CLIENTS.iter() {
1581 match client.remote_scan(uri, sizes) {
1582 Some(Ok(items)) => return items,
1583 Some(Err(err)) => {
1584 log::warn!("failed to scan remote {:?}: {}", uri, err);
1585 }
1586 None => {}
1587 }
1588 }
1589 Vec::new()
1590}
1591
1592pub fn item_from_remote(uri: &str, sizes: IconSizes) -> Result<Item, String> {
1593 for (_key, client) in CLIENTS.iter() {
1594 match client.remote_parent_item(uri, sizes) {
1595 Some(Ok(item)) => return Ok(item),
1596 Some(Err(err)) => {
1597 log::warn!("failed to scan remote {:?}: {}", uri, err);
1598 }
1599 None => {}
1600 }
1601 }
1602 Err(format!("no client found for remote uri: {}", uri))
1603}
1604
1605pub fn scan_desktop(
1607 tab_path: &PathBuf,
1608 _display: &str,
1609 desktop_config: DesktopConfig,
1610 mut sizes: IconSizes,
1611) -> Vec<Item> {
1612 sizes.grid = desktop_config.icon_size;
1613
1614 let mut items = Vec::new();
1615
1616 if desktop_config.show_content {
1617 items.extend(scan_path(tab_path, sizes));
1618 }
1619
1620 if desktop_config.show_mounted_drives {
1621 for mounter in MOUNTERS.values() {
1622 let Some(mounter_items) = mounter.items(sizes) else {
1623 continue;
1624 };
1625 items.extend(mounter_items.into_iter().filter_map(|mounter_item| {
1626 let path = mounter_item.path()?;
1627 let mut item = match item_from_path(&path, sizes) {
1629 Ok(item) => item,
1630 Err(err) => {
1631 log::warn!(
1632 "failed to get item from mounter item {}: {}",
1633 path.display(),
1634 err
1635 );
1636 return None;
1637 }
1638 };
1639
1640 item.name = mounter_item.name();
1642 item.display_name = Item::display_name(&item.name);
1643
1644 if let Some(icon) = mounter_item.icon(false) {
1646 item.icon_handle_grid.clone_from(&icon);
1647 item.icon_handle_list.clone_from(&icon);
1648 item.icon_handle_list_condensed = icon;
1649 }
1650
1651 Some(item)
1652 }));
1653 }
1654 }
1655
1656 if desktop_config.show_connected_drives {
1657 for client in CLIENTS.values() {
1658 let Some(client_items) = client.items(sizes) else {
1659 continue;
1660 };
1661 items.extend(client_items.into_iter().filter_map(|client_item| {
1662 let path = client_item.path()?;
1663 let mut item = match item_from_path(&path, sizes) {
1665 Ok(item) => item,
1666 Err(err) => {
1667 log::warn!(
1668 "failed to get item from mounter item {}: {}",
1669 path.display(),
1670 err
1671 );
1672 return None;
1673 }
1674 };
1675
1676 item.name = client_item.name();
1678 item.display_name = Item::display_name(&item.name);
1679
1680 if let Some(icon) = client_item.icon(false) {
1682 item.icon_handle_grid.clone_from(&icon);
1683 item.icon_handle_list.clone_from(&icon);
1684 item.icon_handle_list_condensed = icon;
1685 }
1686
1687 Some(item)
1688 }));
1689 }
1690 }
1691
1692 if desktop_config.show_trash {
1693 let name = fl!("trash");
1694 let display_name = Item::display_name(&name);
1695
1696 let metadata = ItemMetadata::SimpleDir {
1697 entries: Trash::entries() as u64,
1698 };
1699
1700 let (mime, icon_handle_grid, icon_handle_list, icon_handle_list_condensed) = {
1701 (
1702 "inode/directory".parse().unwrap(),
1703 Trash::icon(sizes.grid()),
1704 Trash::icon(sizes.list()),
1705 Trash::icon(sizes.list_condensed()),
1706 )
1707 };
1708
1709 items.push(Item {
1710 name,
1711 display_name,
1712 is_mount_point: false,
1713 is_client_point: false,
1714 metadata,
1715 hidden: false,
1716 location_opt: Some(Location::Trash),
1717 image_dimensions: None,
1718 mime,
1719 icon_handle_grid,
1720 icon_handle_list,
1721 icon_handle_list_condensed,
1722 thumbnail_opt: Some(ItemThumbnail::NotImage),
1723 button_id: widget::Id::unique(),
1724 pos_opt: Cell::new(None),
1725 rect_opt: Cell::new(None),
1726 selected: false,
1727 highlighted: false,
1728 overlaps_drag_rect: false,
1729 dir_size: DirSize::NotDirectory,
1730 cut: false,
1731 });
1732 }
1733
1734 items
1735}
1736
1737#[derive(Clone, Debug)]
1738pub struct EditLocation {
1739 pub location: Location,
1740 pub completions: Option<Vec<(String, PathBuf)>>,
1741 pub selected: Option<usize>,
1742}
1743
1744impl EditLocation {
1745 pub fn resolve(&self) -> Option<Location> {
1746 if let Location::Network(uri, ..) = &self.location {
1747 MOUNTERS
1748 .values()
1749 .find_map(|mounter| mounter.dir_info(uri))
1750 .map(|(uri, display_name, path_opt)| Location::Network(uri, display_name, path_opt))
1751 } else if let Location::Remote(uri, ..) = &self.location {
1752 CLIENTS
1753 .values()
1754 .find_map(|client| client.dir_info(uri))
1755 .map(|(uri, display_name, path_opt)| Location::Remote(uri, display_name, path_opt))
1756 } else {
1757 let Some(selected) = self.selected else {
1758 return Some(self.location.clone());
1759 };
1760 let completions = self.completions.as_ref()?;
1761 let completion = completions.get(selected)?;
1762 Some(self.location.with_path(completion.1.clone()).normalize())
1763 }
1764 }
1765
1766 pub fn select(&mut self, forwards: bool) {
1767 if let Some(completions) = &self.completions {
1768 if completions.is_empty() {
1769 self.selected = None;
1770 } else {
1771 let mut selected = if forwards {
1772 self.selected.and_then(|x| x.checked_add(1)).unwrap_or(0)
1773 } else {
1774 self.selected
1775 .and_then(|x| x.checked_sub(1))
1776 .unwrap_or(completions.len() - 1)
1777 };
1778 if selected >= completions.len() {
1779 selected = 0;
1780 }
1781 self.selected = Some(selected);
1782
1783 if completions.len() == 1
1785 && let Some(resolved) = self.resolve()
1786 {
1787 self.location = resolved;
1788 self.selected = None;
1789 }
1790 }
1791 } else {
1792 self.selected = None;
1793 }
1794 }
1795}
1796
1797#[derive(Clone, Debug, Eq, Hash, PartialEq)]
1798pub enum SearchLocation {
1799 Path(PathBuf),
1800 Recents,
1801 Trash,
1802}
1803
1804impl std::fmt::Display for SearchLocation {
1805 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1806 match self {
1807 Self::Path(path) => write!(f, "{}", path.display()),
1808 Self::Recents => write!(f, "recents"),
1809 Self::Trash => write!(f, "trash"),
1810 }
1811 }
1812}
1813
1814#[derive(Clone, Debug)]
1815pub enum SearchItem {
1816 Path(PathBuf, String, fs::Metadata),
1817 Trash(TrashItem, TrashItemMetadata),
1818}
1819
1820impl From<Location> for EditLocation {
1821 fn from(location: Location) -> Self {
1822 Self {
1823 location,
1824 completions: None,
1825 selected: None,
1826 }
1827 }
1828}
1829
1830#[derive(Clone, Debug, Eq, Hash, PartialEq)]
1831pub enum Location {
1832 Desktop(PathBuf, String, DesktopConfig),
1833 Network(String, String, Option<PathBuf>),
1834 Path(PathBuf),
1835 Recents,
1836 Remote(String, String, Option<PathBuf>),
1837 Search(SearchLocation, String, bool, Instant),
1838 Trash,
1839}
1840
1841impl std::fmt::Display for Location {
1842 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1843 match self {
1844 Self::Desktop(path, display, ..) => {
1845 write!(f, "{} on display {display}", path.display())
1846 }
1847 Self::Network(uri, ..) => write!(f, "{uri}"),
1848 Self::Path(path) => write!(f, "{}", path.display()),
1849 Self::Recents => write!(f, "recents"),
1850 Self::Remote(..) => write!(f, "remote"),
1851 Self::Search(location, term, ..) => {
1852 write!(f, "search {} for {}", location, term)
1853 }
1854 Self::Trash => write!(f, "trash"),
1855 }
1856 }
1857}
1858
1859impl Location {
1860 pub fn normalize(&self) -> Self {
1861 if let Location::Network(uri, ..) = self {
1862 if !uri.ends_with('/') {
1863 let mut uri = uri.clone();
1864 uri.push('/');
1865 self.with_uri(uri)
1866 } else {
1867 self.clone()
1868 }
1869 } else if let Location::Remote(uri, ..) = self {
1870 if !uri.ends_with('/') {
1871 let mut uri = uri.clone();
1872 uri.push('/');
1873 self.with_uri(uri)
1874 } else {
1875 self.clone()
1876 }
1877 } else if let Some(mut path) = self.path_opt().cloned() {
1878 if let Ok(canonical) = fs::canonicalize(&path) {
1880 path = canonical;
1881 }
1882 if path.is_dir() {
1884 path.push("");
1885 }
1886 self.with_path(path)
1887 } else {
1888 self.clone()
1889 }
1890 }
1891
1892 pub fn ancestors(&self) -> Vec<(Self, String)> {
1893 self.path_opt().map_or_else(Default::default, |path| {
1894 path.ancestors()
1895 .scan(false, |found_home, ancestor| {
1896 (!*found_home).then(|| {
1897 let (name, is_home) = folder_name(ancestor);
1898 *found_home = is_home;
1899 (self.with_path(ancestor.to_path_buf()), name)
1900 })
1901 })
1902 .collect()
1903 })
1904 }
1905
1906 pub const fn path_opt(&self) -> Option<&PathBuf> {
1907 match self {
1908 Self::Desktop(path, ..) => Some(path),
1909 Self::Path(path) => Some(path),
1910 Self::Search(SearchLocation::Path(path), ..) => Some(path),
1911 Self::Network(_, _, path) => path.as_ref(),
1912 Self::Remote(_, _, path) => path.as_ref(),
1913 _ => None,
1914 }
1915 }
1916
1917 pub(crate) fn into_path_opt(self) -> Option<PathBuf> {
1918 match self {
1919 Self::Desktop(path, ..) => Some(path),
1920 Self::Path(path) => Some(path),
1921 Self::Search(SearchLocation::Path(path), ..) => Some(path),
1922 Self::Network(_, _, path) => path,
1923 Self::Remote(_, _, path) => path,
1924 _ => None,
1925 }
1926 }
1927
1928 pub(crate) fn into_uri_opt(self) -> Option<String> {
1929 match self {
1930 Self::Remote(uri, _, _) => Some(uri),
1931 _ => None,
1932 }
1933 }
1934
1935 pub fn with_path(&self, path: PathBuf) -> Self {
1936 let path = Self::expand_tilde(path);
1937 match self {
1938 Self::Desktop(_, display, desktop_config) => {
1939 Self::Desktop(path, display.clone(), *desktop_config)
1940 }
1941 Self::Path(..) => Self::Path(path),
1942 Self::Search(SearchLocation::Path(_), term, show_hidden, time) => Self::Search(
1943 SearchLocation::Path(path),
1944 term.clone(),
1945 *show_hidden,
1946 *time,
1947 ),
1948 Self::Remote(uri, ..) => {
1949 if let Ok(mut url) = url::Url::parse(uri)
1950 && let Some(path_str) = path.to_str()
1951 {
1952 url.set_path(path_str);
1953 let name = path
1954 .file_name()
1955 .and_then(|n| n.to_str())
1956 .unwrap_or("/")
1957 .to_string();
1958 return Self::Remote(url.to_string(), name, Some(path));
1959 }
1960 self.clone()
1961 }
1962
1963 other => other.clone(),
1964 }
1965 }
1966
1967 pub fn with_uri(&self, uri: String) -> Self {
1968 if let Self::Network(_, name, path) = self {
1969 Self::Network(uri, name.clone(), path.clone())
1970 } else if let Self::Remote(_, name, path) = self {
1971 Self::Remote(uri, name.clone(), path.clone())
1972 } else {
1973 self.clone()
1974 }
1975 }
1976
1977 pub fn scan(&self, sizes: IconSizes) -> (Option<Box<Item>>, Vec<Item>) {
1978 let items = match self {
1979 Self::Desktop(path, display, desktop_config) => {
1980 scan_desktop(path, display, *desktop_config, sizes)
1981 }
1982 Self::Path(path) => scan_path(path, sizes),
1983 Self::Search(..) => {
1984 Vec::new()
1986 }
1987 Self::Trash => Trash::scan(sizes),
1988 Self::Recents => scan_recents(sizes),
1989 Self::Network(uri, _, _) => scan_network(uri, sizes),
1990 Self::Remote(uri, _, _) => scan_remote(uri, sizes),
1991 };
1992 let parent_item_opt = match self {
1993 Self::Remote(uri, _, path_opt) => {
1994 match path_opt {
1995 Some(path) => match item_from_remote(uri, sizes) {
1996 Ok(item) => Some(Box::new(item)),
1997 Err(err) => {
1998 log::warn!("failed to get item for {}: {}", path.display(), err);
1999 None
2000 }
2001 },
2002 None => None,
2004 }
2005 }
2006 _ => {
2007 match self.path_opt() {
2008 Some(path) => match item_from_path(path, sizes) {
2009 Ok(item) => Some(Box::new(item)),
2010 Err(err) => {
2011 log::warn!("failed to get item for {}: {}", path.display(), err);
2012 None
2013 }
2014 },
2015 None => None,
2017 }
2018 }
2019 };
2020 (parent_item_opt, items)
2021 }
2022
2023 pub fn title(&self) -> String {
2024 match self {
2025 Self::Desktop(path, _, _) => {
2026 let (name, _) = folder_name(path);
2027 name
2028 }
2029 Self::Path(path) => {
2030 let (name, _) = folder_name(path);
2031 name
2032 }
2033 Self::Search(location, term, ..) => {
2034 let name = match location {
2035 SearchLocation::Path(path) => folder_name(path).0,
2036 SearchLocation::Trash => fl!("trash"),
2037 SearchLocation::Recents => fl!("recents"),
2038 };
2039
2040 format!("Search \"{term}\": {name}")
2042 }
2043 Self::Trash => {
2044 fl!("trash")
2045 }
2046 Self::Recents => {
2047 fl!("recents")
2048 }
2049 Self::Network(display_name, ..) => display_name.clone(),
2050 Self::Remote(_, name, _) => name.clone(),
2051 }
2052 }
2053
2054 pub fn expand_tilde(path: PathBuf) -> PathBuf {
2057 let mut components = path.components();
2058 match components.next() {
2059 Some(path::Component::Normal(os_str)) if os_str == "~" => {
2060 if let Some(home) = dirs::home_dir() {
2061 home.join(components.as_path())
2062 } else {
2063 path
2064 }
2065 }
2066 _ => path,
2067 }
2068 }
2069
2070 pub fn is_trash(&self) -> bool {
2071 matches!(
2072 self,
2073 Location::Trash | Location::Search(SearchLocation::Trash, ..)
2074 )
2075 }
2076
2077 pub fn is_recents(&self) -> bool {
2078 matches!(
2079 self,
2080 Location::Recents | Location::Search(SearchLocation::Recents, ..)
2081 )
2082 }
2083
2084 pub fn supports_paste(&self) -> bool {
2086 matches!(
2087 self,
2088 Self::Desktop(..)
2089 | Self::Path(..)
2090 | Self::Search(..)
2091 | Self::Recents
2092 | Self::Network(_, _, Some(_))
2093 )
2094 }
2095}
2096
2097pub struct TaskWrapper(pub cosmic::Task<Message>);
2098
2099impl From<cosmic::Task<Message>> for TaskWrapper {
2100 fn from(task: cosmic::Task<Message>) -> Self {
2101 Self(task)
2102 }
2103}
2104
2105impl fmt::Debug for TaskWrapper {
2106 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2107 f.debug_struct("TaskWrapper").finish()
2108 }
2109}
2110
2111#[derive(Debug)]
2112pub enum Command {
2113 Action(Action),
2114 AddNetworkDrive,
2115 AddRemoteDrive,
2116 DeleteTbProfilerResults(String, TBConfig),
2117 AddToSidebar(PathBuf),
2118 AutoScroll(Option<f32>),
2119 ChangeLocation(String, Location, Option<Vec<PathBuf>>),
2120 ContextMenu(Option<Point>, Option<window::Id>),
2121 Delete(Vec<PathBuf>),
2122 DownloadFile(Vec<PathBuf>, Vec<String>),
2123 DropFiles(PathBuf, ClipboardPaste),
2124 ClearRecents,
2125 EmptyTrash,
2126 #[cfg(feature = "desktop")]
2127 ExecEntryAction(cosmic::desktop::DesktopEntryData, usize),
2128 Iced(TaskWrapper),
2129 OpenFile(Vec<PathBuf>),
2130 OpenSeqAlignment(Box<SeqIdHit>),
2131 OpenSpeciesAlignment(Box<SeqIdHit>),
2132 OpenInNewTab(PathBuf),
2133 OpenUriInNewTab(String, String, Option<PathBuf>),
2134 OpenInNewWindow(PathBuf),
2135 OpenTrash,
2136 Preview(PreviewKind),
2137 RunContextAction(usize),
2138 SetOpenWith(Mime, String),
2139 SetPermissions(PathBuf, u32),
2140 SetMultiplePermissions(Vec<(PathBuf, u32)>),
2141 SetSort(String, HeadingOptions, bool),
2142 WindowDrag,
2143 WindowToggleMaximize,
2144}
2145
2146#[derive(Clone, Debug)]
2147pub enum Message {
2148 AddNetworkDrive,
2149 AddRemoteDrive,
2150 DeleteTbProfilerResults(String, TBConfig),
2151 AutoScroll(Option<f32>),
2152 Click(Option<usize>),
2153 DoubleClick(Option<usize>),
2154 ClickRelease(Option<usize>),
2155 Config(TabConfig),
2156 ContextAction(Action),
2157 ContextMenu(Option<Point>, Option<window::Id>),
2158 LocationContextMenuPoint(Option<Point>),
2159 LocationContextMenuIndex(Option<Point>, Option<usize>),
2160 LocationMenuAction(LocationMenuAction),
2161 Download(Option<(PathBuf, String)>),
2162 DownloadMany(Vec<PathBuf>, Vec<String>),
2163 Drag(Option<Rectangle>),
2164 DragEnd,
2165 EditLocation(Option<EditLocation>),
2166 EditLocationComplete(usize),
2167 EditLocationEnable,
2168 EditLocationSubmit,
2169 EditLocationTab,
2170 OpenInNewTab(PathBuf),
2171 ClearRecents,
2172 EmptyTrash,
2173 #[cfg(feature = "desktop")]
2174 ExecEntryAction(Option<PathBuf>, usize),
2175 Gallery(bool),
2176 GalleryPrevious,
2177 GalleryNext,
2178 GalleryToggle,
2179 GoNext,
2180 GoPrevious,
2181 ItemDown,
2182 ItemLeft,
2183 ItemPageDown,
2184 ItemPageUp,
2185 ItemRight,
2186 ItemUp,
2187 Location(Location),
2188 LocationUp,
2189 Open(Option<PathBuf>),
2190 Reload,
2191 RightClick(Option<Point>, Option<usize>),
2192 MiddleClick(usize),
2193 Resize(Rectangle),
2194 Scroll(Viewport),
2195 ScrollTab(f32),
2196 ScrollToFocused,
2197 SearchContext(Location, SearchContextWrapper),
2198 SearchReady(bool),
2199 SelectAll,
2200 SelectFirst,
2201 SelectLast,
2202 SetOpenWith(Mime, String),
2203 RunContextAction(usize),
2204 SetPermissions(PathBuf, u32),
2205 ShiftPermissions(Option<(PathBuf, u32)>, u32, u32),
2206 SetSort(HeadingOptions, bool),
2207 TabComplete(PathBuf, Vec<(String, PathBuf)>),
2208 Thumbnail(PathBuf, ItemThumbnail),
2209 ToggleSort(HeadingOptions),
2210 Drop(Option<(Location, ClipboardPaste)>),
2211 DndHover(Location),
2212 DndEnter(Location),
2213 DndLeave(Location),
2214 WindowDrag,
2215 WindowToggleMaximize,
2216 ZoomIn,
2217 ZoomOut,
2218 HighlightDeactivate(usize),
2219 HighlightActivate(usize),
2220 DirectorySize(PathBuf, DirSize),
2221 ImageDecoded(PathBuf, u32, u32, Vec<u8>, Option<(u32, u32)>, u64), OpenSeqAlignment(Box<SeqIdHit>),
2223 OpenSpeciesAlignment(Box<SeqIdHit>),
2224}
2225
2226#[derive(Copy, Clone, Debug, Eq, PartialEq)]
2227pub enum LocationMenuAction {
2228 OpenInNewTab(usize),
2229 OpenInNewWindow(usize),
2230 Preview(usize),
2231 AddToSidebar(usize),
2232}
2233
2234impl MenuAction for LocationMenuAction {
2235 type Message = Message;
2236
2237 fn message(&self) -> Self::Message {
2238 Message::LocationMenuAction(*self)
2239 }
2240}
2241
2242#[derive(Clone, Debug)]
2243pub enum DirSize {
2244 Calculating(Controller),
2245 Directory(u64),
2246 NotDirectory,
2247 Error(String),
2248}
2249
2250#[derive(Clone, Debug)]
2251#[allow(clippy::large_enum_variant)]
2252pub enum ItemMetadata {
2253 Path {
2254 metadata: Metadata,
2255 children_opt: Option<usize>,
2256 tbprofilerjson_opt: Option<TbProfilerJson>,
2257 is_ab1: bool,
2258 sequence_opt: Option<SeqData>,
2259 is_tbprofiler_result_as_sample: bool,
2260 is_tbprofiler_groupable_raw_result_file: bool,
2261 sample_json_path_opt: Option<PathBuf>,
2262 sample_csv_path_opt: Option<PathBuf>,
2263 sample_docx_path_opt: Option<PathBuf>,
2264 is_susceptible: Option<bool>,
2265 susceptibility_calls: SusceptibilityCalls,
2266 },
2267 Trash {
2268 metadata: trash::TrashItemMetadata,
2269 entry: trash::TrashItem,
2270 },
2271 SimpleDir {
2272 entries: u64,
2273 },
2274 SimpleFile {
2275 size: u64,
2276 },
2277 #[cfg(feature = "gvfs")]
2278 GvfsPath {
2279 mtime: u64,
2280 size_opt: Option<u64>,
2281 children_opt: Option<usize>,
2282 },
2283 #[cfg(feature = "russh")]
2284 RusshPath {
2285 mtime: u64,
2286 size_opt: Option<u64>,
2287 children_opt: Option<usize>,
2288 is_tbprofiler_json: bool,
2289 tbprofilerjson_opt: Option<TbProfilerJson>,
2290 is_tbprofiler_result_as_sample: bool,
2291 is_tbprofiler_groupable_raw_result_file: bool,
2292 sample_json_path_opt: Option<PathBuf>,
2293 sample_csv_path_opt: Option<PathBuf>,
2294 sample_docx_path_opt: Option<PathBuf>,
2295 is_susceptible: Option<bool>,
2296 },
2297}
2298
2299impl ItemMetadata {
2300 pub fn is_dir(&self) -> bool {
2301 match self {
2302 Self::Path { metadata, .. } => metadata.is_dir(),
2303 Self::Trash { metadata, .. } => match metadata.size {
2304 trash::TrashItemSize::Entries(_) => true,
2305 trash::TrashItemSize::Bytes(_) => false,
2306 },
2307 Self::SimpleDir { .. } => true,
2308 Self::SimpleFile { .. } => false,
2309 #[cfg(feature = "gvfs")]
2310 Self::GvfsPath { children_opt, .. } => children_opt.is_some(),
2311 #[cfg(feature = "russh")]
2312 Self::RusshPath { children_opt, .. } => children_opt.is_some(),
2313 }
2314 }
2315
2316 pub fn modified(&self) -> Option<SystemTime> {
2317 match self {
2318 Self::Path { metadata, .. } => metadata.modified().ok(),
2319 #[cfg(feature = "gvfs")]
2320 Self::GvfsPath { mtime, .. } => {
2321 Some(SystemTime::UNIX_EPOCH + Duration::from_secs(*mtime))
2322 }
2323 #[cfg(feature = "russh")]
2324 Self::RusshPath { mtime, .. } => {
2325 Some(SystemTime::UNIX_EPOCH + Duration::from_secs(*mtime))
2326 }
2327 _ => None,
2328 }
2329 }
2330
2331 pub fn file_size(&self) -> Option<u64> {
2332 match self {
2333 Self::Path { metadata, .. } => (!metadata.is_dir()).then_some(metadata.len()),
2334 Self::Trash { metadata, .. } => match metadata.size {
2335 TrashItemSize::Bytes(size) => Some(size),
2336 TrashItemSize::Entries(_) => None,
2337 },
2338 #[cfg(feature = "gvfs")]
2339 Self::GvfsPath { size_opt, .. } => *size_opt,
2340 #[cfg(feature = "russh")]
2341 Self::RusshPath { size_opt, .. } => *size_opt,
2342 _ => None,
2343 }
2344 }
2345
2346 pub fn children_count(&self) -> Option<&usize> {
2347 match &self {
2348 ItemMetadata::Path { children_opt, .. } => children_opt.as_ref(),
2349 #[cfg(feature = "gvfs")]
2350 ItemMetadata::GvfsPath { children_opt, .. } => children_opt.as_ref(),
2351 #[cfg(feature = "russh")]
2352 ItemMetadata::RusshPath { children_opt, .. } => children_opt.as_ref(),
2353 _ => None,
2354 }
2355 }
2356
2357 pub fn is_tbprofiler_json(&self) -> bool {
2358 match self {
2359 Self::Path {
2360 tbprofilerjson_opt, ..
2361 } => tbprofilerjson_opt.is_some(),
2362 #[cfg(feature = "russh")]
2363 Self::RusshPath {
2364 is_tbprofiler_json, ..
2365 } => *is_tbprofiler_json,
2366 _ => false,
2367 }
2368 }
2369
2370 pub fn is_ab1(&self) -> bool {
2371 match self {
2372 Self::Path { is_ab1, .. } => *is_ab1,
2373 _ => false,
2374 }
2375 }
2376
2377 pub fn erm41position28_call(&self) -> Erm41Position28 {
2378 match self {
2379 Self::Path { sequence_opt, .. } => sequence_opt
2380 .as_ref()
2381 .and_then(|s| s.seq_id_hits.first()?.erm41_position_28_opt)
2382 .unwrap_or(Erm41Position28::Undetermined),
2383 _ => Erm41Position28::Undetermined,
2384 }
2385 }
2386
2387 pub fn is_erm41position28(&self) -> bool {
2388 !matches!(self.erm41position28_call(), Erm41Position28::Undetermined)
2389 }
2390
2391 pub fn ab1_chromatogram(&self) -> Option<&Ab1Channels> {
2392 match self {
2393 Self::Path { sequence_opt, .. } => sequence_opt.as_ref()?.chromatogram_opt.as_ref(),
2394 _ => None,
2395 }
2396 }
2397
2398 pub fn rrl_position_2058_2059_call(&self) -> RrlPosition2058_2059 {
2399 match self {
2400 Self::Path { sequence_opt, .. } => sequence_opt
2401 .as_ref()
2402 .and_then(|s| s.seq_id_hits.first()?.rrl_position_2058_2059_opt)
2403 .unwrap_or(RrlPosition2058_2059::Undetermined),
2404 _ => RrlPosition2058_2059::Undetermined,
2405 }
2406 }
2407
2408 pub fn is_rrl_position_2058_2059(&self) -> bool {
2409 !matches!(
2410 self.rrl_position_2058_2059_call(),
2411 RrlPosition2058_2059::Undetermined
2412 )
2413 }
2414
2415 pub fn seq_id_hits(&self) -> &[SeqIdHit] {
2416 match self {
2417 Self::Path { sequence_opt, .. } => sequence_opt
2418 .as_ref()
2419 .map(|s| s.seq_id_hits.as_slice())
2420 .unwrap_or(&[]),
2421 _ => &[],
2422 }
2423 }
2424
2425 pub fn is_seq_id(&self) -> bool {
2426 self.seq_id_hits()
2427 .first()
2428 .is_some_and(|h| h.identity >= crate::sequencing::MIN_SEQ_ID_IDENTITY)
2429 }
2430
2431 pub fn sequence_length(&self) -> Option<usize> {
2432 match self {
2433 Self::Path { sequence_opt, .. } => sequence_opt.as_ref().map(|s| s.length),
2434 _ => None,
2435 }
2436 }
2437
2438 pub fn sequence_length_trimmed(&self) -> Option<usize> {
2439 match self {
2440 Self::Path { sequence_opt, .. } => sequence_opt.as_ref().map(|s| s.trimmed_length),
2441 _ => None,
2442 }
2443 }
2444
2445 pub fn sequence_avg_quality_trimmed(&self) -> Option<f32> {
2446 match self {
2447 Self::Path { sequence_opt, .. } => sequence_opt.as_ref()?.trimmed_avg_quality_opt,
2448 _ => None,
2449 }
2450 }
2451
2452 pub fn is_abscessus_seq_id(&self) -> bool {
2453 match self {
2454 Self::Path { sequence_opt, .. } => sequence_opt
2455 .as_ref()
2456 .and_then(|s| s.seq_id_hits.first())
2457 .map(|h| h.description == "Mycobacteroides abscessus subsp. abscessus")
2458 .unwrap_or(false),
2459 _ => false,
2460 }
2461 }
2462
2463 pub fn is_bolletii_seq_id(&self) -> bool {
2464 match self {
2465 Self::Path { sequence_opt, .. } => sequence_opt
2466 .as_ref()
2467 .and_then(|s| s.seq_id_hits.first())
2468 .map(|h| h.description == "Mycobacteroides abscessus subsp. bolletii")
2469 .unwrap_or(false),
2470 _ => false,
2471 }
2472 }
2473
2474 pub fn is_massiliense_seq_id(&self) -> bool {
2475 match self {
2476 Self::Path { sequence_opt, .. } => sequence_opt
2477 .as_ref()
2478 .and_then(|s| s.seq_id_hits.first())
2479 .map(|h| h.description == "Mycobacteroides abscessus subsp. massiliense")
2480 .unwrap_or(false),
2481 _ => false,
2482 }
2483 }
2484
2485 pub fn set_json(&mut self, json: Option<TbProfilerJson>) {
2486 match self {
2487 Self::Path {
2488 tbprofilerjson_opt, ..
2489 } => *tbprofilerjson_opt = json,
2490 #[cfg(feature = "russh")]
2491 Self::RusshPath {
2492 tbprofilerjson_opt, ..
2493 } => *tbprofilerjson_opt = json,
2494 _ => {}
2495 }
2496 }
2497
2498 pub fn is_tbprofilerjson_opt(&self) -> bool {
2499 match self {
2500 Self::Path {
2501 tbprofilerjson_opt, ..
2502 } => tbprofilerjson_opt.is_some(),
2503 #[cfg(feature = "russh")]
2504 Self::RusshPath {
2505 tbprofilerjson_opt, ..
2506 } => tbprofilerjson_opt.is_some(),
2507 _ => false,
2508 }
2509 }
2510
2511 pub fn is_tbprofiler_result_as_sample(&self) -> bool {
2512 match self {
2513 Self::Path {
2514 is_tbprofiler_result_as_sample,
2515 ..
2516 } => *is_tbprofiler_result_as_sample,
2517 #[cfg(feature = "russh")]
2518 Self::RusshPath {
2519 is_tbprofiler_result_as_sample,
2520 ..
2521 } => *is_tbprofiler_result_as_sample,
2522 _ => false,
2523 }
2524 }
2525
2526 pub fn is_groupable_as_sample_tbprofiler_result_item(&self) -> bool {
2527 match self {
2528 Self::Path {
2529 is_tbprofiler_groupable_raw_result_file,
2530 ..
2531 } => *is_tbprofiler_groupable_raw_result_file,
2532 #[cfg(feature = "russh")]
2533 Self::RusshPath {
2534 is_tbprofiler_groupable_raw_result_file,
2535 ..
2536 } => *is_tbprofiler_groupable_raw_result_file,
2537 _ => false,
2538 }
2539 }
2540
2541 pub fn json_path(&self) -> Option<&PathBuf> {
2542 match self {
2543 Self::Path {
2544 sample_json_path_opt,
2545 ..
2546 } => sample_json_path_opt.as_ref(),
2547 #[cfg(feature = "russh")]
2548 Self::RusshPath {
2549 sample_json_path_opt,
2550 ..
2551 } => sample_json_path_opt.as_ref(),
2552 _ => None,
2553 }
2554 }
2555
2556 pub fn csv_path(&self) -> Option<&PathBuf> {
2557 match self {
2558 Self::Path {
2559 sample_csv_path_opt,
2560 ..
2561 } => sample_csv_path_opt.as_ref(),
2562 #[cfg(feature = "russh")]
2563 Self::RusshPath {
2564 sample_csv_path_opt,
2565 ..
2566 } => sample_csv_path_opt.as_ref(),
2567 _ => None,
2568 }
2569 }
2570
2571 pub fn docx_path(&self) -> Option<&PathBuf> {
2572 match self {
2573 Self::Path {
2574 sample_docx_path_opt,
2575 ..
2576 } => sample_docx_path_opt.as_ref(),
2577 #[cfg(feature = "russh")]
2578 Self::RusshPath {
2579 sample_docx_path_opt,
2580 ..
2581 } => sample_docx_path_opt.as_ref(),
2582 _ => None,
2583 }
2584 }
2585
2586 pub fn is_susceptible(&self) -> Option<bool> {
2587 match self {
2588 Self::Path { is_susceptible, .. } => *is_susceptible,
2589 #[cfg(feature = "russh")]
2590 Self::RusshPath { is_susceptible, .. } => *is_susceptible,
2591 _ => None,
2592 }
2593 }
2594
2595 pub fn susceptibility_calls(&self) -> SusceptibilityCalls {
2596 match self {
2597 Self::Path {
2598 susceptibility_calls,
2599 ..
2600 } => susceptibility_calls.clone(),
2601 _ => SusceptibilityCalls::default(),
2602 }
2603 }
2604}
2605
2606#[derive(Debug)]
2607pub enum ItemThumbnail {
2608 NotImage,
2609 Image(widget::image::Handle, Option<(u32, u32)>),
2610 Svg(widget::svg::Handle),
2611 Text(widget::text_editor::Content),
2612}
2613
2614impl Clone for ItemThumbnail {
2615 fn clone(&self) -> Self {
2616 match self {
2617 Self::NotImage => Self::NotImage,
2618 Self::Image(handle, size_opt) => Self::Image(handle.clone(), *size_opt),
2619 Self::Svg(handle) => Self::Svg(handle.clone()),
2620 Self::Text(content) => {
2622 Self::Text(widget::text_editor::Content::with_text(&content.text()))
2623 }
2624 }
2625 }
2626}
2627
2628impl ItemThumbnail {
2629 pub fn new(
2630 path: &Path,
2631 metadata: ItemMetadata,
2632 mime: mime::Mime,
2633 mut thumbnail_size: u32,
2634 max_mem: u64,
2635 jobs: usize,
2636 max_size_mb: u64,
2637 ) -> Self {
2638 let thumbnail_cacher =
2639 ThumbnailCacher::new(path, ThumbnailSize::from_pixel_size(thumbnail_size));
2640 match thumbnail_cacher.as_ref() {
2641 Ok(cache) => match cache.get_cached_thumbnail() {
2642 CachedThumbnail::Valid((thumbnail_path, size)) => {
2643 let original_dims = match image::image_dimensions(path) {
2646 Ok((width, height)) => Some((width, height)),
2647 Err(_) => size.map(|s| (s.pixel_size(), s.pixel_size())),
2648 };
2649
2650 return Self::Image(
2651 widget::image::Handle::from_path(thumbnail_path),
2652 original_dims,
2653 );
2654 }
2655 CachedThumbnail::Failed => {
2656 if mime.type_() != mime::IMAGE {
2657 return Self::NotImage;
2658 }
2659 }
2660 CachedThumbnail::RequiresUpdate(size) => {
2661 thumbnail_size = size.pixel_size();
2662 }
2663 },
2664 Err(err) => {
2665 log::warn!(
2666 "failed to create ThumbnailCache for {}: {}",
2667 path.display(),
2668 err
2669 );
2670 }
2671 }
2672
2673 let size = metadata.file_size().unwrap_or_default();
2674 let check_size = |thumbnailer: &str, max_size| {
2675 if size <= max_size {
2676 true
2677 } else {
2678 log::warn!(
2679 "skipping internal {} thumbnailer for {}: file size {} is larger than {}",
2680 thumbnailer,
2681 path.display(),
2682 format_size(size),
2683 format_size(max_size)
2684 );
2685 false
2686 }
2687 };
2688
2689 let mut tried_supported_file = false;
2690 if mime.type_() == mime::IMAGE && check_size("image", max_size_mb * 1000 * 1000) {
2692 let dimensions_ok = match image::image_dimensions(path) {
2695 Ok((width, height)) => {
2696 if exceeds_memory_limit(width, height, max_mem) {
2697 log::warn!(
2698 "skipping thumbnail generation for {}: {}x{} image would exceed {}MB memory budget",
2699 path.display(),
2700 width,
2701 height,
2702 max_mem
2703 );
2704 false
2705 } else {
2706 if should_use_tiling(width, height) {
2707 log::info!(
2708 "Large image {}x{} detected, will use GPU tiling for display",
2709 width,
2710 height
2711 );
2712 }
2713 true
2714 }
2715 }
2716 Err(err) => {
2717 log::debug!(
2718 "failed to read dimensions for {}: {}, will try decoding",
2719 path.display(),
2720 err
2721 );
2722 true }
2724 };
2725
2726 if !dimensions_ok {
2727 return Self::NotImage;
2729 }
2730
2731 tried_supported_file = true;
2732 let dyn_img = match image::ImageReader::open(path)
2733 .and_then(image::ImageReader::with_guessed_format)
2734 {
2735 Ok(mut reader) => {
2736 let mut limits = image::Limits::default();
2737 let max_ram = max_mem * 1000 * 1000 / jobs as u64;
2738 limits.max_alloc = Some(max_ram);
2739 reader.limits(limits);
2740 match reader.decode() {
2741 Ok(reader) => Some(reader),
2742 Err(err) => {
2743 log::warn!("failed to decode {}: {}", path.display(), err);
2744 None
2745 }
2746 }
2747 }
2748 Err(err) => {
2749 log::warn!("failed to read {}: {}", path.display(), err);
2750 None
2751 }
2752 };
2753
2754 if let Some(dyn_img) = dyn_img {
2755 let (img_width, img_height) = (dyn_img.width(), dyn_img.height());
2756
2757 if let Ok(cacher) = thumbnail_cacher.as_ref() {
2758 match cacher.update_with_image(dyn_img) {
2759 Ok(thumb_path) => {
2760 return Self::Image(
2761 widget::image::Handle::from_path(thumb_path),
2762 Some((img_width, img_height)),
2763 );
2764 }
2765 Err(err) => {
2766 log::warn!("cacher failed to decode {}: {}", path.display(), err);
2767 }
2768 }
2769 } else {
2770 let thumbnail = dyn_img
2772 .thumbnail(thumbnail_size, thumbnail_size)
2773 .into_rgba8();
2774 return Self::Image(
2775 widget::image::Handle::from_rgba(
2776 thumbnail.width(),
2777 thumbnail.height(),
2778 thumbnail.into_raw(),
2779 ),
2780 Some((img_width, img_height)),
2781 );
2782 }
2783 }
2784 }
2785
2786 let thumbnail_dir = thumbnail_cacher
2788 .as_ref()
2789 .ok()
2790 .map(ThumbnailCacher::thumbnail_dir);
2791 if let Some((item_thumbnail, temp_file)) =
2792 Self::generate_thumbnail_external(path, &mime, thumbnail_size, thumbnail_dir)
2793 {
2794 if let Ok(cache) = thumbnail_cacher
2795 && let Err(err) = cache.update_with_temp_file(temp_file)
2796 {
2797 log::warn!("failed to update cache for {}: {}", path.display(), err);
2798 }
2799 return item_thumbnail;
2800 }
2801
2802 tried_supported_file = tried_supported_file || !thumbnailer(&mime).is_empty();
2803
2804 if mime.type_() == mime::IMAGE
2807 && mime.subtype() == mime::SVG
2808 && check_size("svg", 8 * 1000 * 1000)
2809 {
2810 tried_supported_file = true;
2811 match fs::read(path) {
2813 Ok(data) => {
2814 return Self::Svg(widget::svg::Handle::from_memory(data));
2816 }
2817 Err(err) => {
2818 log::warn!("failed to read {}: {}", path.display(), err);
2819 }
2820 }
2821 } else if mime.type_() == mime::TEXT && check_size("text", TEXT_PREVIEW_MAX_FILE_BYTES) {
2822 tried_supported_file = true;
2823 if size > 0 {
2824 let read_cap = (size.min(TEXT_PREVIEW_MAX_BYTES as u64)) as usize;
2826 let mut buf = vec![0u8; read_cap];
2827 match File::open(path).and_then(|f| {
2828 let n = Read::read(&mut f.take(read_cap as u64), &mut buf)?;
2829 buf.truncate(n);
2830 Ok(())
2831 }) {
2832 Ok(()) => {
2833 let text = match std::str::from_utf8(&buf) {
2834 Ok(s) => s.to_string(),
2835 Err(e) => {
2836 std::str::from_utf8(&buf[..e.valid_up_to()])
2838 .unwrap_or("")
2839 .to_string()
2840 }
2841 };
2842 if !text.is_empty() {
2843 return Self::Text(widget::text_editor::Content::with_text(&text));
2844 }
2845 }
2846 Err(err) => {
2847 log::warn!("failed to read {}: {}", path.display(), err);
2848 }
2849 }
2850 }
2851 }
2853
2854 if let Ok(cacher) = thumbnail_cacher
2858 && tried_supported_file
2859 && let Err(err) = cacher.create_fail_marker()
2860 {
2861 log::warn!(
2862 "failed to create thumbnail fail marker for {}: {}",
2863 path.display(),
2864 err
2865 );
2866 }
2867
2868 Self::NotImage
2869 }
2870
2871 fn generate_thumbnail_external(
2872 path: &Path,
2873 mime: &mime::Mime,
2874 thumbnail_size: u32,
2875 thumbnail_dir: Option<&Path>,
2876 ) -> Option<(Self, NamedTempFile)> {
2877 for thumbnailer in thumbnailer(mime) {
2879 let is_evince = thumbnailer.exec.starts_with("evince-thumbnailer ");
2880 let prefix = if is_evince {
2881 "gnome-desktop-"
2883 } else {
2884 "cosmic-files-"
2885 };
2886
2887 let dir = if is_evince { None } else { thumbnail_dir };
2892 let file = match dir {
2893 Some(d) => tempfile::Builder::new().prefix(prefix).tempfile_in(d),
2894 None => tempfile::Builder::new().prefix(prefix).tempfile(),
2895 };
2896 let file = match file {
2897 Ok(ok) => ok,
2898 Err(err) => {
2899 log::warn!(
2900 "failed to create temporary file for thumbnail of {}: {}",
2901 path.display(),
2902 err
2903 );
2904 continue;
2905 }
2906 };
2907
2908 let Some(mut command) = thumbnailer.command(path, file.path(), thumbnail_size) else {
2909 continue;
2910 };
2911 match command.status() {
2912 Ok(status) => {
2913 if status.success() {
2914 match image::ImageReader::open(file.path())
2915 .and_then(ImageReader::with_guessed_format)
2916 {
2917 Ok(reader) => match reader.decode().map(DynamicImage::into_rgba8) {
2918 Ok(image) => {
2919 return Some((
2920 Self::Image(
2921 widget::image::Handle::from_rgba(
2922 image.width(),
2923 image.height(),
2924 image.into_raw(),
2925 ),
2926 None,
2927 ),
2928 file,
2929 ));
2930 }
2931 Err(err) => {
2932 log::warn!("failed to decode {}: {}", path.display(), err);
2933 }
2934 },
2935 Err(err) => {
2936 log::warn!("failed to read {}: {}", path.display(), err);
2937 }
2938 }
2939 } else {
2940 log::warn!(
2941 "failed to run {:?} for {}: {}",
2942 thumbnailer,
2943 path.display(),
2944 status
2945 );
2946 }
2947 }
2948 Err(err) => {
2949 log::warn!(
2950 "failed to run {thumbnailer:?} for {}: {}",
2951 path.display(),
2952 err
2953 );
2954 }
2955 }
2956 }
2957
2958 None
2959 }
2960}
2961
2962#[derive(Clone, Debug)]
2963pub struct Item {
2964 pub name: String,
2965 pub is_mount_point: bool,
2966 pub is_client_point: bool,
2967 pub display_name: String,
2968 pub metadata: ItemMetadata,
2969 pub hidden: bool,
2970 pub location_opt: Option<Location>,
2971 pub mime: Mime,
2972 pub image_dimensions: Option<(u32, u32)>,
2973 pub icon_handle_grid: widget::icon::Handle,
2974 pub icon_handle_list: widget::icon::Handle,
2975 pub icon_handle_list_condensed: widget::icon::Handle,
2976 pub thumbnail_opt: Option<ItemThumbnail>,
2977 pub button_id: widget::Id,
2978 pub pos_opt: Cell<Option<(usize, usize)>>,
2979 pub rect_opt: Cell<Option<Rectangle>>,
2980 pub selected: bool,
2981 pub highlighted: bool,
2982 pub cut: bool,
2983 pub overlaps_drag_rect: bool,
2984 pub dir_size: DirSize,
2985}
2986
2987impl Item {
2988 fn display_name(name: &str) -> String {
2989 name.replace('.', ".\u{200B}").replace('_', "_\u{200B}")
2991 }
2992
2993 fn grid_display_name<'a>(
2995 name: impl Into<Cow<'a, str>> + 'a,
2996 ) -> widget::Text<'a, cosmic::Theme, cosmic::Renderer> {
2997 widget::text::body(name)
2998 .wrapping(text::Wrapping::WordOrGlyph)
2999 .ellipsize(text::Ellipsize::Middle(text::EllipsizeHeightLimit::Lines(
3000 3,
3001 )))
3002 }
3003
3004 fn list_display_name<'a>(
3006 name: impl Into<Cow<'a, str>> + 'a,
3007 ) -> widget::Text<'a, cosmic::Theme, cosmic::Renderer> {
3008 widget::text::body(name)
3009 .wrapping(text::Wrapping::WordOrGlyph)
3010 .ellipsize(text::Ellipsize::Middle(text::EllipsizeHeightLimit::Lines(
3011 1,
3012 )))
3013 }
3014
3015 pub fn path_opt(&self) -> Option<&PathBuf> {
3016 self.location_opt.as_ref()?.path_opt()
3017 }
3018
3019 pub fn seq_id_hits_cached(&self) -> Vec<SeqIdHit> {
3022 let stored = self.metadata.seq_id_hits();
3023 if !stored.is_empty() {
3024 log::debug!("seq_id_hits_cached({}): {} stored hits", self.name, stored.len());
3025 return stored.to_vec();
3026 }
3027 let path = self.path_opt();
3028 let cache_size = crate::sequencing::batch::AB1_SEQ_CACHE.read().map(|g| g.len()).unwrap_or(0);
3029 let result = path
3030 .and_then(|p| {
3031 crate::sequencing::batch::AB1_SEQ_CACHE
3032 .read()
3033 .ok()
3034 .and_then(|guard| guard.get(p).cloned())
3035 })
3036 .unwrap_or_default();
3037 log::debug!("seq_id_hits_cached({}): path={:?}, cache_size={}, found {} hits", self.name, path, cache_size, result.len());
3038 result
3039 }
3040
3041 fn is_fasta(&self) -> bool {
3042 let lower = self.name.to_ascii_lowercase();
3043 lower.ends_with(".fasta") || lower.ends_with(".fa")
3044 }
3045
3046 pub fn is_erm41(&self) -> bool {
3047 let lower = self.name.to_ascii_lowercase();
3048 !self.is_fasta() && (lower.contains("erm41") || lower.contains("erm"))
3049 }
3050
3051 pub fn is_hsp65(&self) -> bool {
3052 let lower = self.name.to_ascii_lowercase();
3053 !self.is_fasta() && (lower.contains("hsp65") || lower.contains("65kda"))
3054 }
3055
3056 pub fn is_rpob(&self) -> bool {
3057 let lower = self.name.to_ascii_lowercase();
3058 !self.is_fasta()
3059 && (lower.contains("rpob") || lower.contains("2573f") || lower.contains("3337r"))
3060 }
3061
3062 pub fn is_16s(&self) -> bool {
3063 let lower = self.name.to_ascii_lowercase();
3064 !self.is_fasta() && lower.contains("mbak14")
3065 }
3066
3067 pub fn is_rrl_ntm(&self) -> bool {
3068 let lower = self.name.to_ascii_lowercase();
3069 !self.is_fasta() && (lower.contains("mclr") || lower.contains("rrl"))
3070 }
3071
3072 pub fn is_pnca(&self) -> bool {
3073 let lower = self.name.to_ascii_lowercase();
3074 !self.is_fasta() && lower.contains("pnca")
3075 }
3076
3077 pub fn can_gallery(&self) -> bool {
3078 self.mime.type_() == mime::IMAGE || self.mime.type_() == mime::TEXT
3079 }
3080
3081 pub fn file_metadata(&self) -> Option<Metadata> {
3082 match &self.metadata {
3083 ItemMetadata::Path { metadata, .. } => Some(metadata.clone()),
3084 #[cfg(feature = "gvfs")]
3085 ItemMetadata::GvfsPath { .. } => self.path_opt().and_then(|p| fs::metadata(p).ok()),
3086 #[cfg(feature = "russh")]
3087 ItemMetadata::RusshPath { .. } => None,
3088 _ => {
3089 None
3091 }
3092 }
3093 }
3094
3095 fn preview(&self) -> Element<'_, Message> {
3096 let spacing = cosmic::theme::spacing();
3097 let icon = widget::icon::icon(self.icon_handle_grid.clone())
3099 .content_fit(ContentFit::Contain)
3100 .size(IconSizes::default().grid())
3101 .into();
3102 match self
3103 .thumbnail_opt
3104 .as_ref()
3105 .unwrap_or(&ItemThumbnail::NotImage)
3106 {
3107 ItemThumbnail::NotImage => icon,
3108 ItemThumbnail::Image(handle, _original_dims) => {
3109 widget::image(handle.clone()).into()
3112 }
3113 ItemThumbnail::Svg(handle) => widget::svg(handle.clone()).into(),
3114 ItemThumbnail::Text(content) => widget::text_editor(content)
3115 .class(cosmic::theme::iced::TextEditor::Custom(Box::new(
3116 text_editor_class,
3117 )))
3118 .width(THUMBNAIL_SIZE as f32)
3119 .height(Length::Fixed(THUMBNAIL_SIZE as f32))
3120 .padding(spacing.space_xxs)
3121 .into(),
3122 }
3123 }
3124
3125 pub fn preview_actions(&self) -> Element<'_, Message> {
3126 let mut row = widget::row::with_capacity(3)
3127 .align_y(Alignment::Center)
3128 .spacing(theme::spacing().space_xxs)
3129 .push(
3130 widget::button::icon(widget::icon::from_name("go-previous-symbolic"))
3131 .on_press(Message::ItemLeft),
3132 )
3133 .push(
3134 widget::button::icon(widget::icon::from_name("go-next-symbolic"))
3135 .on_press(Message::ItemRight),
3136 );
3137 if self.can_gallery()
3138 && let Some(_path) = self.path_opt()
3139 {
3140 row = row.push(
3141 widget::button::icon(widget::icon::from_name("view-fullscreen-symbolic"))
3142 .on_press(Message::Gallery(true)),
3143 );
3144 }
3145 row.into()
3146 }
3147
3148 pub fn preview_view<'a>(
3149 &'a self,
3150 mime_app_cache_opt: Option<&'a mime_app::MimeAppCache>,
3151 military_time: bool,
3152 ) -> Element<'a, Message> {
3153 let cosmic_theme::Spacing {
3154 space_xxxs,
3155 space_m,
3156 ..
3157 } = theme::spacing();
3158
3159 let mut column = widget::column::with_capacity(4).spacing(space_m);
3160
3161 column = column.push(
3162 widget::container(self.preview())
3163 .center_x(Length::Fill)
3164 .max_height(THUMBNAIL_SIZE as f32),
3165 );
3166
3167 let mut details = widget::column::with_capacity(8).spacing(space_xxxs);
3168 details = details.push(widget::text::heading(self.name.clone()));
3169 details = details.push(widget::text::body(fl!(
3170 "type",
3171 mime = self.mime.to_string()
3172 )));
3173 let mut settings = Vec::new();
3174 if let Some(mime_app_cache) = mime_app_cache_opt {
3175 let mime_apps = mime_app_cache.get_apps_for_mime(&self.mime, false);
3176 if !mime_apps.is_empty() {
3177 let (names, icons) = mime_apps
3178 .iter()
3179 .map(|(app, _)| (Cow::Owned(app.name.clone()), app.icon()))
3180 .collect::<(Vec<_>, Vec<_>)>();
3181 settings.push(
3182 widget::settings::item::builder(fl!("open-with")).control(
3183 Element::from(
3184 widget::dropdown(
3185 names,
3186 mime_apps.iter().position(|(x, _)| x.is_default(&self.mime)),
3187 move |index| index,
3188 )
3189 .icons(Cow::Owned(icons)),
3190 )
3191 .map(move |index| {
3192 let mime_app = &mime_apps[index].0;
3193 Message::SetOpenWith(self.mime.clone(), mime_app.id.clone())
3194 }),
3195 ),
3196 );
3197 }
3198 }
3199
3200 if let Some(metadata) = self.file_metadata() {
3201 if metadata.is_dir() {
3202 if let Some(children) = self.metadata.children_count() {
3203 details = details.push(widget::text::body(fl!("items", items = children)));
3204 }
3205 let size = match &self.dir_size {
3206 DirSize::Calculating(_) => fl!("calculating"),
3207 DirSize::Directory(size) => format_size(*size),
3208 DirSize::NotDirectory => String::new(),
3209 DirSize::Error(err) => err.clone(),
3210 };
3211 if !size.is_empty() {
3212 details = details.push(widget::text::body(fl!("item-size", size = size)));
3213 }
3214 } else {
3215 details = details.push(widget::text::body(fl!(
3216 "item-size",
3217 size = format_size(metadata.len())
3218 )));
3219 }
3220
3221 let date_time_formatter = date_time_formatter(military_time);
3222 let time_formatter = time_formatter(military_time);
3223
3224 if let Ok(time) = metadata.created() {
3225 details = details.push(widget::text::body(fl!(
3226 "item-created",
3227 created = format_time(time, &date_time_formatter, &time_formatter).to_string()
3228 )));
3229 }
3230
3231 if let Ok(time) = metadata.modified() {
3232 details = details.push(widget::text::body(fl!(
3233 "item-modified",
3234 modified = format_time(time, &date_time_formatter, &time_formatter).to_string()
3235 )));
3236 }
3237
3238 if let Ok(time) = metadata.accessed() {
3239 details = details.push(widget::text::body(fl!(
3240 "item-accessed",
3241 accessed = format_time(time, &date_time_formatter, &time_formatter).to_string()
3242 )));
3243 }
3244
3245 #[cfg(unix)]
3246 if let Some(path) = self.path_opt() {
3247 use std::os::unix::fs::MetadataExt;
3248
3249 let mode = metadata.mode();
3250
3251 let user_name = uzers::get_user_by_uid(metadata.uid())
3252 .and_then(|user| user.name().to_str().map(ToOwned::to_owned))
3253 .unwrap_or_default();
3254 let user_path = path.clone();
3255 settings.push(
3256 widget::settings::item::builder(user_name)
3257 .description(fl!("owner"))
3258 .control(widget::dropdown(
3259 Cow::Borrowed(MODE_NAMES.as_slice()),
3260 Some(get_mode_part(mode, MODE_SHIFT_USER).try_into().unwrap()),
3261 move |selected| {
3262 Message::SetPermissions(
3263 user_path.clone(),
3264 set_mode_part(
3265 mode,
3266 MODE_SHIFT_USER,
3267 selected.try_into().unwrap(),
3268 ),
3269 )
3270 },
3271 )),
3272 );
3273
3274 let group_name = uzers::get_group_by_gid(metadata.gid())
3275 .and_then(|group| group.name().to_str().map(ToOwned::to_owned))
3276 .unwrap_or_default();
3277 let group_path = path.clone();
3278 settings.push(
3279 widget::settings::item::builder(group_name)
3280 .description(fl!("group"))
3281 .control(widget::dropdown(
3282 Cow::Borrowed(MODE_NAMES.as_slice()),
3283 Some(get_mode_part(mode, MODE_SHIFT_GROUP).try_into().unwrap()),
3284 move |selected| {
3285 Message::SetPermissions(
3286 group_path.clone(),
3287 set_mode_part(
3288 mode,
3289 MODE_SHIFT_GROUP,
3290 selected.try_into().unwrap(),
3291 ),
3292 )
3293 },
3294 )),
3295 );
3296
3297 let other_path = path.clone();
3298 settings.push(widget::settings::item::builder(fl!("other")).control(
3299 widget::dropdown(
3300 Cow::Borrowed(MODE_NAMES.as_slice()),
3301 Some(get_mode_part(mode, MODE_SHIFT_OTHER).try_into().unwrap()),
3302 move |selected| {
3303 Message::SetPermissions(
3304 other_path.clone(),
3305 set_mode_part(mode, MODE_SHIFT_OTHER, selected.try_into().unwrap()),
3306 )
3307 },
3308 ),
3309 ));
3310 }
3311 } else {
3312 match self.metadata {
3313 #[cfg(feature = "russh")]
3314 ItemMetadata::RusshPath { .. } => {
3315 if self.metadata.is_dir() {
3316 if let Some(children) = self.metadata.children_count() {
3317 details =
3318 details.push(widget::text::body(fl!("items", items = children)));
3319 }
3320 } else if let Some(size) = self.metadata.file_size() {
3321 details = details.push(widget::text::body(fl!(
3322 "item-size",
3323 size = format_size(size)
3324 )));
3325 }
3326 let date_time_formatter = date_time_formatter(military_time);
3327 let time_formatter = time_formatter(military_time);
3328 if let Some(time) = self.metadata.modified() {
3329 details = details.push(widget::text::body(fl!(
3330 "item-modified",
3331 modified = format_time(time, &date_time_formatter, &time_formatter)
3332 .to_string()
3333 )));
3334 }
3335 }
3336 _ => (),
3337 }
3338 }
3339
3340 match self.metadata {
3341 #[cfg(feature = "russh")]
3342 ItemMetadata::RusshPath { .. } => {
3343 column = column.push(details);
3344
3345 if self.metadata.is_dir() {
3346 if let Some(_path) = self.path_opt()
3347 && self.selected
3348 {
3349 column = column.push(
3350 widget::button::standard(fl!("open")).on_press(Message::Open(None)),
3351 );
3352 }
3353 } else if let Some(Location::Remote(uri, _user, path_opt)) =
3354 self.location_opt.clone()
3355 && self.selected
3356 && path_opt.is_some()
3357 {
3358 column = column.push(
3359 widget::button::standard(fl!("download"))
3360 .on_press(Message::Download(Some((path_opt.unwrap(), uri.clone())))),
3361 );
3362 }
3363 }
3364 _ => {
3365 if let Some(path) = self.path_opt()
3366 && let Ok(img) = image::image_dimensions(path)
3367 {
3368 let (width, height) = img;
3369 details = details.push(widget::text::body(format!("{width}x{height}")));
3370 }
3371 column = column.push(details);
3372
3373 if let Some(path) = self.path_opt()
3374 && self.selected
3375 {
3376 column = column.push(
3377 widget::button::standard(fl!("open"))
3378 .on_press(Message::Open(Some(path.clone()))),
3379 );
3380 }
3381 }
3382 }
3383
3384 if !settings.is_empty() {
3385 let mut section = widget::settings::section();
3386 section = section.extend(settings);
3387 column = column.push(section);
3388 }
3389
3390 column.into()
3391 }
3392
3393 pub fn preview_tbprofiler_json(&self) -> Element<'_, Message> {
3394 fn tb_variant_widget(v: &DrVariant) -> Element<'static, Message> {
3395 widget::container(
3396 widget::column::with_capacity(3)
3397 .push({
3398 let mut drug_col = widget::column::with_capacity(v.drugs.len());
3399 for drug in &v.drugs {
3400 drug_col = drug_col.push(widget::text::body(format!(
3401 "{}: {}",
3402 drug.drug, drug.confidence
3403 )));
3404 }
3405 drug_col
3406 })
3407 .push(widget::text::body(format!(
3408 "{} ({}): {}",
3409 v.gene_name, v.gene_id, v.change
3410 )))
3411 .push({
3412 let mut ecoli_col = widget::column::with_capacity(1);
3413 if let Some(ecoli_value) =
3414 TB_ECOLI_MAPPING.get(&(v.gene_name.clone(), v.change.clone()))
3415 {
3416 ecoli_col = ecoli_col
3417 .push(widget::text::body(format!("E. coli: {}", ecoli_value)));
3418 }
3419 ecoli_col
3420 }),
3421 )
3422 .into()
3423 }
3424 let cosmic_theme::Spacing {
3425 space_xxxs,
3426 space_m,
3427 ..
3428 } = theme::active().cosmic().spacing;
3429 let mut column = widget::column::with_capacity(10).spacing(space_m);
3430 let mut details = widget::column::with_capacity(1).spacing(space_xxxs);
3431 details = details.push(widget::text::heading(self.name.clone()));
3432
3433 let tbprofilerjson_opt = match &self.metadata {
3434 ItemMetadata::Path {
3435 tbprofilerjson_opt, ..
3436 } => tbprofilerjson_opt.as_ref(),
3437 #[cfg(feature = "russh")]
3438 ItemMetadata::RusshPath {
3439 tbprofilerjson_opt, ..
3440 } => tbprofilerjson_opt.as_ref(),
3441 _ => None,
3442 };
3443
3444 column = column.push(details);
3445
3446 #[cfg(feature = "russh")]
3447 if let ItemMetadata::RusshPath { .. } = &self.metadata
3448 && self.metadata.is_dir()
3449 {
3450 if self.selected {
3451 column = column
3452 .push(widget::button::standard(fl!("open")).on_press(Message::Open(None)));
3453 }
3454 return column.into();
3455 }
3456
3457 if let Some(json) = tbprofilerjson_opt {
3458 column = column.push(widget::text::heading(format!(
3459 "DB: {} ({})",
3460 json.pipeline.db_version.name, json.pipeline.db_version.commit
3461 )));
3462 let mut dr_variants = json.dr_variants.clone();
3463 dr_variants.sort_by_key(|v| v.highest_confidence_rank());
3464 for v in &dr_variants {
3465 column = column.push(tb_variant_widget(v));
3466 }
3467 }
3468
3469 if let ItemMetadata::Path { .. } = &self.metadata
3470 && self.metadata.is_tbprofiler_result_as_sample()
3471 {
3472 for (label, opt_path) in [
3473 ("Open .results.csv", self.metadata.csv_path()),
3474 ("Open .results.json", self.metadata.json_path()),
3475 ("Open .results.docx", self.metadata.docx_path()),
3476 ] {
3477 if let Some(p) = opt_path {
3478 column = column.push(
3479 widget::button::standard(label)
3480 .on_press(Message::Open(Some(p.to_path_buf()))),
3481 );
3482 }
3483 }
3484 }
3485
3486 #[cfg(feature = "russh")]
3487 if let ItemMetadata::RusshPath { .. } = &self.metadata {
3488 if let Some(Location::Remote(uri, _, Some(path))) = &self.location_opt {
3489 log::info!(
3490 "item is remote, showing download button: {}",
3491 path.display()
3492 );
3493 if !self.metadata.is_tbprofiler_result_as_sample() {
3494 column = column.push(
3495 widget::button::standard(fl!("download"))
3496 .on_press(Message::Download(Some((path.clone(), uri.clone())))),
3497 );
3498 }
3499 }
3500 if let Some(Location::Remote(uri, _, _)) = &self.location_opt
3501 && self.metadata.is_tbprofiler_result_as_sample()
3502 {
3503 for (label, opt_path) in [
3504 ("Download .results.csv", self.metadata.csv_path()),
3505 ("Download .results.json", self.metadata.json_path()),
3506 ("Download .results.docx", self.metadata.docx_path()),
3507 ] {
3508 if let Some(p) = opt_path {
3509 column = column.push(
3510 widget::button::standard(label)
3511 .on_press(Message::Download(Some((p.to_path_buf(), uri.clone())))),
3512 );
3513 }
3514 }
3515 }
3516 }
3517
3518 column.into()
3519 }
3520
3521 pub fn preview_erm41(&self) -> Element<'_, Message> {
3522 let cosmic_theme::Spacing {
3523 space_xxxs,
3524 space_m,
3525 ..
3526 } = theme::active().cosmic().spacing;
3527
3528 let mut column = widget::column::with_capacity(1).spacing(space_m);
3529 let mut details = widget::column::with_capacity(18).spacing(space_xxxs);
3530 details = details.push(widget::text::heading(self.name.clone()));
3531
3532 let hits = self.seq_id_hits_cached();
3533 if hits.is_empty()
3534 || self
3535 .metadata
3536 .sequence_length_trimmed()
3537 .is_some_and(|n| n < 100)
3538 || hits.first().is_none_or(|h| h.identity < crate::sequencing::MIN_SEQ_ID_IDENTITY)
3539 {
3540 details = details.push(widget::text::body(
3541 "Could not align sequence to references.",
3542 ));
3543 if let Some(sequence_length) = self.metadata.sequence_length() {
3544 details = details.push(widget::text::body(format!(
3545 "Sequence length: {}",
3546 sequence_length
3547 )));
3548 }
3549 if let Some(sequence_length_trimmed) = self.metadata.sequence_length_trimmed() {
3550 details = details.push(widget::text::body(format!(
3551 "Trimmed sequence length: {}",
3552 sequence_length_trimmed
3553 )));
3554 }
3555 if let Some(avg_qual) = self.metadata.sequence_avg_quality_trimmed() {
3556 details = details.push(widget::text::body(format!(
3557 "Average quality score: {:.1}",
3558 avg_qual
3559 )));
3560 }
3561 } else {
3562 let best = &hits[0];
3563 details = details.push(widget::text::body(format!(
3564 "Sequence identity to {}: {:.1}%",
3565 best.description, best.identity
3566 )));
3567 if let Some(sequence_length_trimmed) = self.metadata.sequence_length_trimmed() {
3568 details = details.push(widget::text::body(format!(
3569 "Trimmed sequence length: {}",
3570 sequence_length_trimmed
3571 )));
3572 }
3573 if let Some(avg_qual) = self.metadata.sequence_avg_quality_trimmed() {
3574 details = details.push(widget::text::body(format!(
3575 "Average quality score: {:.1}",
3576 avg_qual
3577 )));
3578 }
3579 details = details.push(widget::text::heading(""));
3580 details = details.push(widget::text::heading(
3581 "Percent identity to erm(41) references of abscessus subspecies:",
3582 ));
3583 for hit in &hits[..hits.len().min(3)] {
3584 details = details.push(
3585 widget::button::link(format!("{}: {:.1}%", hit.description, hit.identity,))
3586 .on_press(Message::OpenSeqAlignment(Box::new(hit.clone())))
3587 .padding(0),
3588 );
3589 }
3590 if self.metadata.is_abscessus_seq_id() || self.metadata.is_bolletii_seq_id() {
3591 let call = self.metadata.erm41position28_call();
3592 details = details.push(widget::text::body(""));
3593 details = details.push(widget::text::heading(format!("erm(41) {}", call)));
3594 }
3595 details = details.push(widget::text::body(""));
3596 {
3597 let snp_tags: Vec<String> = best
3598 .erm41_snp_calls
3599 .iter()
3600 .map(|s| s.call_tag())
3601 .filter(|t| !t.is_empty())
3602 .collect();
3603 details = details.push(widget::text::body("erm(41) loss-of-function SNPs:"));
3604 if snp_tags.is_empty() {
3605 details = details.push(widget::text::body("No erm(41) SNPs found."));
3606 } else {
3607 details = details.push(widget::text::body(format!(
3608 "(Using commit: {} of ntm-db repository)",
3609 env!("NTM_DB_COMMIT")
3610 )));
3611 for tag in snp_tags {
3612 details = details.push(widget::text::body(tag));
3613 }
3614 }
3615 }
3616 if let Some(chrom) = self
3617 .metadata
3618 .ab1_chromatogram()
3619 .filter(|c| c.erm41_view_state_opt.is_some())
3620 && !self.metadata.is_massiliense_seq_id()
3621 {
3622 let view_state = chrom.erm41_view_state_opt.unwrap();
3623 details = details.push(widget::text::body(""));
3624 details = details.push(widget::text::body(
3625 "Shown are bases 19-39, with position 28 in bold.",
3626 ));
3627 if view_state.is_reverse {
3628 details = details.push(widget::text::body("reverse complement"));
3629 }
3630 details = details.push(widget::text::body(""));
3631 let canvas = widget::Canvas::new(ChromatogramProgram {
3632 is_reverse: view_state.is_reverse,
3633 display_window: Some(view_state.window),
3634 highlighted_scans: chrom
3635 .peak_locs
3636 .get(view_state.pos28_base_idx as usize)
3637 .copied()
3638 .map(|v| vec![v])
3639 .unwrap_or_default(),
3640 chrom,
3641 })
3642 .width(Length::Fill)
3643 .height(Length::Fixed(200.0));
3644 details = details.push(canvas);
3645 details = details.push(widget::text::body(""));
3646 } else {
3647 details = details.push(widget::text::body(""));
3648 details = details.push(widget::text::body(
3649 "erm(41) chromatogram view is not available.",
3650 ));
3651 details = details.push(widget::text::body(""));
3652 }
3653 match self
3654 .metadata
3655 .susceptibility_calls()
3656 .erm41
3657 .position_28
3658 .and_then(|p| p.is_susceptible())
3659 {
3660 Some(true) => {
3661 details = details.push(widget::text::heading(
3662 "Predicted to be susceptible to macrolides.",
3663 ))
3664 }
3665 Some(false) => {
3666 details = details.push(widget::text::heading(
3667 "Predicted inducible macrolide resistance.",
3668 ))
3669 }
3670 None => {}
3671 }
3672 }
3673
3674 column = column.push(details);
3675 column.into()
3676 }
3677
3678 pub fn preview_hsp65(&self) -> Element<'_, Message> {
3679 let cosmic_theme::Spacing {
3680 space_xxxs,
3681 space_m,
3682 ..
3683 } = theme::active().cosmic().spacing;
3684
3685 let mut column = widget::column::with_capacity(1).spacing(space_m);
3686 let mut details = widget::column::with_capacity(10).spacing(space_xxxs);
3687 details = details.push(widget::text::heading(self.name.clone()));
3688
3689 let hits = self.seq_id_hits_cached();
3690 if hits.is_empty()
3691 || self
3692 .metadata
3693 .sequence_length_trimmed()
3694 .is_some_and(|n| n < 100)
3695 || hits.first().is_none_or(|h| h.identity < crate::sequencing::MIN_SEQ_ID_IDENTITY)
3696 {
3697 details = details.push(widget::text::body(
3698 "Could not align sequence to references.",
3699 ));
3700 if let Some(sequence_length) = self.metadata.sequence_length() {
3701 details = details.push(widget::text::body(format!(
3702 "Sequence length: {}",
3703 sequence_length
3704 )));
3705 }
3706 if let Some(sequence_length_trimmed) = self.metadata.sequence_length_trimmed() {
3707 details = details.push(widget::text::body(format!(
3708 "Trimmed sequence length: {}",
3709 sequence_length_trimmed
3710 )));
3711 }
3712 if let Some(avg_qual) = self.metadata.sequence_avg_quality_trimmed() {
3713 details = details.push(widget::text::body(format!(
3714 "Average quality score: {:.1}",
3715 avg_qual
3716 )));
3717 }
3718 } else {
3719 let best = &hits[0];
3720 details = details.push(widget::text::body(format!(
3721 "Sequence identity to {}: {:.1}%",
3722 best.description, best.identity
3723 )));
3724 if let Some(sequence_length_trimmed) = self.metadata.sequence_length_trimmed() {
3725 details = details.push(widget::text::body(format!(
3726 "Trimmed sequence length: {}",
3727 sequence_length_trimmed
3728 )));
3729 }
3730 if let Some(avg_qual) = self.metadata.sequence_avg_quality_trimmed() {
3731 details = details.push(widget::text::body(format!(
3732 "Average quality score: {:.1}",
3733 avg_qual
3734 )));
3735 }
3736 details = details.push(widget::text::heading(""));
3737 details = details.push(widget::text::heading(
3738 "Species identification (hsp65 database):",
3739 ));
3740 for hit in &hits[..hits.len().min(3)] {
3741 details = details.push(
3742 widget::button::link(format!("{} ({:.1}%)", hit.description, hit.identity))
3743 .on_press(Message::OpenSeqAlignment(Box::new(hit.clone())))
3744 .padding(0),
3745 );
3746 }
3747
3748 details = details.push(widget::text::body(""));
3749 if best.is_kansasii() || best.is_gastri() {
3750 let gastri_n = best
3751 .kansasii_gastri_snp_calls
3752 .iter()
3753 .filter(|c| c.is_gastri())
3754 .count();
3755 let kansasii_n = best
3756 .kansasii_gastri_snp_calls
3757 .iter()
3758 .filter(|c| c.is_kansasii())
3759 .count();
3760 let total = best.kansasii_gastri_snp_calls.len();
3761 let species_call = best
3762 .kansasii_gastri_snp_species_call()
3763 .unwrap_or("Ambiguous");
3764 details = details.push(widget::text::body("hsp65 kansasii/gastri SNP call:"));
3765 details = details.push(widget::text::body(format!(
3766 "{} (M. gastri {}/{}, M. kansasii {}/{})",
3767 species_call, gastri_n, total, kansasii_n, total,
3768 )));
3769 for snp in &best.kansasii_gastri_snp_calls {
3770 details = details.push(widget::text::body(format!(
3771 " pos {}: {} = {}",
3772 snp.ref_pos + 1,
3773 snp.query_base as char,
3774 snp.species_tag(),
3775 )));
3776 }
3777 details = details.push(widget::text::body(""));
3778 }
3779 if best.is_marinum() || best.is_ulcerans() {
3780 let ulcerans_n = best
3781 .marinum_ulcerans_snp_calls
3782 .iter()
3783 .filter(|c| c.is_ulcerans())
3784 .count();
3785 let marinum_n = best
3786 .marinum_ulcerans_snp_calls
3787 .iter()
3788 .filter(|c| c.is_marinum())
3789 .count();
3790 let total = best.marinum_ulcerans_snp_calls.len();
3791 let species_call = best
3792 .marinum_ulcerans_snp_species_call()
3793 .unwrap_or("Ambiguous");
3794 details = details.push(widget::text::body("hsp65 marinum/ulcerans SNP call:"));
3795 details = details.push(widget::text::body(format!(
3796 "{} (M. ulcerans {}/{}, M. marinum {}/{})",
3797 species_call, ulcerans_n, total, marinum_n, total,
3798 )));
3799 for snp in &best.marinum_ulcerans_snp_calls {
3800 details = details.push(widget::text::body(format!(
3801 " pos {}: {} = {}",
3802 snp.ref_pos + 1,
3803 snp.query_base as char,
3804 snp.species_tag(),
3805 )));
3806 }
3807 details = details.push(widget::text::body(""));
3808 }
3809 }
3810
3811 column = column.push(details);
3812 column.into()
3813 }
3814
3815 pub fn preview_rpob(&self) -> Element<'_, Message> {
3816 let cosmic_theme::Spacing {
3817 space_xxxs,
3818 space_m,
3819 ..
3820 } = theme::active().cosmic().spacing;
3821
3822 let mut column = widget::column::with_capacity(1).spacing(space_m);
3823 let mut details = widget::column::with_capacity(6).spacing(space_xxxs);
3824 details = details.push(widget::text::heading(self.name.clone()));
3825
3826 let hits = self.seq_id_hits_cached();
3827 if hits.is_empty()
3828 || self
3829 .metadata
3830 .sequence_length_trimmed()
3831 .is_some_and(|n| n < 100)
3832 || hits.first().is_none_or(|h| h.identity < crate::sequencing::MIN_SEQ_ID_IDENTITY)
3833 {
3834 details = details.push(widget::text::body(
3835 "Could not align sequence to references.",
3836 ));
3837 if let Some(sequence_length) = self.metadata.sequence_length() {
3838 details = details.push(widget::text::body(format!(
3839 "Sequence length: {}",
3840 sequence_length
3841 )));
3842 }
3843 if let Some(sequence_length_trimmed) = self.metadata.sequence_length_trimmed() {
3844 details = details.push(widget::text::body(format!(
3845 "Trimmed sequence length: {}",
3846 sequence_length_trimmed
3847 )));
3848 }
3849 if let Some(avg_qual) = self.metadata.sequence_avg_quality_trimmed() {
3850 details = details.push(widget::text::body(format!(
3851 "Average quality score: {:.1}",
3852 avg_qual
3853 )));
3854 }
3855 } else {
3856 let best = &hits[0];
3857 details = details.push(widget::text::body(format!(
3858 "Sequence identity to {}: {:.1}%",
3859 best.description, best.identity
3860 )));
3861 if let Some(sequence_length_trimmed) = self.metadata.sequence_length_trimmed() {
3862 details = details.push(widget::text::body(format!(
3863 "Trimmed sequence length: {}",
3864 sequence_length_trimmed
3865 )));
3866 }
3867 if let Some(avg_qual) = self.metadata.sequence_avg_quality_trimmed() {
3868 details = details.push(widget::text::body(format!(
3869 "Average quality score: {:.1}",
3870 avg_qual
3871 )));
3872 }
3873 details = details.push(widget::text::heading(""));
3874 details = details.push(widget::text::heading(
3875 "Species identification (rpoB database):",
3876 ));
3877 for hit in &hits[..hits.len().min(3)] {
3878 details = details.push(
3879 widget::button::link(format!("{} ({:.1}%)", hit.description, hit.identity))
3880 .on_press(Message::OpenSeqAlignment(Box::new(hit.clone())))
3881 .padding(0),
3882 );
3883 }
3884 }
3885
3886 column = column.push(details);
3887 column.into()
3888 }
3889
3890 pub fn preview_16s(&self) -> Element<'_, Message> {
3891 let cosmic_theme::Spacing {
3892 space_xxxs,
3893 space_m,
3894 ..
3895 } = theme::active().cosmic().spacing;
3896
3897 let mut column = widget::column::with_capacity(1).spacing(space_m);
3898 let mut details = widget::column::with_capacity(6).spacing(space_xxxs);
3899 details = details.push(widget::text::heading(self.name.clone()));
3900
3901 let hits = self.seq_id_hits_cached();
3902 if hits.is_empty()
3903 || self
3904 .metadata
3905 .sequence_length_trimmed()
3906 .is_some_and(|n| n < 100)
3907 || hits.first().is_none_or(|h| h.identity < crate::sequencing::MIN_SEQ_ID_IDENTITY)
3908 {
3909 details = details.push(widget::text::body(
3910 "Could not align sequence to references.",
3911 ));
3912 if let Some(sequence_length) = self.metadata.sequence_length() {
3913 details = details.push(widget::text::body(format!(
3914 "Sequence length: {}",
3915 sequence_length
3916 )));
3917 }
3918 if let Some(sequence_length_trimmed) = self.metadata.sequence_length_trimmed() {
3919 details = details.push(widget::text::body(format!(
3920 "Trimmed sequence length: {}",
3921 sequence_length_trimmed
3922 )));
3923 }
3924 if let Some(avg_qual) = self.metadata.sequence_avg_quality_trimmed() {
3925 details = details.push(widget::text::body(format!(
3926 "Average quality score: {:.1}",
3927 avg_qual
3928 )));
3929 }
3930 } else {
3931 let best = &hits[0];
3932 let best_snp_hit = hits
3933 .iter()
3934 .find(|h| h.description == best.description && !h.rrs_snp_calls.is_empty());
3935 details = details.push(widget::text::body(format!(
3936 "Sequence identity to {}: {:.1}%",
3937 best.description, best.identity
3938 )));
3939 if let Some(sequence_length_trimmed) = self.metadata.sequence_length_trimmed() {
3940 details = details.push(widget::text::body(format!(
3941 "Trimmed sequence length: {}",
3942 sequence_length_trimmed
3943 )));
3944 }
3945 if let Some(avg_qual) = self.metadata.sequence_avg_quality_trimmed() {
3946 details = details.push(widget::text::body(format!(
3947 "Average quality score: {:.1}",
3948 avg_qual
3949 )));
3950 }
3951 details = details.push(widget::text::heading(""));
3952 details = details.push(widget::text::heading(
3953 "Species identification (16S database):",
3954 ));
3955 for hit in &hits[..hits.len().min(8)] {
3956 details = details.push(
3957 widget::button::link(format!("{} ({:.1}%)", hit.description, hit.identity))
3958 .on_press(Message::OpenSeqAlignment(Box::new(hit.clone())))
3959 .padding(0),
3960 );
3961 }
3962 if let Some(snp_hit) = best_snp_hit {
3963 details = details.push(widget::text::body(""));
3964 details = details.push(widget::text::body(
3965 "16S rRNA amikacin resistance SNPs (rrs):",
3966 ));
3967 details = details.push(widget::text::body(format!(
3968 "(Using commit: {} of ntm-db repository)",
3969 env!("NTM_DB_COMMIT")
3970 )));
3971 for snp in &snp_hit.rrs_snp_calls {
3972 details = details.push(widget::text::body(format!(
3973 " pos {}: {}",
3974 snp.ref_pos + 1,
3975 snp.call_tag()
3976 )));
3977 }
3978 }
3979 }
3980
3981 column = column.push(details);
3982 column.into()
3983 }
3984
3985 pub fn preview_rrl_ntm(&self) -> Element<'_, Message> {
3986 let cosmic_theme::Spacing {
3987 space_xxxs,
3988 space_m,
3989 ..
3990 } = theme::active().cosmic().spacing;
3991
3992 let mut column = widget::column::with_capacity(1).spacing(space_m);
3993 let mut details = widget::column::with_capacity(10).spacing(space_xxxs);
3994 details = details.push(widget::text::heading(self.name.clone()));
3995
3996 let hits = self.seq_id_hits_cached();
3997 if hits.is_empty()
3998 || self
3999 .metadata
4000 .sequence_length_trimmed()
4001 .is_some_and(|n| n < 100)
4002 || hits.first().is_none_or(|h| h.identity < crate::sequencing::MIN_SEQ_ID_IDENTITY)
4003 {
4004 details = details.push(widget::text::body(
4005 "Could not align sequence to references.",
4006 ));
4007 if let Some(sequence_length) = self.metadata.sequence_length() {
4008 details = details.push(widget::text::body(format!(
4009 "Sequence length: {}",
4010 sequence_length
4011 )));
4012 }
4013 if let Some(sequence_length_trimmed) = self.metadata.sequence_length_trimmed() {
4014 details = details.push(widget::text::body(format!(
4015 "Trimmed sequence length: {}",
4016 sequence_length_trimmed
4017 )));
4018 }
4019 if let Some(avg_qual) = self.metadata.sequence_avg_quality_trimmed() {
4020 details = details.push(widget::text::body(format!(
4021 "Average quality score: {:.1}",
4022 avg_qual
4023 )));
4024 }
4025 } else {
4026 let best = &hits[0];
4027 let best_snp_hit = hits
4031 .iter()
4032 .find(|h| h.description == best.description && !h.rrl_snp_calls.is_empty());
4033 details = details.push(widget::text::body(format!(
4034 "Sequence identity to {}: {:.1}%",
4035 best.description, best.identity
4036 )));
4037 if let Some(sequence_length_trimmed) = self.metadata.sequence_length_trimmed() {
4038 details = details.push(widget::text::body(format!(
4039 "Trimmed sequence length: {}",
4040 sequence_length_trimmed
4041 )));
4042 }
4043 if let Some(avg_qual) = self.metadata.sequence_avg_quality_trimmed() {
4044 details = details.push(widget::text::body(format!(
4045 "Average quality score: {:.1}",
4046 avg_qual
4047 )));
4048 }
4049 details = details.push(widget::text::heading(""));
4050 details = details.push(widget::text::heading(
4051 "Species identification (rrl database):",
4052 ));
4053 for hit in &hits[..hits.len().min(3)] {
4054 details = details.push(
4055 widget::button::link(format!("{} ({:.1}%)", hit.description, hit.identity))
4056 .on_press(Message::OpenSeqAlignment(Box::new(hit.clone())))
4057 .padding(0),
4058 );
4059 }
4060 details = details.push(widget::text::body(""));
4061 if let Some(snp_hit) = best_snp_hit {
4062 details = details.push(widget::text::body(
4063 "23S rRNA macrolide resistance SNPs (rrl):",
4064 ));
4065 details = details.push(widget::text::body(format!(
4066 "(Using commit: {} of ntm-db repository)",
4067 env!("NTM_DB_COMMIT")
4068 )));
4069 for snp in &snp_hit.rrl_snp_calls {
4070 details = details.push(widget::text::body(format!(
4071 " pos {}: {}",
4072 snp.ref_pos + 1,
4073 snp.call_tag()
4074 )));
4075 }
4076 }
4077 if let Some(chrom) = self
4078 .metadata
4079 .ab1_chromatogram()
4080 .filter(|c| c.rrl_ntm_view_state_opt.is_some())
4081 {
4082 let view_state = chrom.rrl_ntm_view_state_opt.unwrap();
4083 details = details.push(widget::text::body(""));
4084 details = details.push(widget::text::body(
4085 "Shown in bold are bases coresponding to E. coli positions 2058/2059",
4086 ));
4087 if view_state.is_reverse {
4088 details = details.push(widget::text::body("reverse complement"));
4089 }
4090 details = details.push(widget::text::body(""));
4091 let canvas = widget::Canvas::new(ChromatogramProgram {
4092 is_reverse: view_state.is_reverse,
4093 display_window: Some(view_state.window),
4094 highlighted_scans: {
4095 let idx = view_state.snp_base_idx as usize;
4096 [idx, idx + 1]
4097 .iter()
4098 .filter_map(|&i| chrom.peak_locs.get(i).copied())
4099 .collect()
4100 },
4101 chrom,
4102 })
4103 .width(Length::Fill)
4104 .height(Length::Fixed(200.0));
4105 details = details.push(canvas);
4106 details = details.push(widget::text::body(""));
4107 } else {
4108 details = details.push(widget::text::body(""));
4109 details = details.push(widget::text::body(
4110 "rrl chromatogram view is not available.",
4111 ));
4112 details = details.push(widget::text::body(""));
4113 }
4114 match self
4115 .metadata
4116 .susceptibility_calls()
4117 .rrl
4118 .position_2058_2059
4119 .and_then(|p| p.is_susceptible())
4120 {
4121 Some(true) => {
4122 details = details.push(widget::text::heading(
4123 "E. coli positions A2058 and A2059 are wt (macrolide susceptible).",
4124 ))
4125 }
4126 Some(false) => {
4127 details = details.push(widget::text::heading(
4128 "Mutation in E. coli A2058 and/or A2059. Predicted macrolide resistance.",
4129 ))
4130 }
4131 None => {}
4132 }
4133 }
4134
4135 column = column.push(details);
4136 column.into()
4137 }
4138
4139 pub fn preview_pnca(&self) -> Element<'_, Message> {
4140 let cosmic_theme::Spacing {
4141 space_xxxs,
4142 space_m,
4143 ..
4144 } = theme::active().cosmic().spacing;
4145
4146 let mut column = widget::column::with_capacity(1).spacing(space_m);
4147 let mut details = widget::column::with_capacity(6).spacing(space_xxxs);
4148 details = details.push(widget::text::heading(self.name.clone()));
4149
4150 let hits = self.seq_id_hits_cached();
4151 if hits.is_empty()
4152 || self
4153 .metadata
4154 .sequence_length_trimmed()
4155 .is_some_and(|n| n < 100)
4156 || hits.first().is_none_or(|h| h.identity < crate::sequencing::MIN_SEQ_ID_IDENTITY)
4157 {
4158 details = details.push(widget::text::body(
4159 "Could not align sequence to the pncA reference.",
4160 ));
4161 if let Some(sequence_length) = self.metadata.sequence_length() {
4162 details = details.push(widget::text::body(format!(
4163 "Sequence length: {}",
4164 sequence_length
4165 )));
4166 }
4167 if let Some(sequence_length_trimmed) = self.metadata.sequence_length_trimmed() {
4168 details = details.push(widget::text::body(format!(
4169 "Trimmed sequence length: {}",
4170 sequence_length_trimmed
4171 )));
4172 }
4173 if let Some(avg_qual) = self.metadata.sequence_avg_quality_trimmed() {
4174 details = details.push(widget::text::body(format!(
4175 "Average quality score: {:.1}",
4176 avg_qual
4177 )));
4178 }
4179 } else {
4180 let best = &hits[0];
4181 details = details.push(widget::text::body(format!(
4182 "Sequence identity to {}: {:.1}%",
4183 best.description, best.identity
4184 )));
4185 if let Some(sequence_length_trimmed) = self.metadata.sequence_length_trimmed() {
4186 details = details.push(widget::text::body(format!(
4187 "Trimmed sequence length: {}",
4188 sequence_length_trimmed
4189 )));
4190 }
4191 if let Some(avg_qual) = self.metadata.sequence_avg_quality_trimmed() {
4192 details = details.push(widget::text::body(format!(
4193 "Average quality score: {:.1}",
4194 avg_qual
4195 )));
4196 }
4197 details = details.push(
4198 widget::button::link(format!("View alignment ({:.1}%)", best.identity))
4199 .on_press(Message::OpenSeqAlignment(Box::new(best.clone())))
4200 .padding(0),
4201 );
4202
4203 let pnca = &self.metadata.susceptibility_calls().pnca;
4204 let called: Vec<_> = pnca
4205 .snp_calls
4206 .iter()
4207 .filter(|c| !c.call_tag().is_empty())
4208 .collect();
4209 details = details.push(widget::text::body(""));
4210 details = details.push(widget::text::heading(
4211 "Pyrazinamide resistance calls (pncA, WHO mutation catalogue):",
4212 ));
4213 if called.is_empty() {
4214 details = details.push(widget::text::body("No catalogued sites covered."));
4215 } else {
4216 for snp in &called {
4217 details = details.push(widget::text::body(format!(
4218 " {}: {}",
4219 snp.site_label(),
4220 snp.call_tag()
4221 )));
4222 }
4223 }
4224 match pnca.is_susceptible {
4225 Some(true) => {
4226 details = details.push(widget::text::heading(
4227 "No pncA mutation at or above WHO-catalogue resistance confidence. Predicted pyrazinamide susceptible.",
4228 ))
4229 }
4230 Some(false) => {
4231 details = details.push(widget::text::heading(
4232 "pncA mutation meets WHO-catalogue resistance confidence. Predicted pyrazinamide resistance.",
4233 ))
4234 }
4235 None => {}
4236 }
4237 }
4238
4239 column = column.push(details);
4240 column.into()
4241 }
4242
4243 pub fn replace_view(&self, heading: String, military_time: bool) -> Element<'_, Message> {
4244 let cosmic_theme::Spacing { space_xxxs, .. } = theme::spacing();
4245
4246 let mut row = widget::row::with_capacity(2).spacing(space_xxxs);
4247 row = row.push(self.preview());
4248
4249 let mut column = widget::column::with_capacity(3).spacing(space_xxxs);
4250 column = column.push(widget::text::heading(heading));
4251
4252 if let ItemMetadata::Path {
4255 metadata,
4256 children_opt,
4257 ..
4258 } = &self.metadata
4259 {
4260 if metadata.is_dir() {
4261 if let Some(children) = children_opt {
4262 column = column.push(widget::text::body(format!("Items: {children}")));
4263 }
4264 } else {
4265 column = column.push(widget::text::body(format!(
4266 "Size: {}",
4267 format_size(metadata.len())
4268 )));
4269 }
4270 if let Ok(time) = metadata.modified() {
4271 let date_time_formatter = date_time_formatter(military_time);
4272 let time_formatter = time_formatter(military_time);
4273
4274 column = column.push(widget::text::body(format!(
4275 "Last modified: {}",
4276 format_time(time, &date_time_formatter, &time_formatter)
4277 )));
4278 }
4279 } else {
4280 }
4282
4283 row = row.push(column);
4284 row.into()
4285 }
4286}
4287
4288struct ChromatogramProgram<'a> {
4294 chrom: &'a crate::sequencing::Ab1Channels,
4295 is_reverse: bool,
4296 display_window: Option<(u16, u16)>,
4297 highlighted_scans: Vec<u16>,
4298}
4299
4300impl<'a> widget::canvas::Program<Message, cosmic::Theme, cosmic::Renderer>
4301 for ChromatogramProgram<'a>
4302{
4303 type State = ();
4304
4305 fn draw(
4306 &self,
4307 _state: &Self::State,
4308 renderer: &cosmic::Renderer,
4309 _theme: &cosmic::Theme,
4310 bounds: Rectangle,
4311 _cursor: cosmic::iced::mouse::Cursor,
4312 ) -> Vec<widget::canvas::Geometry<cosmic::Renderer>> {
4313 use cosmic::iced::alignment;
4314 use widget::canvas::{Frame, Path, Stroke};
4315
4316 let chrom = self.chrom;
4317 let is_rev = self.is_reverse;
4318
4319 let dna_complement = |b: u8| -> u8 {
4324 match b.to_ascii_uppercase() {
4325 b'A' => b'T',
4326 b'T' => b'A',
4327 b'C' => b'G',
4328 b'G' => b'C',
4329 _ => b'N',
4330 }
4331 };
4332 let base_color = |base: u8| -> Color {
4333 match base.to_ascii_uppercase() {
4334 b'A' => Color::from_rgb(0.0, 0.7, 0.0),
4335 b'C' => Color::from_rgb(0.0, 0.0, 1.0),
4336 b'G' => Color::from_rgb(0.8, 0.55, 0.0),
4337 b'T' => Color::from_rgb(1.0, 0.0, 0.0),
4338 _ => Color::from_rgb(0.5, 0.5, 0.5),
4339 }
4340 };
4341
4342 let total_scans: u16 = chrom
4345 .channels
4346 .iter()
4347 .map(|c| c.len() as u16)
4348 .max()
4349 .unwrap_or(1);
4350 if total_scans == 0 {
4351 return vec![];
4352 }
4353 let (scan_start, scan_end) = self
4354 .display_window
4355 .unwrap_or((0, total_scans.saturating_sub(1)));
4356 let scan_end = scan_end.min(total_scans.saturating_sub(1));
4357 let window_len = (scan_end + 1).saturating_sub(scan_start).max(1);
4358
4359 let mut y_min = i16::MAX;
4361 let mut y_max = i16::MIN;
4362 for channel in &chrom.channels {
4363 for &v in &channel
4364 [(scan_start as usize).min(channel.len())..(scan_end as usize).min(channel.len())]
4365 {
4366 if v < y_min {
4367 y_min = v;
4368 }
4369 if v > y_max {
4370 y_max = v;
4371 }
4372 }
4373 }
4374 if y_min == i16::MAX {
4375 y_min = 0;
4376 y_max = 1;
4377 }
4378 if y_max == y_min {
4379 y_max = y_min + 1;
4380 }
4381 let y_range = (y_max - y_min) as f32;
4382
4383 let top_margin = 18.0_f32;
4385 let plot_h = (bounds.height - top_margin).max(1.0);
4386 let plot_w = bounds.width;
4387
4388 let scan_to_x = |scan: usize| -> f32 {
4392 let t = (scan.saturating_sub(scan_start as usize)) as f32
4393 / ((window_len as usize) - 1).max(1) as f32;
4394 if is_rev {
4395 (1.0 - t) * plot_w
4396 } else {
4397 t * plot_w
4398 }
4399 };
4400 let intensity_to_y =
4401 |v: i16| -> f32 { top_margin + (1.0 - (v - y_min) as f32 / y_range) * plot_h };
4402
4403 let mut frame = Frame::new(renderer, bounds.size());
4404
4405 for (ch_idx, channel) in chrom.channels.iter().enumerate() {
4409 if channel.is_empty() {
4410 continue;
4411 }
4412 let raw_base = chrom.base_order[ch_idx];
4413 let display_base = if is_rev {
4414 dna_complement(raw_base)
4415 } else {
4416 raw_base
4417 };
4418 let color = base_color(display_base);
4419
4420 let slice_end = (scan_end + 1).min(channel.len() as u16);
4421 let slice_start = scan_start.min(slice_end);
4422 if slice_start >= slice_end {
4423 continue;
4424 }
4425 let window_slice = &channel[slice_start as usize..slice_end as usize];
4426
4427 let path = Path::new(|builder| {
4428 builder.move_to(Point {
4429 x: scan_to_x(slice_start as usize),
4430 y: intensity_to_y(window_slice[0]),
4431 });
4432 for (i, &v) in window_slice.iter().enumerate().skip(1) {
4433 builder.line_to(Point {
4434 x: scan_to_x(slice_start as usize + i),
4435 y: intensity_to_y(v),
4436 });
4437 }
4438 });
4439
4440 frame.stroke(&path, Stroke::default().with_color(color).with_width(1.2));
4441 }
4442
4443 for (base, &peak) in chrom.bases.iter().zip(chrom.peak_locs.iter()) {
4448 let scan = peak;
4449 if scan < scan_start || scan > scan_end {
4450 continue;
4451 }
4452 let display_base = if is_rev { dna_complement(*base) } else { *base };
4453 let x = scan_to_x(scan as usize);
4454 let color = base_color(display_base);
4455 let is_highlighted = self.highlighted_scans.contains(&scan);
4456 frame.fill_text(widget::canvas::Text {
4457 content: String::from(display_base as char),
4458 position: Point { x, y: 2.0 },
4459 max_width: f32::INFINITY,
4460 color,
4461 size: cosmic::iced::Pixels(if is_highlighted { 20.0 } else { 11.0 }),
4462 line_height: cosmic::iced::advanced::text::LineHeight::default(),
4463 font: cosmic::iced::Font::default(),
4464 align_x: cosmic::iced::advanced::text::Alignment::Center,
4465 align_y: alignment::Vertical::Top,
4466 shaping: cosmic::iced::advanced::text::Shaping::Basic,
4467 });
4468 }
4469
4470 vec![frame.into_geometry()]
4471 }
4472}
4473
4474#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)]
4475pub enum View {
4476 Grid,
4477 List,
4478}
4479#[derive(Clone, Copy, Debug, Hash, PartialEq, PartialOrd, Ord, Eq, Deserialize, Serialize)]
4480pub enum HeadingOptions {
4481 Name = 0,
4482 Modified,
4483 Size,
4484 TrashedOn,
4485}
4486
4487impl fmt::Display for HeadingOptions {
4488 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4489 match self {
4490 Self::Name => write!(f, "{}", fl!("name")),
4491 Self::Modified => write!(f, "{}", fl!("modified")),
4492 Self::Size => write!(f, "{}", fl!("size")),
4493 Self::TrashedOn => write!(f, "{}", fl!("trashed-on")),
4494 }
4495 }
4496}
4497
4498impl HeadingOptions {
4499 pub fn names() -> Vec<String> {
4500 vec![
4501 Self::Name.to_string(),
4502 Self::Modified.to_string(),
4503 Self::Size.to_string(),
4504 Self::TrashedOn.to_string(),
4505 ]
4506 }
4507}
4508
4509#[derive(Clone, Debug)]
4510pub enum Mode {
4511 App,
4512 Desktop,
4513 Dialog(DialogKind),
4514}
4515
4516impl Mode {
4517 pub fn multiple(&self) -> bool {
4519 match self {
4520 Self::App | Self::Desktop => true,
4521 Self::Dialog(dialog) => dialog.multiple(),
4522 }
4523 }
4524}
4525
4526struct SearchContext {
4527 results_rx: mpsc::Receiver<SearchItem>,
4528 ready: Arc<atomic::AtomicBool>,
4529 last_modified_opt: Arc<RwLock<Option<SystemTime>>>,
4530}
4531
4532pub struct SearchContextWrapper(Option<SearchContext>);
4533
4534impl Clone for SearchContextWrapper {
4535 fn clone(&self) -> Self {
4536 Self(None)
4537 }
4538}
4539
4540impl fmt::Debug for SearchContextWrapper {
4541 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4542 f.debug_struct("SearchContextWrapper").finish()
4543 }
4544}
4545
4546pub struct Tab {
4549 pub location: Location,
4551 pub location_ancestors: Vec<(Location, String)>,
4552 pub location_title: String,
4553 pub location_context_menu_point: Option<Point>,
4554 pub location_context_menu_index: Option<usize>,
4555 pub context_menu: Option<Point>,
4556 pub mode: Mode,
4557 pub scroll_opt: Option<AbsoluteOffset>,
4558 pub size_opt: Cell<Option<Size>>,
4559 pub content_height_opt: Cell<Option<f32>>,
4560 pub viewport_opt: Option<Rectangle>,
4561 pub item_view_size_opt: Cell<Option<Size>>,
4562 pub edit_location: Option<EditLocation>,
4563 pub edit_location_id: widget::Id,
4564 pub history_i: usize,
4565 pub history: Vec<Location>,
4566 pub config: TabConfig,
4567 pub tb_config: TBConfig,
4568 pub thumb_config: ThumbCfg,
4569 pub sort_name: HeadingOptions,
4570 pub sort_direction: bool,
4571 pub gallery: bool,
4572 pub(crate) parent_item_opt: Option<Box<Item>>,
4573 pub(crate) items_opt: Option<Vec<Item>>,
4574 pub dnd_hovered: Option<(Location, Instant)>,
4575 pub(crate) scrollable_id: widget::Id,
4576 select_focus: Option<usize>,
4577 select_range: Option<(usize, usize)>,
4578 clicked: Option<usize>,
4579 selected_clicked: bool,
4580 last_right_click: Option<usize>,
4581 search_context: Option<SearchContext>,
4582 date_time_formatter: DateTimeFormatter<fieldsets::YMDT>,
4583 time_formatter: DateTimeFormatter<fieldsets::T>,
4584 watch_drag: bool,
4585 window_id: Option<window::Id>,
4586 large_image_manager: LargeImageManager,
4587}
4588
4589async fn calculate_dir_size(path: &Path, controller: Controller) -> Result<u64, OperationError> {
4590 let mut total = 0;
4591 for entry_res in WalkDir::new(path) {
4592 controller
4593 .check()
4594 .await
4595 .map_err(|s| OperationError::from_state(s, &controller))?;
4596
4597 if let Ok(entry) = entry_res
4599 && let Ok(metadata) = entry.metadata()
4600 && metadata.is_file()
4601 {
4602 total += metadata.len();
4603 }
4604
4605 tokio::task::yield_now().await;
4607 }
4608 Ok(total)
4609}
4610
4611fn folder_name<P: AsRef<Path>>(path: P) -> (String, bool) {
4612 let path = path.as_ref();
4613 let mut found_home = false;
4614 let name = match path.file_name() {
4615 Some(name) => {
4616 if path == crate::home_dir() {
4617 found_home = true;
4618 fl!("home")
4619 } else {
4620 match (get_filename_from_path(path), fs::metadata(path)) {
4621 (Ok(name), Ok(metadata)) => {
4622 let is_gvfs = fs_kind(&metadata) == FsKind::Gvfs;
4623 display_name_for_file(path, &name, is_gvfs, false)
4624 }
4625 _ => name.to_string_lossy().into_owned(),
4626 }
4627 }
4628 }
4629 None => {
4630 fl!("filesystem")
4631 }
4632 };
4633 (name, found_home)
4634}
4635
4636pub fn parse_hidden_file(path: &PathBuf) -> Box<[String]> {
4638 let Ok(file) = File::open(path) else {
4639 return Default::default();
4640 };
4641
4642 BufReader::new(file)
4643 .lines()
4644 .map_while(Result::ok)
4645 .filter_map(|line| {
4646 let line = line.trim();
4647 (!line.is_empty()).then_some(line.to_owned())
4648 })
4649 .collect()
4650}
4651
4652impl Tab {
4653 pub fn new(
4654 location: Location,
4655 config: TabConfig,
4656 tb_config: TBConfig,
4657 thumb_config: ThumbCfg,
4658 sorting_options: Option<&FxOrderMap<String, (HeadingOptions, bool)>>,
4659 scrollable_id: widget::Id,
4660 window_id: Option<window::Id>,
4661 ) -> Self {
4662 let location_str = location.to_string();
4663 let (sort_name, sort_direction) = sorting_options
4664 .and_then(|opts| opts.get(&location_str))
4665 .or_else(|| SORT_OPTION_FALLBACK.get(&location_str))
4666 .copied()
4667 .unwrap_or((HeadingOptions::Name, true));
4668 let location = location.normalize();
4669 let location_ancestors = location.ancestors();
4670 let location_title = location.title();
4671 let history = vec![location.clone()];
4672 Self {
4673 location,
4674 location_ancestors,
4675 location_title,
4676 context_menu: None,
4677 location_context_menu_point: None,
4678 location_context_menu_index: None,
4679 mode: Mode::App,
4680 scroll_opt: None,
4681 size_opt: Cell::new(None),
4682 content_height_opt: Cell::new(None),
4683 viewport_opt: None,
4684 item_view_size_opt: Cell::new(None),
4685 edit_location: None,
4686 edit_location_id: widget::Id::unique(),
4687 history_i: 0,
4688 history,
4689 config,
4690 tb_config,
4691 thumb_config,
4692 sort_name,
4693 sort_direction,
4694 gallery: false,
4695 parent_item_opt: None,
4696 items_opt: None,
4697 scrollable_id,
4698 select_focus: None,
4699 select_range: None,
4700 clicked: None,
4701 dnd_hovered: None,
4702 selected_clicked: false,
4703 last_right_click: None,
4704 search_context: None,
4705 date_time_formatter: date_time_formatter(config.military_time),
4706 time_formatter: time_formatter(config.military_time),
4707 watch_drag: true,
4708 window_id,
4709 large_image_manager: LargeImageManager::new(),
4710 }
4711 }
4712
4713 pub fn title(&self) -> String {
4714 self.location_title.clone()
4716 }
4717
4718 pub const fn items_opt(&self) -> Option<&Vec<Item>> {
4719 self.items_opt.as_ref()
4720 }
4721
4722 pub const fn items_opt_mut(&mut self) -> Option<&mut Vec<Item>> {
4723 self.items_opt.as_mut()
4724 }
4725
4726 pub fn set_items(&mut self, mut items: Vec<Item>) {
4727 let highlighted = self
4728 .items_opt
4729 .as_ref()
4730 .and_then(|items| items.iter().enumerate().find(|i| i.1.highlighted))
4731 .map(|(i, _)| i);
4732 let selected = self.selected_locations();
4733 for item in &mut items {
4734 item.selected = false;
4735 if let Some(location) = &item.location_opt
4736 && selected.contains(location)
4737 {
4738 item.selected = true;
4739 }
4740 }
4741 self.items_opt = Some(items);
4742 if let Some(i) = highlighted
4743 .zip(self.items_opt.as_mut())
4744 .and_then(|(h, items)| items.get_mut(h))
4745 {
4746 i.highlighted = true;
4747 }
4748 }
4749
4750 pub fn cut_selected(&mut self) {
4751 if let Some(ref mut items) = self.items_opt {
4752 for item in items.iter_mut() {
4753 item.cut = item.selected;
4754 }
4755 }
4756 }
4757
4758 pub fn refresh_cut(&mut self, locations: &[PathBuf]) {
4759 if let Some(ref mut items) = self.items_opt {
4760 for item in items.iter_mut() {
4761 item.cut = false;
4762 if let Some(location_path) = item.location_opt.as_ref().and_then(Location::path_opt)
4763 && locations.contains(location_path)
4764 {
4765 item.cut = true;
4766 }
4767 }
4768 }
4769 }
4770
4771 pub fn selected_locations(&self) -> Vec<Location> {
4772 if let Some(ref items) = self.items_opt {
4773 items
4774 .iter()
4775 .filter_map(|item| {
4776 if item.selected {
4777 item.location_opt.clone()
4778 } else {
4779 None
4780 }
4781 })
4782 .collect()
4783 } else {
4784 Vec::new()
4785 }
4786 }
4787
4788 pub fn selected_delete_paths(&self) -> Vec<PathBuf> {
4791 let Some(ref items) = self.items_opt else {
4792 return Vec::new();
4793 };
4794 items
4795 .iter()
4796 .filter(|item| item.selected)
4797 .flat_map(|item| {
4798 if item.metadata.is_tbprofiler_result_as_sample() {
4799 let mut paths = Vec::new();
4800 if let Some(p) = item.metadata.json_path() {
4801 paths.push(p.clone());
4802 }
4803 if let Some(p) = item.metadata.csv_path() {
4804 paths.push(p.clone());
4805 }
4806 if let Some(p) = item.metadata.docx_path() {
4807 paths.push(p.clone());
4808 }
4809 paths
4810 } else {
4811 item.location_opt
4812 .as_ref()
4813 .and_then(Location::path_opt)
4814 .map(|p| vec![p.clone()])
4815 .unwrap_or_default()
4816 }
4817 })
4818 .collect()
4819 }
4820
4821 pub fn select_all(&mut self) {
4822 if let Some(ref mut items) = self.items_opt {
4823 for item in items.iter_mut() {
4824 if !self.config.show_hidden && item.hidden {
4825 item.selected = false;
4826 continue;
4827 }
4828 item.selected = true;
4829 }
4830 }
4831 }
4832
4833 pub fn select_none(&mut self) -> bool {
4834 self.select_focus = None;
4835 let mut had_selection = false;
4836 if let Some(ref mut items) = self.items_opt {
4837 for item in items.iter_mut() {
4838 if item.selected {
4839 item.selected = false;
4840 had_selection = true;
4841 }
4842 }
4843 }
4844 had_selection
4845 }
4846
4847 pub fn select_name(&mut self, name: &str) {
4848 self.select_focus = None;
4849 if let Some(ref mut items) = self.items_opt {
4850 for (i, item) in items.iter_mut().enumerate() {
4851 item.selected = item.name == name;
4852 if item.selected {
4853 self.select_focus = Some(i);
4854 }
4855 }
4856 }
4857 }
4858
4859 pub fn select_by_prefix(&mut self, prefix: &str) -> bool {
4862 let prefix_lower = prefix.to_lowercase();
4863 let focus = self.select_focus.take();
4864
4865 if let Some(ref mut items) = self.items_opt {
4866 for item in items.iter_mut() {
4868 item.selected = false;
4869 }
4870
4871 let single_char = prefix_lower.chars().count() == 1;
4876 let start = if single_char {
4877 Self::index_after_focus(focus, self.sort_direction)
4878 } else {
4879 Self::index_before_focus(focus, self.sort_direction)
4880 };
4881 self.select_focus = Self::select_first_prefix_from_index(
4882 &prefix_lower,
4883 items,
4884 start,
4885 self.sort_direction,
4886 );
4887
4888 if self.select_focus.is_some() || single_char {
4889 return self.select_focus.is_some();
4890 }
4891
4892 let mut chars = prefix_lower.chars();
4893 let Some(first) = chars.next() else {
4894 log::error!("search term is empty");
4895 return self.select_focus.is_some();
4896 };
4897
4898 if !chars.all(|c| c == first) {
4900 return self.select_focus.is_some();
4901 }
4902
4903 let start = Self::index_after_focus(focus, self.sort_direction);
4906 self.select_focus = Self::select_first_prefix_from_index(
4907 &first.to_string(),
4908 items,
4909 start,
4910 self.sort_direction,
4911 );
4912
4913 return self.select_focus.is_some();
4914 }
4915 false
4916 }
4917
4918 fn index_before_focus(current_focus: Option<usize>, forward: bool) -> usize {
4919 current_focus.map_or(0, |i| if forward { i } else { i + 1 })
4920 }
4921
4922 fn index_after_focus(current_focus: Option<usize>, forward: bool) -> usize {
4923 current_focus.map_or(0, |i| if forward { i + 1 } else { i })
4924 }
4925
4926 fn select_first_prefix_from_index(
4927 prefix_lower: &str,
4928 items: &mut [Item],
4929 start: usize,
4930 forward: bool,
4931 ) -> Option<usize> {
4932 let Some((until, after)) = items.split_at_mut_checked(start) else {
4934 log::error!(
4935 "invalid start index {start} for items of length {}",
4936 items.len()
4937 );
4938 return None;
4939 };
4940 let search_items = after
4941 .iter_mut()
4942 .enumerate()
4943 .map(|(i, item)| (i + start, item))
4944 .chain(until.iter_mut().enumerate());
4945
4946 if forward {
4947 Self::select_first_prefix_match(prefix_lower, search_items)
4948 } else {
4949 Self::select_first_prefix_match(prefix_lower, search_items.rev())
4950 }
4951 }
4952
4953 fn select_first_prefix_match<'a>(
4957 prefix: &str,
4958 items: impl Iterator<Item = (usize, &'a mut Item)>,
4959 ) -> Option<usize> {
4960 for (i, item) in items {
4961 if item.name.to_lowercase().starts_with(prefix) {
4962 item.selected = true;
4963 return Some(i);
4964 }
4965 }
4966 None
4967 }
4968
4969 pub fn select_paths(&mut self, paths: Vec<PathBuf>) {
4970 self.select_focus = None;
4971 if let Some(ref mut items) = self.items_opt {
4972 for (i, item) in items.iter_mut().enumerate() {
4973 item.selected = false;
4974 if let Some(path) = item.path_opt()
4975 && paths.contains(path)
4976 {
4977 item.selected = true;
4978 self.select_focus = Some(i);
4979 }
4980 }
4981 }
4982 }
4983
4984 fn select_position(&mut self, row: usize, col: usize, mod_shift: bool) -> bool {
4985 let mut start = (row, col);
4986 let mut end = (row, col);
4987 if mod_shift {
4988 if self.select_focus.is_none() || self.select_range.is_none() {
4989 self.select_range = self.select_focus.map(|i| (i, i));
4991 }
4992
4993 if let Some(pos) = self.select_range_start_pos_opt() {
4994 if pos.0 < row || (pos.0 == row && pos.1 < col) {
4995 start = pos;
4996 } else {
4997 end = pos;
4998 }
4999 }
5000 }
5001
5002 let mut found = false;
5003 if let Some(ref mut items) = self.items_opt {
5004 for (i, item) in items.iter_mut().enumerate() {
5005 item.selected = false;
5006 let pos = match item.pos_opt.get() {
5007 Some(some) => some,
5008 None => continue,
5009 };
5010 if pos.0 < start.0 || (pos.0 == start.0 && pos.1 < start.1) {
5011 continue;
5013 }
5014 if pos.0 > end.0 || (pos.0 == end.0 && pos.1 > end.1) {
5015 continue;
5017 }
5018 if pos == (row, col) {
5019 self.select_focus = Some(i);
5021 self.select_range = if mod_shift {
5022 self.select_range.map(|r| (r.0, i))
5023 } else {
5024 Some((i, i))
5025 };
5026 found = true;
5027 }
5028 item.selected = true;
5029 }
5030 }
5031 found
5032 }
5033
5034 pub fn select_rect(&mut self, rect: Rectangle, mod_ctrl: bool, mod_shift: bool) {
5035 if let Some(ref mut items) = self.items_opt {
5036 for item in items.iter_mut() {
5037 let was_overlapped = item.overlaps_drag_rect;
5038 item.overlaps_drag_rect = item.rect_opt.get().is_some_and(|r| r.intersects(&rect));
5039
5040 item.selected = if mod_ctrl || mod_shift {
5041 if was_overlapped == item.overlaps_drag_rect {
5042 item.selected
5043 } else {
5044 !item.selected
5045 }
5046 } else {
5047 item.overlaps_drag_rect
5048 };
5049 }
5050 }
5051 }
5052
5053 pub fn select_focus_id(&self) -> Option<widget::Id> {
5054 let items = self.items_opt.as_ref()?;
5055 let item = items.get(self.select_focus?)?;
5056 Some(item.button_id.clone())
5057 }
5058
5059 fn select_focus_pos_opt(&self) -> Option<(usize, usize)> {
5060 let items = self.items_opt.as_ref()?;
5061 let item = items.get(self.select_focus?)?;
5062 item.pos_opt.get()
5063 }
5064
5065 fn dehighlight_all(&mut self) {
5066 if let Some(items) = self.items_opt.as_mut() {
5067 for item in items.iter_mut() {
5068 item.highlighted = false;
5069 }
5070 }
5071 }
5072
5073 pub(crate) fn select_focus_scroll(&mut self) -> Option<AbsoluteOffset> {
5074 let items = self.items_opt.as_ref()?;
5075 let item = items.get(self.select_focus?)?;
5076 let rect = item.rect_opt.get()?;
5077
5078 let visible_rect = {
5080 let point = match self.scroll_opt {
5081 Some(offset) => Point::new(0.0, offset.y),
5082 None => Point::new(0.0, 0.0),
5083 };
5084 let size = self
5085 .item_view_size_opt
5086 .get()
5087 .unwrap_or_else(|| Size::new(0.0, 0.0));
5088 Rectangle::new(point, size)
5089 };
5090
5091 if rect.y < visible_rect.y {
5092 self.scroll_opt = Some(AbsoluteOffset { x: 0.0, y: rect.y });
5094 self.scroll_opt
5095 } else if (rect.y + rect.height) > (visible_rect.y + visible_rect.height) {
5096 self.scroll_opt = Some(AbsoluteOffset {
5098 x: 0.0,
5099 y: rect.y + rect.height - visible_rect.height,
5100 });
5101 self.scroll_opt
5102 } else {
5103 None
5105 }
5106 }
5107
5108 fn rows_per_page(&self) -> usize {
5109 let viewport_height = self
5110 .item_view_size_opt
5111 .get()
5112 .map_or(0.0, |s| s.height);
5113 let row_height = self
5114 .select_focus
5115 .and_then(|i| self.items_opt.as_ref()?.get(i))
5116 .and_then(|item| item.rect_opt.get())
5117 .map_or(0.0, |r| r.height);
5118 if row_height > 0.0 && viewport_height > 0.0 {
5119 (viewport_height / row_height).floor() as usize
5120 } else {
5121 1
5122 }
5123 }
5124
5125 fn select_range_start_pos_opt(&self) -> Option<(usize, usize)> {
5126 let items = self.items_opt.as_ref()?;
5127 let item = items.get(self.select_range.map(|r| r.0)?)?;
5128 item.pos_opt.get()
5129 }
5130
5131 fn select_first_pos_opt(&self) -> Option<(usize, usize)> {
5132 let items = self.items_opt.as_ref()?;
5133 let mut first = None;
5134 for item in items {
5135 if !item.selected {
5136 continue;
5137 }
5138
5139 let (row, col) = match item.pos_opt.get() {
5140 Some(some) => some,
5141 None => continue,
5142 };
5143
5144 first = Some(match first {
5145 Some((first_row, first_col)) => match row.cmp(&first_row) {
5146 Ordering::Less => (row, col),
5147 Ordering::Equal => (row, col.min(first_row)),
5148 Ordering::Greater => (first_row, first_col),
5149 },
5150 None => (row, col),
5151 });
5152 }
5153 first
5154 }
5155
5156 fn select_last_pos_opt(&self) -> Option<(usize, usize)> {
5157 let items = self.items_opt.as_ref()?;
5158 let mut last = None;
5159 for item in items {
5160 if !item.selected {
5161 continue;
5162 }
5163
5164 let (row, col) = match item.pos_opt.get() {
5165 Some(some) => some,
5166 None => continue,
5167 };
5168
5169 last = Some(match last {
5170 Some((last_row, last_col)) => match row.cmp(&last_row) {
5171 Ordering::Greater => (row, col),
5172 Ordering::Equal => (row, col.max(last_row)),
5173 Ordering::Less => (last_row, last_col),
5174 },
5175 None => (row, col),
5176 });
5177 }
5178 last
5179 }
5180
5181 fn trigger_async_decode(&mut self) -> Vec<Command> {
5182 if !self.gallery {
5184 return Vec::new();
5185 }
5186
5187 let Some(index) = self.select_focus else {
5188 return Vec::new();
5189 };
5190
5191 let Some(items) = &self.items_opt else {
5192 return Vec::new();
5193 };
5194
5195 let Some(item) = items.get(index) else {
5196 return Vec::new();
5197 };
5198
5199 let Some(ItemThumbnail::Image(_, original_dims)) = &item.thumbnail_opt else {
5200 return Vec::new();
5201 };
5202
5203 if let Some((w, h)) = original_dims
5204 && !should_use_tiling(*w, *h)
5205 {
5206 return Vec::new();
5207 }
5208
5209 let Some(path) = item.path_opt() else {
5210 return Vec::new();
5211 };
5212
5213 let path = path.to_path_buf();
5215
5216 let display_dimensions = self
5218 .size_opt
5219 .get()
5220 .map(|size| (size.width as u32, size.height as u32));
5221
5222 let (should_decode, target_dimensions, generation) = self
5224 .large_image_manager
5225 .try_decode(&path, display_dimensions);
5226 if should_decode {
5227 vec![Command::Iced(
5228 cosmic::iced::Task::perform(
5229 decode_large_image(path, target_dimensions),
5230 move |result| {
5231 result
5232 .map(|(path, width, height, pixels)| {
5233 Message::ImageDecoded(
5234 path,
5235 width,
5236 height,
5237 pixels,
5238 display_dimensions,
5239 generation,
5240 )
5241 })
5242 .unwrap_or_else(|| Message::AutoScroll(None))
5243 },
5244 )
5245 .into(),
5246 )]
5247 } else {
5248 Vec::new()
5249 }
5250 }
5251
5252 pub fn change_location(&mut self, location: &Location, history_i_opt: Option<usize>) {
5253 self.location = location.normalize();
5254 self.location_ancestors = self.location.ancestors();
5255 self.location_title = self.location.title();
5256 self.context_menu = None;
5257 self.edit_location = None;
5258 self.items_opt = None;
5259 self.scroll_opt = None;
5261 self.select_focus = None;
5262 self.search_context = None;
5263 if let Some(history_i) = history_i_opt {
5264 self.history_i = history_i;
5266 } else {
5267 self.history.truncate(self.history_i + 1);
5269
5270 {
5272 let mut remove = false;
5273 if let Some(last_location) = self.history.last() {
5274 if let Location::Network(last_uri, ..) = last_location
5275 && let Location::Network(uri, ..) = location
5276 {
5277 remove = last_uri == uri;
5278 } else if let Location::Remote(last_uri, ..) = last_location
5279 && let Location::Remote(uri, ..) = location
5280 {
5281 remove = last_uri == uri;
5282 } else if let Some(last_path) = last_location.path_opt()
5283 && let Some(path) = location.path_opt()
5284 {
5285 remove = last_path == path;
5286 }
5287 }
5288 if remove {
5289 self.history.pop();
5290 }
5291 }
5292
5293 self.history_i = self.history.len();
5295 self.history.push(location.clone());
5296 }
5297 }
5298
5299 pub fn update(&mut self, message: Message, modifiers: Modifiers) -> Vec<Command> {
5300 let mut commands = Vec::new();
5301 let mut cd = None;
5302 let mut history_i_opt = None;
5303 let mod_ctrl = modifiers.contains(Modifiers::CTRL) && self.mode.multiple();
5304 let mod_shift = modifiers.contains(Modifiers::SHIFT) && self.mode.multiple();
5305 let last_context_menu = self.context_menu;
5306 match message {
5307 Message::AddNetworkDrive => {
5308 commands.push(Command::AddNetworkDrive);
5309 }
5310 Message::AddRemoteDrive => {
5311 commands.push(Command::AddRemoteDrive);
5312 }
5313 Message::DeleteTbProfilerResults(uri, tb_config) => {
5314 commands.push(Command::DeleteTbProfilerResults(uri, tb_config));
5315 }
5316 Message::AutoScroll(auto_scroll) => {
5317 commands.push(Command::AutoScroll(auto_scroll));
5318 }
5319 Message::ClickRelease(click_i_opt) => {
5320 if !mod_ctrl && self.config.single_click {
5322 let mut paths_to_open = Vec::new();
5323 if let Some(ref mut items) = self.items_opt {
5324 for (i, item) in items.iter_mut().enumerate() {
5325 if Some(i) == click_i_opt {
5326 if let Some(location) = &item.location_opt {
5327 if item.metadata.is_dir() {
5328 cd = Some(location.clone());
5329 } else if let Some(path) = location.path_opt() {
5330 paths_to_open.push(path.clone());
5331 } else {
5332 log::warn!("no path for item {item:?}");
5333 }
5334 } else {
5335 log::warn!("no location for item {item:?}");
5336 }
5337 }
5338 }
5339 }
5340 if !paths_to_open.is_empty() {
5341 commands.push(Command::OpenFile(paths_to_open));
5342 }
5343 }
5344
5345 if click_i_opt != self.clicked.take() {
5346 self.context_menu = None;
5347 self.location_context_menu_index = None;
5348 if let Some(ref mut items) = self.items_opt {
5349 for (i, item) in items.iter_mut().enumerate() {
5350 if mod_ctrl {
5351 if Some(i) == click_i_opt && item.selected {
5352 item.selected = false;
5353 self.select_range = None;
5354 }
5355 } else if Some(i) != click_i_opt {
5356 item.selected = false;
5357 }
5358 }
5359 }
5360 }
5361 }
5362 Message::DragEnd => {
5363 self.clicked = None;
5364 self.watch_drag = true;
5365 }
5366 Message::DoubleClick(click_i_opt) => {
5367 if let Some(clicked_item) = self
5368 .items_opt
5369 .as_ref()
5370 .and_then(|items| click_i_opt.and_then(|click_i| items.get(click_i)))
5371 {
5372 if let Some(location) = &clicked_item.location_opt {
5373 if clicked_item.metadata.is_dir() {
5374 cd = Some(location.clone());
5375 } else if let Some(path) = location.path_opt() {
5376 commands.push(Command::OpenFile(vec![path.clone()]));
5377 } else {
5378 log::warn!("no path for item {clicked_item:?}");
5379 }
5380 } else {
5381 log::warn!("no location for item {clicked_item:?}");
5382 }
5383 } else {
5384 log::warn!("no item for click index {click_i_opt:?}");
5385 }
5386 }
5387 Message::Click(click_i_opt) => {
5388 self.selected_clicked = false;
5389 self.context_menu = None;
5390 self.edit_location = None;
5391 self.location_context_menu_index = None;
5392 if click_i_opt.is_none() {
5393 self.clicked = click_i_opt;
5394 }
5395
5396 if mod_shift {
5397 if let Some(click_i) = click_i_opt {
5398 self.select_range = self
5399 .select_range
5400 .map_or(Some((click_i, click_i)), |r| Some((r.0, click_i)));
5401 if let Some(range) = self.select_range {
5402 let range_min = range.0.min(range.1);
5403 let range_max = range.0.max(range.1);
5404 let indices: Vec<_> = self
5420 .column_sort()
5421 .map(|sorted| sorted.into_iter().map(|(i, _)| i).collect())
5422 .unwrap_or_else(|| {
5423 let len = self
5424 .items_opt
5425 .as_deref()
5426 .map(<[Item]>::len)
5427 .unwrap_or_default();
5428 (0..len).collect()
5429 });
5430
5431 let min = indices
5434 .iter()
5435 .copied()
5436 .position(|offset| offset == range_min)
5437 .unwrap_or_default();
5438 let max = indices
5441 .iter()
5442 .copied()
5443 .position(|offset| offset == range_max)
5444 .unwrap_or(indices.len());
5445 let min_real = min.min(max);
5446 let max_real = max.max(min);
5447
5448 if let Some(ref mut items) = self.items_opt {
5449 for index in indices
5450 .into_iter()
5451 .skip(min_real)
5452 .take(max_real - min_real + 1)
5453 {
5454 if let Some(item) = items.get_mut(index) {
5455 if item.hidden {
5456 if self.config.show_hidden {
5457 item.selected = true;
5458 }
5459 } else {
5460 item.selected = true;
5461 }
5462 }
5463 }
5464 }
5465 }
5466 self.clicked = click_i_opt;
5467 self.select_focus = click_i_opt;
5468 self.selected_clicked = true;
5469 }
5470 } else {
5471 let dont_unset = mod_ctrl
5472 || self.column_sort().is_some_and(|l| {
5473 l.iter()
5474 .any(|&(e_i, e)| Some(e_i) == click_i_opt && e.selected)
5475 });
5476 if let Some(ref mut items) = self.items_opt {
5477 for (i, item) in items.iter_mut().enumerate() {
5478 if Some(i) == click_i_opt {
5479 if let Mode::Dialog(dialog) = &self.mode {
5481 let item_is_dir = item.metadata.is_dir();
5482 if item_is_dir != dialog.is_dir() {
5483 if !item_is_dir {
5487 continue;
5488 }
5489 }
5490 }
5491 if !item.selected {
5492 self.clicked = click_i_opt;
5493 item.selected = true;
5494 }
5495 self.select_range = Some((i, i));
5496 self.select_focus = click_i_opt;
5497 self.selected_clicked = true;
5498 } else if !dont_unset && item.selected {
5499 self.clicked = click_i_opt;
5500 item.selected = false;
5501 }
5502 }
5503 }
5504 }
5505 }
5506 Message::Config(config) => {
5507 let view = self.config.view;
5509 let military_time_changed = self.config.military_time != config.military_time;
5510 let show_hidden_changed = self.config.show_hidden != config.show_hidden;
5511 self.config = config;
5512 self.config.view = view;
5513 if military_time_changed {
5514 self.date_time_formatter = date_time_formatter(self.config.military_time);
5515 self.time_formatter = time_formatter(self.config.military_time);
5516 }
5517 if show_hidden_changed && let Location::Search(path, term, ..) = &self.location {
5518 cd = Some(Location::Search(
5519 path.clone(),
5520 term.clone(),
5521 self.config.show_hidden,
5522 Instant::now(),
5523 ));
5524 }
5525 if let Some(ref mut items) = self.items_opt {
5527 for item in items.iter_mut() {
5528 item.highlighted = false;
5529 }
5530 }
5531 }
5532 Message::ContextAction(action) => {
5533 self.context_menu = None;
5535
5536 commands.push(Command::Action(action));
5537 }
5538 Message::RunContextAction(action) => {
5539 self.context_menu = None;
5540
5541 commands.push(Command::RunContextAction(action));
5542 }
5543 Message::ContextMenu(point_opt, _) => {
5544 self.edit_location = None;
5545 self.context_menu = point_opt;
5546 self.location_context_menu_index = None;
5547
5548 if self.context_menu.is_some()
5550 && self.last_right_click.take().is_none()
5551 && let Some(ref mut items) = self.items_opt
5552 {
5553 for item in items.iter_mut() {
5554 item.selected = false;
5555 }
5556 }
5557 }
5558 Message::Download(path_uri_opt) => {
5559 if let Some(path_uri) = path_uri_opt {
5560 commands.push(Command::DownloadFile(vec![path_uri.0], vec![path_uri.1]));
5561 }
5562 }
5563 Message::DownloadMany(paths, uris) => {
5564 commands.push(Command::DownloadFile(paths, uris));
5565 }
5566 Message::LocationContextMenuPoint(point_opt) => {
5567 self.context_menu = None;
5568 self.location_context_menu_point = point_opt;
5569 }
5570 Message::LocationContextMenuIndex(p, index_opt) => {
5571 self.context_menu = None;
5572 self.location_context_menu_point = p;
5573 self.location_context_menu_index = index_opt;
5574 }
5575 Message::LocationMenuAction(action) => {
5576 self.location_context_menu_index = None;
5577 let path_for_index = |ancestor_index| {
5578 self.location
5579 .path_opt()
5580 .and_then(|path| path.ancestors().nth(ancestor_index))
5581 .map(Path::to_path_buf)
5582 };
5583 match action {
5584 LocationMenuAction::OpenInNewTab(ancestor_index) => {
5585 if let Some(path) = path_for_index(ancestor_index) {
5586 commands.push(Command::OpenInNewTab(path));
5587 }
5588 }
5589 LocationMenuAction::OpenInNewWindow(ancestor_index) => {
5590 if let Some(path) = path_for_index(ancestor_index) {
5591 commands.push(Command::OpenInNewWindow(path));
5592 }
5593 }
5594 LocationMenuAction::Preview(ancestor_index) => {
5595 if let Some(path) = path_for_index(ancestor_index) {
5596 match item_from_path(&path, IconSizes::default()) {
5598 Ok(item) => {
5599 commands.push(Command::Preview(PreviewKind::Custom(
5600 PreviewItem(Box::new(item)),
5601 )));
5602 }
5603 Err(err) => {
5604 log::warn!(
5605 "failed to get item from path {}: {}",
5606 path.display(),
5607 err
5608 );
5609 }
5610 }
5611 }
5612 }
5613 LocationMenuAction::AddToSidebar(ancestor_index) => {
5614 if let Some(path) = path_for_index(ancestor_index) {
5615 commands.push(Command::AddToSidebar(path));
5616 } else {
5617 log::warn!(
5618 "no ancestor {ancestor_index} for location {:?}",
5619 self.location
5620 );
5621 }
5622 }
5623 }
5624 }
5625 Message::Drag(rect_opt) => {
5626 self.watch_drag = false;
5627 if let Some(rect) = rect_opt {
5628 self.context_menu = None;
5629 self.location_context_menu_index = None;
5630 if self.mode.multiple() {
5631 self.select_rect(rect, mod_ctrl, mod_shift);
5632 }
5633 if self.select_focus.take().is_some() {
5634 commands.push(Command::Iced(
5636 widget::button::focus(widget::Id::unique()).into(),
5637 ));
5638 }
5639 }
5640 }
5641 Message::EditLocation(edit_location) => {
5642 self.edit_location = edit_location;
5643 if self.edit_location.is_some() {
5644 commands.push(Command::Iced(
5645 widget::text_input::focus(self.edit_location_id.clone()).into(),
5646 ));
5647 }
5648 }
5649 Message::EditLocationComplete(selected) => {
5650 if let Some(mut edit_location) = self.edit_location.take()
5651 && !matches!(
5652 edit_location.location,
5653 Location::Network(..) | Location::Remote(..)
5654 )
5655 {
5656 edit_location.selected = Some(selected);
5657 cd = edit_location.resolve();
5658 }
5659 }
5660 Message::EditLocationEnable => {
5661 commands.push(Command::Iced(
5662 widget::text_input::focus(self.edit_location_id.clone()).into(),
5663 ));
5664 self.edit_location = Some(self.location.clone().into());
5665 }
5666 Message::EditLocationSubmit => {
5667 if let Some(mut edit_location) = self.edit_location.take() {
5668 if edit_location.selected.is_none()
5670 && edit_location
5671 .completions
5672 .as_ref()
5673 .is_some_and(|completions| !completions.is_empty())
5674 && edit_location
5675 .location
5676 .path_opt()
5677 .is_some_and(|path| !path.exists())
5678 {
5679 edit_location.selected = Some(0);
5680 }
5681
5682 cd = edit_location.resolve();
5683 }
5684 }
5685 Message::EditLocationTab => {
5686 if let Some(edit_location) = &mut self.edit_location {
5687 edit_location.select(!modifiers.contains(Modifiers::SHIFT));
5688 }
5689 }
5690 Message::OpenInNewTab(path) => {
5691 commands.push(Command::OpenInNewTab(path));
5692 }
5693 Message::ClearRecents => {
5694 commands.push(Command::ClearRecents);
5695 }
5696 Message::EmptyTrash => {
5697 commands.push(Command::EmptyTrash);
5698 }
5699 #[cfg(feature = "desktop")]
5700 Message::ExecEntryAction(path, action) => {
5701 let lang_id = crate::localize::LANGUAGE_LOADER.current_language();
5702 let language = lang_id.language.as_str();
5703 match path.map_or_else(
5704 || {
5705 let items = self.items_opt.as_deref()?;
5706 items.iter().find(|&item| item.selected).and_then(|item| {
5707 let location = item.location_opt.as_ref()?;
5708 let path = location.path_opt()?;
5709 cosmic::desktop::load_desktop_file(&[language.into()], path.into())
5710 })
5711 },
5712 |path| cosmic::desktop::load_desktop_file(&[language.into()], path),
5713 ) {
5714 Some(entry) => commands.push(Command::ExecEntryAction(entry, action)),
5715 None => log::warn!("Invalid desktop entry path passed to ExecEntryAction"),
5716 }
5717 }
5718 Message::Gallery(gallery) => {
5719 self.gallery = gallery;
5720
5721 if gallery {
5722 commands.extend(self.trigger_async_decode());
5723 }
5724 }
5725 Message::GalleryPrevious | Message::GalleryNext => {
5726 let mut pos_opt = None;
5727 if let Some(mut indices) = self.column_sort() {
5728 if matches!(message, Message::GalleryPrevious) {
5729 indices.reverse();
5730 }
5731 let mut found = false;
5732 for (index, item) in indices {
5733 if self.select_focus.is_none() {
5734 found = true;
5735 }
5736 if self.select_focus == Some(index) {
5737 found = true;
5738 continue;
5739 }
5740 if found && item.can_gallery() {
5741 pos_opt = item.pos_opt.get();
5742 if pos_opt.is_some() {
5743 break;
5744 }
5745 }
5746 }
5747 }
5748 if let Some((row, col)) = pos_opt {
5749 self.select_position(row, col, mod_shift);
5751
5752 commands.extend(self.trigger_async_decode());
5753 }
5754 if let Some(offset) = self.select_focus_scroll() {
5755 commands.push(Command::Iced(
5756 scrollable::scroll_to(
5757 self.scrollable_id.clone(),
5758 AbsoluteOffset {
5759 x: Some(offset.x),
5760 y: Some(offset.y),
5761 },
5762 )
5763 .into(),
5764 ));
5765 }
5766 if let Some(id) = self.select_focus_id() {
5767 commands.push(Command::Iced(widget::button::focus(id).into()));
5768 }
5769 }
5770 Message::GalleryToggle => {
5771 if let Some(indices) = self.column_sort() {
5772 for (_, item) in &indices {
5773 if item.selected && item.can_gallery() {
5774 self.gallery = !self.gallery;
5775
5776 if self.gallery {
5777 commands.extend(self.trigger_async_decode());
5778 }
5779 break;
5780 }
5781 }
5782 }
5783 }
5784 Message::GoNext => {
5785 if let Some(history_i) = self.history_i.checked_add(1)
5786 && let Some(location) = self.history.get(history_i)
5787 {
5788 cd = Some(location.clone());
5789 history_i_opt = Some(history_i);
5790 }
5791 }
5792 Message::GoPrevious => {
5793 if let Some(history_i) = self.history_i.checked_sub(1)
5794 && let Some(location) = self.history.get(history_i)
5795 {
5796 cd = Some(location.clone());
5797 history_i_opt = Some(history_i);
5798 }
5799 }
5800 Message::ItemDown => {
5801 self.dehighlight_all();
5802 if let Some(edit_location) = &mut self.edit_location {
5803 edit_location.select(true);
5804 } else if self.gallery {
5805 commands.append(&mut self.update(Message::GalleryNext, modifiers));
5806 } else {
5807 if let Some((row, col)) =
5808 self.select_focus_pos_opt().or(self.select_last_pos_opt())
5809 {
5810 if self.select_focus.is_none() {
5811 self.select_position(row, col, mod_shift);
5813 }
5814
5815 if !self.select_position(row + 1, col, mod_shift) {
5818 self.select_position(row, col, mod_shift);
5820 }
5821 } else {
5822 self.select_position(0, 0, mod_shift);
5825 }
5826 if let Some(offset) = self.select_focus_scroll() {
5827 commands.push(Command::Iced(
5828 scrollable::scroll_to(
5829 self.scrollable_id.clone(),
5830 AbsoluteOffset {
5831 x: Some(offset.x),
5832 y: Some(offset.y),
5833 },
5834 )
5835 .into(),
5836 ));
5837 }
5838 if let Some(id) = self.select_focus_id() {
5839 commands.push(Command::Iced(widget::button::focus(id).into()));
5840 }
5841 }
5842 }
5843 Message::ItemPageDown => {
5844 self.dehighlight_all();
5845 if let Some((row, col)) =
5846 self.select_focus_pos_opt().or(self.select_last_pos_opt())
5847 {
5848 if self.select_focus.is_none() {
5849 self.select_position(row, col, mod_shift);
5850 }
5851
5852 let rows_per_page = self.rows_per_page().max(1);
5853 let target_row = row.saturating_add(rows_per_page);
5854
5855 if !self.select_position(target_row, col, mod_shift) {
5856 let best = self
5858 .items_opt
5859 .as_ref()
5860 .and_then(|items| {
5861 items
5862 .iter()
5863 .filter_map(|item| item.pos_opt.get())
5864 .filter(|(r, _)| *r <= target_row)
5865 .max()
5866 });
5867 if let Some((best_row, best_col)) = best {
5868 self.select_position(best_row, best_col, mod_shift);
5869 }
5870 }
5871 } else {
5872 self.select_position(0, 0, mod_shift);
5873 }
5874 if let Some(offset) = self.select_focus_scroll() {
5875 commands.push(Command::Iced(
5876 scrollable::scroll_to(
5877 self.scrollable_id.clone(),
5878 AbsoluteOffset {
5879 x: Some(offset.x),
5880 y: Some(offset.y),
5881 },
5882 )
5883 .into(),
5884 ));
5885 }
5886 if let Some(id) = self.select_focus_id() {
5887 commands.push(Command::Iced(widget::button::focus(id).into()));
5888 }
5889 }
5890 Message::ItemPageUp => {
5891 self.dehighlight_all();
5892 if let Some((row, col)) =
5893 self.select_focus_pos_opt().or(self.select_first_pos_opt())
5894 {
5895 if self.select_focus.is_none() {
5896 self.select_position(row, col, mod_shift);
5897 }
5898
5899 let rows_per_page = self.rows_per_page().max(1);
5900 let target_row = row.saturating_sub(rows_per_page);
5901
5902 if !self.select_position(target_row, col, mod_shift) {
5903 let best = self
5905 .items_opt
5906 .as_ref()
5907 .and_then(|items| {
5908 items
5909 .iter()
5910 .filter_map(|item| item.pos_opt.get())
5911 .filter(|(r, _)| *r >= target_row)
5912 .min()
5913 });
5914 if let Some((best_row, best_col)) = best {
5915 self.select_position(best_row, best_col, mod_shift);
5916 }
5917 }
5918 } else {
5919 self.select_position(0, 0, mod_shift);
5920 }
5921 if let Some(offset) = self.select_focus_scroll() {
5922 commands.push(Command::Iced(
5923 scrollable::scroll_to(
5924 self.scrollable_id.clone(),
5925 AbsoluteOffset {
5926 x: Some(offset.x),
5927 y: Some(offset.y),
5928 },
5929 )
5930 .into(),
5931 ));
5932 }
5933 if let Some(id) = self.select_focus_id() {
5934 commands.push(Command::Iced(widget::button::focus(id).into()));
5935 }
5936 }
5937 Message::ItemLeft => {
5938 self.dehighlight_all();
5939 if self.gallery {
5940 commands.append(&mut self.update(Message::GalleryPrevious, modifiers));
5941 } else {
5942 if let Some((row, col)) =
5943 self.select_focus_pos_opt().or(self.select_first_pos_opt())
5944 {
5945 if self.select_focus.is_none() {
5946 self.select_position(row, col, mod_shift);
5948 }
5949
5950 if !col
5952 .checked_sub(1)
5953 .is_some_and(|col| self.select_position(row, col, mod_shift))
5954 {
5955 if !row.checked_sub(1).is_some_and(|row| {
5957 let mut col = 0;
5958 if let Some(ref items) = self.items_opt {
5959 for item in items {
5960 match item.pos_opt.get() {
5961 Some((item_row, item_col)) if item_row == row => {
5962 col = col.max(item_col);
5963 }
5964 _ => continue,
5965 }
5966 }
5967 }
5968 self.select_position(row, col, mod_shift)
5969 }) {
5970 self.select_position(row, col, mod_shift);
5972 }
5973 }
5974 } else {
5975 self.select_position(0, 0, mod_shift);
5978 }
5979 if let Some(offset) = self.select_focus_scroll() {
5980 commands.push(Command::Iced(
5981 scrollable::scroll_to(
5982 self.scrollable_id.clone(),
5983 AbsoluteOffset {
5984 x: Some(offset.x),
5985 y: Some(offset.y),
5986 },
5987 )
5988 .into(),
5989 ));
5990 }
5991 if let Some(id) = self.select_focus_id() {
5992 commands.push(Command::Iced(widget::button::focus(id).into()));
5993 }
5994 }
5995 }
5996 Message::ItemRight => {
5997 self.dehighlight_all();
5998 if self.gallery {
5999 commands.append(&mut self.update(Message::GalleryNext, modifiers));
6000 } else {
6001 if let Some((row, col)) =
6002 self.select_focus_pos_opt().or(self.select_last_pos_opt())
6003 {
6004 if self.select_focus.is_none() {
6005 self.select_position(row, col, mod_shift);
6007 }
6008 if !self.select_position(row, col + 1, mod_shift) {
6010 if !self.select_position(row + 1, 0, mod_shift) {
6012 self.select_position(row, col, mod_shift);
6014 }
6015 }
6016 } else {
6017 self.select_position(0, 0, mod_shift);
6020 }
6021 if let Some(offset) = self.select_focus_scroll() {
6022 commands.push(Command::Iced(
6023 scrollable::scroll_to(
6024 self.scrollable_id.clone(),
6025 AbsoluteOffset {
6026 x: Some(offset.x),
6027 y: Some(offset.y),
6028 },
6029 )
6030 .into(),
6031 ));
6032 }
6033 if let Some(id) = self.select_focus_id() {
6034 commands.push(Command::Iced(widget::button::focus(id).into()));
6035 }
6036 }
6037 }
6038 Message::ItemUp => {
6039 self.dehighlight_all();
6040 if let Some(edit_location) = &mut self.edit_location {
6041 edit_location.select(false);
6042 } else if self.gallery {
6043 commands.append(&mut self.update(Message::GalleryPrevious, modifiers));
6044 } else {
6045 if let Some((row, col)) =
6046 self.select_focus_pos_opt().or(self.select_first_pos_opt())
6047 {
6048 if self.select_focus.is_none() {
6049 self.select_position(row, col, mod_shift);
6051 }
6052
6053 if !row
6056 .checked_sub(1)
6057 .is_some_and(|row| self.select_position(row, col, mod_shift))
6058 {
6059 self.select_position(row, col, mod_shift);
6061 }
6062 } else {
6063 self.select_position(0, 0, mod_shift);
6066 }
6067 if let Some(offset) = self.select_focus_scroll() {
6068 commands.push(Command::Iced(
6069 scrollable::scroll_to(
6070 self.scrollable_id.clone(),
6071 AbsoluteOffset {
6072 x: Some(offset.x),
6073 y: Some(offset.y),
6074 },
6075 )
6076 .into(),
6077 ));
6078 }
6079 if let Some(id) = self.select_focus_id() {
6080 commands.push(Command::Iced(widget::button::focus(id).into()));
6081 }
6082 }
6083 }
6084 Message::Location(location) => {
6085 match &location {
6087 Location::Path(path) => {
6088 if path.is_dir() {
6089 cd = Some(location);
6090 } else {
6091 commands.push(Command::OpenFile(vec![path.clone()]));
6092 }
6093 }
6094 #[cfg(feature = "russh")]
6095 Location::Remote(..) => {
6096 cd = Some(location);
6097 }
6098 _ => {
6099 cd = Some(location);
6100 }
6101 }
6102 }
6103 Message::LocationUp => {
6104 if let Location::Path(ref path) = self.location
6107 && let Some(parent) = path.parent()
6108 {
6109 cd = Some(Location::Path(parent.to_owned()));
6110 }
6111 }
6112 Message::Open(path_opt) => {
6113 match path_opt {
6114 Some(path) => {
6115 if path.is_dir() {
6116 cd = Some(Location::Path(path));
6117 } else {
6118 commands.push(Command::OpenFile(vec![path]));
6119 }
6120 }
6121 None => {
6123 enum ResolveResult {
6124 Open(Option<PathBuf>),
6125 OpenInTab(Option<PathBuf>),
6126 OpenUriInTab(String, String, Option<PathBuf>),
6127 OpenTrash,
6128 OpenProperties,
6129 Cd(Location),
6130 Skip,
6131 }
6132 fn resolve_item(
6133 item: &Item,
6134 mode: &Mode,
6135 is_only_one_selected: bool,
6136 ) -> ResolveResult {
6137 if !item.selected {
6138 return ResolveResult::Skip;
6139 }
6140
6141 let location = match &item.location_opt {
6142 Some(l) => l,
6143 None => return ResolveResult::OpenProperties,
6144 };
6145
6146 let path_opt = location.path_opt();
6147
6148 if item.metadata.is_dir() {
6149 match mode {
6150 Mode::App => match &item.location_opt {
6151 Some(Location::Remote(uri, name, path)) => {
6152 if is_only_one_selected {
6153 ResolveResult::Cd(location.clone())
6154 } else {
6155 ResolveResult::OpenUriInTab(
6156 uri.clone(),
6157 name.clone(),
6158 path.clone(),
6159 )
6160 }
6161 }
6162 _ => {
6163 if is_only_one_selected {
6164 ResolveResult::Cd(location.clone())
6165 } else {
6166 ResolveResult::OpenInTab(path_opt.cloned())
6167 }
6168 }
6169 },
6170 Mode::Desktop => match location {
6171 Location::Trash => ResolveResult::OpenTrash,
6172 _ => ResolveResult::Open(path_opt.cloned()),
6173 },
6174 Mode::Dialog(_) => {
6175 if is_only_one_selected {
6176 ResolveResult::Cd(location.clone())
6177 } else {
6178 ResolveResult::Skip
6179 }
6180 }
6181 }
6182 } else {
6183 ResolveResult::Open(path_opt.cloned())
6184 }
6185 }
6186 let mut open_files = Vec::new();
6187 if let Some(items) = self.items_opt.as_ref() {
6188 let selected_count = items.iter().filter(|i| i.selected).count();
6189
6190 for item in items.iter() {
6191 match resolve_item(item, &self.mode, selected_count == 1) {
6192 ResolveResult::Open(Some(p)) => open_files.push(p),
6193 ResolveResult::OpenInTab(Some(p)) => {
6194 commands.push(Command::OpenInNewTab(p))
6195 }
6196 ResolveResult::OpenUriInTab(uri, name, path) => {
6197 commands.push(Command::OpenUriInNewTab(uri, name, path))
6198 }
6199 ResolveResult::Cd(loc) => cd = Some(loc),
6200 ResolveResult::OpenTrash => commands.push(Command::OpenTrash),
6201 ResolveResult::OpenProperties => {} _ => {}
6203 }
6204 }
6205 }
6206 if !open_files.is_empty() {
6207 commands.push(Command::OpenFile(open_files));
6208 }
6209 }
6210 }
6211 }
6212 Message::Reload => {
6213 let selected_paths = self
6215 .selected_locations()
6216 .into_iter()
6217 .filter_map(Location::into_path_opt)
6218 .collect();
6219 let location = self.location.clone();
6220 self.change_location(&location, None);
6221 commands.push(Command::ChangeLocation(
6222 self.title(),
6223 location,
6224 Some(selected_paths),
6225 ));
6226 }
6227 Message::RightClick(_point_opt, click_i_opt) => {
6228 if mod_ctrl || mod_shift {
6229 self.update(Message::Click(click_i_opt), modifiers);
6230 }
6231 if let Some(ref mut items) = self.items_opt
6232 && !click_i_opt
6233 .is_some_and(|click_i| items.get(click_i).is_some_and(|x| x.selected))
6234 {
6235 for (i, item) in items.iter_mut().enumerate() {
6237 item.selected = Some(i) == click_i_opt;
6238 }
6239 }
6240 self.last_right_click = click_i_opt;
6242 }
6243 Message::MiddleClick(click_i) => {
6244 if mod_ctrl || mod_shift {
6245 self.update(Message::Click(Some(click_i)), modifiers);
6246 } else {
6247 if let Some(ref mut items) = self.items_opt {
6248 for (i, item) in items.iter_mut().enumerate() {
6249 item.selected = i == click_i;
6250 }
6251 self.select_range = Some((click_i, click_i));
6252 }
6253 if let Some(clicked_item) =
6254 self.items_opt.as_ref().and_then(|items| items.get(click_i))
6255 {
6256 if let Some(path) = clicked_item.path_opt() {
6257 if clicked_item.metadata.is_dir() {
6258 commands.push(Command::OpenInNewTab(path.clone()));
6260 } else {
6261 commands.push(Command::OpenFile(vec![path.clone()]));
6262 }
6263 } else {
6264 log::warn!("no path for item {clicked_item:?}");
6265 }
6266 } else {
6267 log::warn!("no item for click index {click_i:?}");
6268 }
6269 }
6270 }
6271 Message::HighlightDeactivate(i) => {
6272 self.watch_drag = true;
6273 if let Some(item) = self.items_opt.as_mut().and_then(|f| f.get_mut(i)) {
6274 item.highlighted = false;
6275 }
6276 }
6277 Message::HighlightActivate(i) => {
6278 self.watch_drag = true;
6279 if let Some(item) = self.items_opt.as_mut().and_then(|f| f.get_mut(i)) {
6280 item.highlighted = true;
6281 }
6282 }
6283 Message::Resize(viewport) => {
6284 if self.viewport_opt.map(|v| v.size()) != Some(viewport.size())
6286 && let Some(offset) = self.select_focus_scroll()
6287 {
6288 commands.push(Command::Iced(
6289 scrollable::scroll_to(
6290 self.scrollable_id.clone(),
6291 AbsoluteOffset {
6292 x: Some(offset.x),
6293 y: Some(offset.y),
6294 },
6295 )
6296 .into(),
6297 ));
6298 }
6299
6300 self.viewport_opt = Some(viewport);
6301 }
6302 Message::Scroll(viewport) => {
6303 self.scroll_opt = Some(viewport.absolute_offset());
6304 self.watch_drag = true;
6305 }
6306 Message::ScrollTab(scroll_speed) => {
6307 commands.push(Command::Iced(
6308 scrollable::scroll_by(
6309 self.scrollable_id.clone(),
6310 AbsoluteOffset {
6311 x: 0.0,
6312 y: scroll_speed,
6313 },
6314 )
6315 .into(),
6316 ));
6317 }
6318 Message::ScrollToFocused => {
6319 if let Some(offset) = self.select_focus_scroll() {
6320 commands.push(Command::Iced(
6321 scrollable::scroll_to(
6322 self.scrollable_id.clone(),
6323 AbsoluteOffset {
6324 x: Some(offset.x),
6325 y: Some(offset.y),
6326 },
6327 )
6328 .into(),
6329 ));
6330 }
6331 }
6332 Message::SearchContext(location, context) => {
6333 if location == self.location {
6334 self.search_context = context.0;
6335 } else {
6336 log::warn!(
6337 "search context provided for {:?} instead of {:?}",
6338 location,
6339 self.location
6340 );
6341 }
6342 }
6343 Message::SearchReady(finished) => {
6344 if let Some(context) = &mut self.search_context {
6345 if let Some(items) = &mut self.items_opt {
6346 if finished || context.ready.swap(false, atomic::Ordering::SeqCst) {
6347 let duration = Instant::now();
6348 while let Ok(search_item) = context.results_rx.try_recv() {
6349 let index =
6351 if let SearchItem::Path(_, _, ref metadata) = search_item {
6352 let item_modified = metadata.modified().ok();
6353 match items.binary_search_by(|other| {
6354 item_modified.cmp(&other.metadata.modified())
6355 }) {
6356 Ok(index) => index,
6357 Err(index) => index,
6358 }
6359 } else {
6360 items.len()
6361 };
6362
6363 if index < MAX_SEARCH_RESULTS {
6364 let item =
6366 item_from_search_item(search_item, IconSizes::default());
6367 items.insert(index, item);
6368 }
6369 if !finished && duration.elapsed() >= MAX_SEARCH_LATENCY {
6371 break;
6372 }
6373 }
6374 }
6375 if items.len() >= MAX_SEARCH_RESULTS {
6376 items.truncate(MAX_SEARCH_RESULTS);
6377 if let Some(last_modified) =
6378 items.last().and_then(|item| item.metadata.modified())
6379 {
6380 *context.last_modified_opt.write().unwrap() = Some(last_modified);
6381 }
6382 }
6383 } else {
6384 log::warn!("search ready but items array is empty");
6385 }
6386 }
6387 if finished {
6388 self.search_context = None;
6389 }
6390 }
6391 Message::SelectAll => {
6392 self.select_all();
6393 if self.select_focus.take().is_some() {
6394 commands.push(Command::Iced(
6396 widget::button::focus(widget::Id::unique()).into(),
6397 ));
6398 }
6399 }
6400 Message::SelectFirst => {
6401 if self.select_position(0, 0, mod_shift) {
6402 if let Some(offset) = self.select_focus_scroll() {
6403 commands.push(Command::Iced(
6404 scrollable::scroll_to(
6405 self.scrollable_id.clone(),
6406 AbsoluteOffset {
6407 x: Some(offset.x),
6408 y: Some(offset.y),
6409 },
6410 )
6411 .into(),
6412 ));
6413 }
6414 if let Some(id) = self.select_focus_id() {
6415 commands.push(Command::Iced(widget::button::focus(id).into()));
6416 }
6417 }
6418 }
6419 Message::SelectLast => {
6420 if let Some(ref items) = self.items_opt
6421 && let Some(last_pos) = items.iter().filter_map(|item| item.pos_opt.get()).max()
6422 && self.select_position(last_pos.0, last_pos.1, mod_shift)
6423 {
6424 if let Some(offset) = self.select_focus_scroll() {
6425 commands.push(Command::Iced(
6426 scrollable::scroll_to(
6427 self.scrollable_id.clone(),
6428 AbsoluteOffset {
6429 x: Some(offset.x),
6430 y: Some(offset.y),
6431 },
6432 )
6433 .into(),
6434 ));
6435 }
6436 if let Some(id) = self.select_focus_id() {
6437 commands.push(Command::Iced(widget::button::focus(id).into()));
6438 }
6439 }
6440 }
6441 Message::SetOpenWith(mime, id) => {
6442 commands.push(Command::SetOpenWith(mime, id));
6443 }
6444 Message::SetPermissions(path, mode) => {
6445 commands.push(Command::SetPermissions(path, mode));
6446 }
6447 Message::ShiftPermissions(path_mode_opt, shift, bits) => match path_mode_opt {
6448 Some((path, mode)) => commands.push(Command::SetPermissions(
6449 path,
6450 set_mode_part(mode, shift, bits),
6451 )),
6452 None => {
6454 let permissions = Vec::new();
6455 for _item in self.items_opt().map_or(Vec::new(), |items| {
6456 items.iter().filter(|item| item.selected).collect()
6457 }) {
6458 #[cfg(unix)]
6459 if let (Some(path), Some(mode)) = (
6460 item.path_opt(),
6461 item.file_metadata().map(|metadata| metadata.mode()),
6462 ) {
6463 permissions.push((path.clone(), set_mode_part(mode, shift, bits)));
6464 }
6465 }
6466 commands.push(Command::SetMultiplePermissions(permissions));
6467 }
6468 },
6469 Message::SetSort(heading_option, dir) => {
6470 if !matches!(self.location, Location::Search(..)) {
6471 self.sort_name = heading_option;
6472 self.sort_direction = dir;
6473 if !matches!(self.location, Location::Desktop(..)) {
6474 commands.push(Command::SetSort(
6475 self.location.normalize().to_string(),
6476 heading_option,
6477 self.sort_direction,
6478 ));
6479 }
6480 }
6481 }
6482 Message::TabComplete(path, completions) => {
6483 if let Some(edit_location) = &mut self.edit_location
6484 && edit_location.location.path_opt() == Some(&path)
6485 {
6486 edit_location.completions = Some(completions);
6487 commands.push(Command::Iced(
6488 widget::text_input::focus(self.edit_location_id.clone()).into(),
6489 ));
6490 }
6491 }
6492 Message::Thumbnail(path, thumbnail) => {
6493 if let Some(ref mut items) = self.items_opt {
6494 let location = Location::Path(path);
6495 for item in items.iter_mut() {
6496 if item.location_opt.as_ref() == Some(&location) {
6497 let handle_opt = match &thumbnail {
6498 ItemThumbnail::NotImage => None,
6499 ItemThumbnail::Image(handle, _) => Some(widget::icon::Handle {
6500 symbolic: false,
6501 data: widget::icon::Data::Image(handle.clone()),
6502 }),
6503 ItemThumbnail::Svg(handle) => Some(widget::icon::Handle {
6504 symbolic: false,
6505 data: widget::icon::Data::Svg(handle.clone()),
6506 }),
6507 ItemThumbnail::Text(_text) => None,
6509 };
6510 if let Some(handle) = handle_opt {
6511 item.icon_handle_grid.clone_from(&handle);
6512 item.icon_handle_list.clone_from(&handle);
6513 item.icon_handle_list_condensed = handle;
6514 }
6515 item.thumbnail_opt = Some(thumbnail);
6516 break;
6517 }
6518 }
6519 }
6520 }
6521 Message::ImageDecoded(path, width, height, pixels, display_size, generation) => {
6522 let handle = widget::image::Handle::from_rgba(width, height, pixels);
6524
6525 self.large_image_manager.store_decoded_with_generation(
6527 path,
6528 handle,
6529 display_size,
6530 generation,
6531 );
6532 }
6533 Message::ToggleSort(heading_option) => {
6534 if !matches!(self.location, Location::Search(..)) {
6535 let heading_sort = if self.sort_name == heading_option {
6536 !self.sort_direction
6537 } else {
6538 heading_option != HeadingOptions::Modified
6540 };
6541
6542 if !matches!(self.location, Location::Desktop(..)) {
6543 commands.push(Command::SetSort(
6544 self.location.normalize().to_string(),
6545 heading_option,
6546 heading_sort,
6547 ));
6548 }
6549
6550 self.sort_direction = heading_sort;
6551 self.sort_name = heading_option;
6552 }
6553 }
6554 Message::Drop(Some((to, mut from))) => {
6555 self.dnd_hovered = None;
6556 match to {
6557 Location::Desktop(to, ..)
6558 | Location::Path(to)
6559 | Location::Network(_, _, Some(to)) => {
6560 if let Ok(entries) = fs::read_dir(&to) {
6561 for i in entries.into_iter().filter_map(Result::ok) {
6562 let i = i.path();
6563 from.paths.retain(|p| &i != p);
6564 if from.paths.is_empty() {
6565 log::info!("All dropped files already in target directory.");
6566 return commands;
6567 }
6568 }
6569 }
6570 commands.push(Command::DropFiles(to, from));
6571 }
6572 Location::Trash if matches!(from.kind, ClipboardKind::Cut { .. }) => {
6573 commands.push(Command::Delete(from.paths));
6574 }
6575 _ => {
6576 log::warn!("{:?} to {:?} is not supported.", from.kind, to);
6577 }
6578 }
6579 }
6580 Message::Drop(None) => {
6581 self.dnd_hovered = None;
6582 }
6583 Message::DndHover(loc) => {
6584 if self
6585 .dnd_hovered
6586 .as_ref()
6587 .is_some_and(|(l, i)| *l == loc && i.elapsed() > HOVER_DURATION)
6588 {
6589 cd = Some(loc);
6590 }
6591 }
6592 Message::DndEnter(loc) => {
6593 self.dnd_hovered = Some((loc.clone(), Instant::now()));
6594 if loc != self.location {
6595 commands.push(Command::Iced(
6596 cosmic::Task::future(async move {
6597 tokio::time::sleep(HOVER_DURATION).await;
6598 Message::DndHover(loc)
6599 })
6600 .into(),
6601 ));
6602 }
6603 }
6604 Message::DndLeave(loc) => {
6605 if Some(&loc) == self.dnd_hovered.as_ref().map(|(l, _)| l) {
6606 self.dnd_hovered = None;
6607 }
6608 }
6609 Message::WindowDrag => {
6610 commands.push(Command::WindowDrag);
6611 }
6612 Message::WindowToggleMaximize => {
6613 commands.push(Command::WindowToggleMaximize);
6614 }
6615 Message::ZoomIn => {
6616 commands.push(Command::Action(Action::ZoomIn));
6617 }
6618 Message::ZoomOut => {
6619 commands.push(Command::Action(Action::ZoomOut));
6620 }
6621 Message::DirectorySize(path, dir_size) => {
6622 let location = Location::Path(path);
6623 if let Some(ref mut item) = self.parent_item_opt
6624 && item.location_opt.as_ref() == Some(&location)
6625 {
6626 item.dir_size.clone_from(&dir_size);
6627 }
6628 if let Some(ref mut items) = self.items_opt {
6629 for item in items.iter_mut() {
6630 if item.location_opt.as_ref() == Some(&location) {
6631 item.dir_size = dir_size;
6632 break;
6633 }
6634 }
6635 }
6636 }
6637 Message::OpenSeqAlignment(hit) => {
6638 commands.push(Command::OpenSeqAlignment(hit));
6639 }
6640 Message::OpenSpeciesAlignment(hit) => {
6641 commands.push(Command::OpenSpeciesAlignment(hit));
6642 }
6643 }
6644
6645 if self.scroll_opt.is_none() {
6647 let offset = AbsoluteOffset { x: 0.0, y: 0.0 };
6648 self.scroll_opt = Some(offset);
6649 commands.push(Command::Iced(
6650 scrollable::scroll_to(
6651 self.scrollable_id.clone(),
6652 AbsoluteOffset {
6653 x: Some(0.0),
6654 y: Some(0.0),
6655 },
6656 )
6657 .into(),
6658 ));
6659 }
6660
6661 if let Some(mut location) = cd {
6663 location = location.normalize();
6664 if matches!(self.mode, Mode::Desktop) {
6665 match location {
6666 Location::Path(path) => {
6667 commands.push(Command::OpenFile(vec![path]));
6668 }
6669 Location::Trash => {
6670 commands.push(Command::OpenTrash);
6671 }
6672 _ => {}
6673 }
6674 } else {
6675 let mut selected_paths = None;
6677 if let Some(path) = location.path_opt()
6678 && !matches!(location, Location::Remote(..))
6679 && !path.is_dir()
6680 && let Some(parent) = path.parent()
6681 {
6682 selected_paths = Some(vec![path.clone()]);
6683 location = location.with_path(parent.to_path_buf());
6684 }
6685 if location != self.location || selected_paths.is_some() {
6686 if matches!(location, Location::Remote(..))
6687 || location.path_opt().is_none_or(|path| path.is_dir())
6688 {
6689 if selected_paths.is_none() {
6690 selected_paths =
6691 self.location.path_opt().map(|path| vec![path.clone()]);
6692 }
6693 self.change_location(&location, history_i_opt);
6694 commands.push(Command::ChangeLocation(
6695 self.title(),
6696 location,
6697 selected_paths,
6698 ));
6699 } else {
6700 log::warn!("tried to cd to {location:?} which is not a directory");
6701 }
6702 }
6703 }
6704 }
6705
6706 if self.context_menu != last_context_menu {
6708 if last_context_menu.is_some() {
6709 commands.push(Command::ContextMenu(None, self.window_id));
6710 }
6711 if let Some(point) = self.context_menu {
6712 commands.push(Command::ContextMenu(Some(point), self.window_id));
6713 }
6714 }
6715
6716 commands
6717 }
6718
6719 pub(crate) const fn sort_options(&self) -> (HeadingOptions, bool, bool) {
6720 match self.location {
6721 Location::Search(..) => (HeadingOptions::Modified, false, false),
6722 _ => (
6723 self.sort_name,
6724 self.sort_direction,
6725 self.config.folders_first,
6726 ),
6727 }
6728 }
6729
6730 fn column_sort(&self) -> Option<Vec<(usize, &Item)>> {
6731 let check_reverse = |ord: Ordering, sort: bool| {
6732 if sort { ord } else { ord.reverse() }
6733 };
6734 let mut items: Vec<_> = self.items_opt.as_ref()?.iter().enumerate().collect();
6735 let (sort_name, sort_direction, folders_first) = self.sort_options();
6736 match sort_name {
6737 HeadingOptions::Size => {
6738 items.sort_by(|a, b| {
6739 let get_size = |x: &Item| match &x.metadata {
6741 ItemMetadata::Path {
6742 metadata,
6743 children_opt,
6744 ..
6745 } => {
6746 if metadata.is_dir() {
6747 (true, children_opt.unwrap_or_default() as u64)
6748 } else {
6749 (false, metadata.len())
6750 }
6751 }
6752 ItemMetadata::Trash { metadata, .. } => match metadata.size {
6753 trash::TrashItemSize::Entries(entries) => (true, entries as u64),
6754 trash::TrashItemSize::Bytes(bytes) => (false, bytes),
6755 },
6756 ItemMetadata::SimpleDir { entries } => (true, *entries),
6757 ItemMetadata::SimpleFile { size } => (false, *size),
6758 #[cfg(feature = "gvfs")]
6759 ItemMetadata::GvfsPath {
6760 size_opt,
6761 children_opt,
6762 ..
6763 } => match children_opt {
6764 Some(child_count) => (true, *child_count as u64),
6765 None => (false, size_opt.unwrap_or_default()),
6766 },
6767 #[cfg(feature = "russh")]
6768 ItemMetadata::RusshPath {
6769 size_opt,
6770 children_opt,
6771 ..
6772 } => match children_opt {
6773 Some(child_count) => (true, *child_count as u64),
6774 None => (false, size_opt.unwrap_or_default()),
6775 },
6776 };
6777 let (a_is_entry, a_size) = get_size(a.1);
6778 let (b_is_entry, b_size) = get_size(b.1);
6779
6780 match (a_is_entry, b_is_entry) {
6782 (true, false) => Ordering::Less,
6783 (false, true) => Ordering::Greater,
6784 _ => check_reverse(a_size.cmp(&b_size), sort_direction),
6785 }
6786 });
6787 }
6788 HeadingOptions::Name => items.sort_by(|a, b| {
6789 if folders_first {
6790 match (a.1.metadata.is_dir(), b.1.metadata.is_dir()) {
6791 (true, false) => Ordering::Less,
6792 (false, true) => Ordering::Greater,
6793 _ => check_reverse(
6794 LANGUAGE_SORTER.compare(&a.1.display_name, &b.1.display_name),
6795 sort_direction,
6796 ),
6797 }
6798 } else {
6799 check_reverse(
6800 LANGUAGE_SORTER.compare(&a.1.display_name, &b.1.display_name),
6801 sort_direction,
6802 )
6803 }
6804 }),
6805 HeadingOptions::Modified => {
6806 items.sort_by(|a, b| {
6807 let a_modified = a.1.metadata.modified();
6808 let b_modified = b.1.metadata.modified();
6809 if folders_first {
6810 match (a.1.metadata.is_dir(), b.1.metadata.is_dir()) {
6811 (true, false) => Ordering::Less,
6812 (false, true) => Ordering::Greater,
6813 _ => check_reverse(a_modified.cmp(&b_modified), sort_direction),
6814 }
6815 } else {
6816 check_reverse(a_modified.cmp(&b_modified), sort_direction)
6817 }
6818 });
6819 }
6820 HeadingOptions::TrashedOn => {
6821 let time_deleted = |x: &Item| match &x.metadata {
6822 ItemMetadata::Trash { entry, .. } => Some(entry.time_deleted),
6823 _ => None,
6824 };
6825
6826 items.sort_by(|a, b| {
6827 let a_time_deleted = time_deleted(a.1);
6828 let b_time_deleted = time_deleted(b.1);
6829 if folders_first {
6830 match (a.1.metadata.is_dir(), b.1.metadata.is_dir()) {
6831 (true, false) => Ordering::Less,
6832 (false, true) => Ordering::Greater,
6833 _ => check_reverse(a_time_deleted.cmp(&b_time_deleted), sort_direction),
6834 }
6835 } else {
6836 check_reverse(b_time_deleted.cmp(&a_time_deleted), sort_direction)
6837 }
6838 });
6839 }
6840 }
6841 Some(items)
6842 }
6843
6844 fn dnd_dest<'a>(
6845 &self,
6846 location: &Location,
6847 element: impl Into<Element<'a, Message>>,
6848 ) -> Element<'a, Message> {
6849 let location1 = location.clone();
6850 let location2 = location.clone();
6851 let location3 = location.clone();
6852 let is_dnd_hovered = self.dnd_hovered.as_ref().map(|(l, _)| l) == Some(location);
6853 let mut container = widget::container(
6854 DndDestination::for_data::<ClipboardPaste>(element, move |data, action| {
6855 if let Some(mut data) = data {
6856 if action == DndAction::Copy {
6857 Message::Drop(Some((location1.clone(), data)))
6858 } else if action == DndAction::Move {
6859 data.kind = ClipboardKind::Cut { is_dnd: true };
6860 Message::Drop(Some((location1.clone(), data)))
6861 } else {
6862 log::warn!("unsupported action: {action:?}");
6863 Message::Drop(None)
6864 }
6865 } else {
6866 Message::Drop(None)
6867 }
6868 })
6869 .on_enter(move |_, _, _| Message::DndEnter(location2.clone()))
6870 .on_leave(move || Message::DndLeave(location3.clone())),
6871 );
6872 if is_dnd_hovered && !matches!(self.mode, Mode::Desktop) {
6874 container = container.style(|t| {
6875 let mut a = widget::container::Style::default();
6876 let t = t.cosmic();
6877 let mut bg = t.accent_color();
6879 bg.alpha = 0.2;
6880 a.background = Some(Color::from(bg).into());
6881 a.border = Border {
6882 color: t.accent_color().into(),
6883 width: 1.0,
6884 radius: t.radius_s().into(),
6885 };
6886 a
6887 });
6888 }
6889 container.into()
6890 }
6891
6892 pub fn gallery_view(&self) -> Element<'_, Message> {
6893 let cosmic_theme::Spacing {
6894 space_xxs,
6895 space_xs,
6896 space_m,
6897 ..
6898 } = theme::spacing();
6899
6900 let mut name_opt = None;
6902 let mut element_opt: Option<Element<Message>> = None;
6903 if let Some(index) = self.select_focus
6904 && let Some(items) = &self.items_opt
6905 && let Some(item) = items.get(index)
6906 {
6907 name_opt = Some(widget::text::heading(&item.display_name));
6908 match item
6909 .thumbnail_opt
6910 .as_ref()
6911 .unwrap_or(&ItemThumbnail::NotImage)
6912 {
6913 ItemThumbnail::NotImage => {}
6914 ItemThumbnail::Image(handle, original_dims) => {
6915 let mut is_loading = false;
6917 let mut error_msg_opt = None;
6918 let image_handle = if let Some(path) = item.path_opt() {
6919 if let Some(error_msg) = self.large_image_manager.get_error(path) {
6920 error_msg_opt = Some(error_msg.clone());
6921 handle.clone()
6922 } else if self.large_image_manager.is_decoding(path) {
6923 is_loading = true;
6925 self.large_image_manager
6927 .get_decoded(path)
6928 .cloned()
6929 .unwrap_or_else(|| handle.clone())
6930 } else if let Some(decoded_handle) =
6931 self.large_image_manager.get_decoded(path)
6932 {
6933 decoded_handle.clone()
6935 } else if let Some((w, h)) = original_dims {
6936 if should_use_tiling(*w, *h) {
6938 handle.clone()
6940 } else {
6941 widget::image::Handle::from_path(path)
6943 }
6944 } else {
6945 handle.clone()
6947 }
6948 } else {
6949 handle.clone()
6950 };
6951
6952 let content: cosmic::Element<'_, Message> =
6953 if let Some(error_msg) = error_msg_opt {
6954 widget::column::with_capacity(2)
6955 .push(widget::image(image_handle))
6956 .push(widget::text(format!("âš {}", error_msg)).size(13))
6957 .padding(space_xs)
6958 .align_x(cosmic::iced::Alignment::Center)
6959 .into()
6960 } else if is_loading {
6961 widget::column::with_capacity(2)
6962 .push(widget::image(image_handle))
6963 .push(widget::text("Loading higher resolution...").size(14))
6964 .padding(space_xs)
6965 .align_x(cosmic::iced::Alignment::Center)
6966 .into()
6967 } else {
6968 crate::load_image::loaded_image(image_handle).into()
6970 };
6971
6972 element_opt = Some(widget::container(content).center(Length::Fill).into());
6973 }
6974 ItemThumbnail::Svg(handle) => {
6975 element_opt = Some(
6976 widget::svg(handle.clone())
6977 .width(Length::Fill)
6978 .height(Length::Fill)
6979 .into(),
6980 );
6981 }
6982 ItemThumbnail::Text(text) => {
6983 element_opt = Some(
6984 widget::container(widget::text_editor(text).padding(space_xxs).class(
6985 cosmic::theme::iced::TextEditor::Custom(Box::new(text_editor_class)),
6986 ))
6987 .center(Length::Fill)
6988 .into(),
6989 );
6990 }
6991 }
6992 }
6993
6994 let mut column = widget::column::with_capacity(2);
6995 column = column.push(widget::space::vertical().height(Length::Fixed(space_m.into())));
6996 {
6997 let mut row = widget::row::with_capacity(5).align_y(Alignment::Center);
6998 row = row.push(widget::space::horizontal());
6999 if let Some(name) = name_opt {
7000 row = row.push(name);
7001 }
7002 row = row.push(widget::space::horizontal());
7003 row = row.push(
7004 widget::button::icon(widget::icon::from_name("window-close-symbolic"))
7005 .class(theme::Button::Standard)
7006 .on_press(Message::Gallery(false)),
7007 );
7008 row = row.push(widget::space::horizontal().width(Length::Fixed(space_m.into())));
7009 let mouse_area = mouse_area::MouseArea::new(row)
7011 .on_press(|_| Message::WindowDrag)
7012 .on_double_click(|_| Message::WindowToggleMaximize);
7013 column = column.push(mouse_area);
7014 }
7015 {
7016 let mut row = widget::row::with_capacity(7).align_y(Alignment::Center);
7017 row = row.push(widget::space::horizontal().width(Length::Fixed(space_m.into())));
7018 row = row.push(
7019 widget::button::icon(widget::icon::from_name("go-previous-symbolic"))
7020 .padding(space_xs)
7021 .class(theme::Button::Standard)
7022 .on_press(Message::GalleryPrevious),
7023 );
7024 row = row.push(widget::space::horizontal().width(Length::Fixed(space_xxs.into())));
7025 if let Some(element) = element_opt {
7026 row = row.push(element);
7027 } else {
7028 row = row.push(space::horizontal().width(Length::Fill));
7030 row = row.push(space::vertical().height(Length::Fill));
7031 }
7032 row = row.push(widget::space::horizontal().width(Length::Fixed(space_xxs.into())));
7033 row = row.push(
7034 widget::button::icon(widget::icon::from_name("go-next-symbolic"))
7035 .padding(space_xs)
7036 .class(theme::Button::Standard)
7037 .on_press(Message::GalleryNext),
7038 );
7039 row = row.push(widget::space::horizontal().width(Length::Fixed(space_m.into())));
7040 column = column.push(row);
7041 }
7042
7043 widget::container(column)
7044 .width(Length::Fill)
7045 .height(Length::Fill)
7046 .style(|theme| {
7047 let cosmic = theme.cosmic();
7048 let mut bg = cosmic.bg_color();
7049 bg.alpha = 0.75;
7050 widget::container::Style {
7051 background: Some(Color::from(bg).into()),
7052 ..Default::default()
7053 }
7054 })
7055 .into()
7056 }
7057
7058 pub fn location_view(&self) -> Element<'_, Message> {
7059 fn text_width<'a>(
7061 content: &'a str,
7062 font: font::Font,
7063 font_size: f32,
7064 line_height: f32,
7065 ) -> f32 {
7066 let text: text::Text<&'a str, font::Font> = text::Text {
7067 content,
7068 bounds: Size::INFINITE,
7069 size: font_size.into(),
7070 line_height: text::LineHeight::Absolute(line_height.into()),
7071 font,
7072 align_x: text::Alignment::Left,
7073 align_y: Vertical::Top,
7074 shaping: text::Shaping::default(),
7075 wrapping: text::Wrapping::None,
7076 ellipsize: text::Ellipsize::End(text::EllipsizeHeightLimit::Lines(1)),
7077 };
7078 graphics::text::Paragraph::with_text(text)
7079 .min_bounds()
7080 .width
7081 }
7082 fn text_width_body(content: &str) -> f32 {
7083 text_width(content, font::default(), 14.0, 20.0)
7085 }
7086 fn text_width_heading(content: &str) -> f32 {
7087 text_width(content, font::semibold(), 14.0, 20.0)
7088 }
7089
7090 let cosmic_theme::Spacing {
7091 space_xxxs,
7092 space_xxs,
7093 space_s,
7094 space_m,
7095 ..
7096 } = theme::spacing();
7097
7098 let size = self.size_opt.get().unwrap_or(Size::new(0.0, 0.0));
7099
7100 let mut row = widget::row::with_capacity(5)
7101 .align_y(Alignment::Center)
7102 .padding([space_xxxs, 0]);
7103 let mut w = 0.0;
7104
7105 let mut prev_button =
7106 widget::button::custom(widget::icon::from_name("go-previous-symbolic").size(16))
7107 .padding(space_xxs)
7108 .class(theme::Button::Icon);
7109 if self.history_i > 0 && !self.history.is_empty() {
7110 prev_button = prev_button.on_press(Message::GoPrevious);
7111 }
7112 row = row.push(prev_button);
7113 w += f32::from(space_xxs).mul_add(2.0, 16.0);
7114
7115 let mut next_button =
7116 widget::button::custom(widget::icon::from_name("go-next-symbolic").size(16))
7117 .padding(space_xxs)
7118 .class(theme::Button::Icon);
7119 if self.history_i + 1 < self.history.len() {
7120 next_button = next_button.on_press(Message::GoNext);
7121 }
7122 row = row.push(next_button);
7123 w += f32::from(space_xxs).mul_add(2.0, 16.0);
7124
7125 row = row.push(widget::space::horizontal().width(Length::Fixed(space_s.into())));
7126 w += f32::from(space_s);
7127
7128 let name_width = 300.0;
7130 let modified_width = 200.0;
7131 let size_width = 100.0;
7132 let condensed = size.width < (name_width + modified_width + size_width);
7133
7134 let (sort_name, sort_direction, _) = self.sort_options();
7135 let heading_item = |name, width, msg| {
7136 let mut row = widget::row::with_capacity(2)
7137 .align_y(Alignment::Center)
7138 .spacing(space_xxxs)
7139 .width(width);
7140 row = row.push(widget::text::heading(name));
7141 match (sort_name == msg, sort_direction) {
7142 (true, true) => {
7143 row = row.push(widget::icon::from_name("pan-down-symbolic").size(16));
7144 }
7145 (true, false) => {
7146 row = row.push(widget::icon::from_name("pan-up-symbolic").size(16));
7147 }
7148 _ => {}
7149 }
7150 mouse_area::MouseArea::new(row)
7152 .on_press(move |_point_opt| Message::ToggleSort(msg))
7153 .into()
7154 };
7155
7156 let heading_row = widget::row::with_children([
7157 heading_item(fl!("name"), Length::Fill, HeadingOptions::Name),
7158 if self.location.is_trash() {
7159 heading_item(
7160 fl!("trashed-on"),
7161 Length::Fixed(modified_width),
7162 HeadingOptions::TrashedOn,
7163 )
7164 } else {
7165 heading_item(
7166 fl!("modified"),
7167 Length::Fixed(modified_width),
7168 HeadingOptions::Modified,
7169 )
7170 },
7171 heading_item(fl!("size"), Length::Fixed(size_width), HeadingOptions::Size),
7172 ])
7173 .align_y(Alignment::Center)
7174 .height(Length::Fixed((space_m + 4).into()))
7175 .padding([0, space_xxs]);
7176
7177 let accent_rule =
7178 rule::horizontal(1).class(theme::Rule::Custom(Box::new(|theme| rule::Style {
7179 color: theme.cosmic().accent_color().into(),
7180 radius: 0.0.into(),
7181 fill_mode: rule::FillMode::Full,
7182 snap: true,
7183 })));
7184 let heading_rule = widget::container(rule::horizontal(1))
7185 .padding([0, theme::active().cosmic().corner_radii.radius_xs[0] as u16]);
7186
7187 if let Some(edit_location) = &self.edit_location {
7188 let mut text_input = None;
7189
7190 if let Location::Network(ref uri, ..) = edit_location.location {
7192 let location = edit_location.location.clone();
7193 text_input = Some(
7194 widget::text_input("", uri.clone())
7195 .id(self.edit_location_id.clone())
7196 .on_input(move |input| {
7197 Message::EditLocation(Some(location.with_uri(input).into()))
7198 })
7199 .on_submit(|_| Message::EditLocationSubmit)
7200 .line_height(1.0),
7201 );
7202 } else if let Location::Remote(ref uri, ..) = edit_location.location {
7203 let location = edit_location.location.clone();
7204 text_input = Some(
7205 widget::text_input("", uri.clone())
7206 .id(self.edit_location_id.clone())
7207 .on_input(move |input| {
7208 Message::EditLocation(Some(location.with_uri(input).into()))
7209 })
7210 .on_submit(|_| Message::EditLocationSubmit)
7211 .line_height(1.0),
7212 );
7213 } else if let Some(resolved_location) = edit_location.resolve()
7214 && let Some(path) = resolved_location.path_opt().cloned()
7215 {
7216 text_input = Some(
7217 widget::text_input("", path.to_string_lossy().into_owned())
7218 .id(self.edit_location_id.clone())
7219 .on_input(move |input| {
7220 Message::EditLocation(Some(
7221 resolved_location.with_path(PathBuf::from(input)).into(),
7222 ))
7223 })
7224 .on_submit(|_| Message::EditLocationSubmit)
7225 .on_tab(Message::EditLocationTab)
7226 .on_unfocus(Message::EditLocation(None))
7227 .line_height(1.0),
7228 );
7229 }
7230 if let Some(text_input) = text_input {
7231 row = row.push(
7232 widget::button::custom(
7233 widget::icon::from_name("window-close-symbolic").size(16),
7234 )
7235 .on_press(Message::EditLocation(None))
7236 .padding(space_xxs)
7237 .class(theme::Button::Icon),
7238 );
7239 let mut popover =
7240 widget::popover(text_input).position(widget::popover::Position::Bottom);
7241 if let Some(completions) = &edit_location.completions
7242 && !completions.is_empty()
7243 {
7244 let mut column =
7245 widget::column::with_capacity(completions.len()).padding(space_xxs);
7246 for (i, (name, _path)) in completions.iter().enumerate() {
7247 let selected = edit_location.selected == Some(i);
7248 column = column.push(
7249 widget::button::custom(widget::text::body(name))
7250 .class(if selected {
7252 theme::Button::Standard
7253 } else {
7254 theme::Button::HeaderBar
7255 })
7256 .on_press(Message::EditLocationComplete(i))
7257 .padding(space_xxs)
7258 .width(Length::Fill),
7259 );
7260 }
7261 popover = popover.popup(
7262 widget::container(column)
7263 .class(theme::Container::Dropdown)
7264 .max_width(size.width - 140.0),
7266 );
7267 }
7268 row = row.push(popover);
7269 let mut column = widget::column::with_capacity(4).padding([0, space_s]);
7270 column = column.push(row);
7271 column = column.push(accent_rule);
7272 if self.config.view == View::List && !condensed {
7273 column = column.push(heading_row);
7274 column = column.push(heading_rule);
7275 }
7276 return column.into();
7277 }
7278 } else if let Some(path) = self.location.path_opt() {
7279 row = row.push(
7280 crate::mouse_area::MouseArea::new(
7281 widget::button::custom(widget::icon::from_name("edit-symbolic").size(16))
7282 .padding(space_xxs)
7283 .class(theme::Button::Icon)
7284 .on_press(Message::EditLocation(Some(self.location.clone().into()))),
7285 )
7286 .on_middle_press(move |_| Message::OpenInNewTab(path.clone())),
7287 );
7288 w += f32::from(space_xxs).mul_add(2.0, 16.0);
7289 }
7290
7291 let mut children: Vec<Element<_>> = Vec::new();
7292 match &self.location {
7293 Location::Desktop(path, ..)
7294 | Location::Path(path)
7295 | Location::Search(SearchLocation::Path(path), ..) => {
7296 let excess_str = "...";
7297 let excess_width = text_width_body(excess_str);
7298 for (index, ancestor) in path.ancestors().enumerate() {
7299 let (name, found_home) = folder_name(ancestor);
7300 let (name_width, name_text) = if children.is_empty() {
7301 (
7302 text_width_heading(&name),
7303 widget::text::heading(name)
7304 .wrapping(text::Wrapping::None)
7305 .ellipsize(text::Ellipsize::End(
7306 text::EllipsizeHeightLimit::Lines(1),
7307 )),
7308 )
7309 } else {
7310 children.push(
7311 widget::icon::from_name("go-next-symbolic")
7312 .size(16)
7313 .icon()
7314 .into(),
7315 );
7316 w += 16.0;
7317 (
7318 text_width_body(&name),
7319 widget::text::body(name).wrapping(text::Wrapping::None),
7320 )
7321 };
7322
7323 w += 2.0 * f32::from(space_xxxs);
7325
7326 let mut row = widget::row::with_capacity(2)
7327 .align_y(Alignment::Center)
7328 .spacing(space_xxxs);
7329 let overflow_offset = 64.0;
7331 let overflow = w + name_width + overflow_offset > size.width && index > 0;
7332 if overflow {
7333 row = row.push(widget::text::body(excess_str));
7334 w += excess_width;
7335 } else {
7336 row = row.push(name_text);
7337 w += name_width;
7338 }
7339
7340 let location = self.location.with_path(ancestor.to_path_buf());
7341 let mut mouse_area = crate::mouse_area::MouseArea::new(
7342 widget::button::custom(row)
7343 .padding(space_xxxs)
7344 .class(theme::Button::Link)
7345 .on_press(if ancestor == path {
7346 Message::EditLocation(Some(self.location.clone().into()))
7347 } else {
7348 Message::Location(location.clone())
7349 }),
7350 );
7351
7352 if self.location_context_menu_index.is_some() {
7353 mouse_area = mouse_area
7354 .on_right_press(move |point_opt| {
7355 Message::LocationContextMenuIndex(point_opt, None)
7356 })
7357 .wayland_on_right_press_window_position();
7358 } else {
7359 mouse_area = mouse_area
7360 .on_right_press_no_capture()
7361 .on_right_press(move |point_opt| {
7362 Message::LocationContextMenuIndex(point_opt, Some(index))
7363 })
7364 .wayland_on_right_press_window_position();
7365 }
7366
7367 let mouse_area = if let Location::Path(_) = &self.location {
7368 mouse_area
7369 .on_middle_press(move |_| Message::OpenInNewTab(ancestor.to_path_buf()))
7370 } else {
7371 mouse_area
7372 };
7373
7374 children.push(self.dnd_dest(&location, mouse_area));
7375
7376 if found_home || overflow {
7377 break;
7378 }
7379 }
7380 children.reverse();
7381 }
7382 Location::Trash | Location::Search(SearchLocation::Trash, ..) => {
7383 children.push(
7384 widget::button::custom(widget::text::heading(fl!("trash")))
7385 .padding(space_xxxs)
7386 .on_press(Message::Location(Location::Trash))
7387 .class(theme::Button::Text)
7388 .into(),
7389 );
7390 }
7391 Location::Recents | Location::Search(SearchLocation::Recents, ..) => {
7392 children.push(
7393 widget::button::custom(widget::text::heading(fl!("recents")))
7394 .padding(space_xxxs)
7395 .on_press(Message::Location(Location::Recents))
7396 .class(theme::Button::Text)
7397 .into(),
7398 );
7399 }
7400 Location::Network(uri, display_name, path) => {
7401 children.push(
7402 widget::button::custom(widget::text::heading(display_name))
7403 .padding(space_xxxs)
7404 .on_press(Message::Location(Location::Network(
7405 uri.clone(),
7406 display_name.clone(),
7407 path.clone(),
7408 )))
7409 .class(theme::Button::Text)
7410 .into(),
7411 );
7412 }
7413 Location::Remote(uri, display_name, Some(path)) => {
7414 let excess_str = "...";
7415 let excess_width = text_width_body(excess_str);
7416
7417 let host_label = if let Ok(url) = url::Url::parse(uri) {
7419 let host = url.host_str().unwrap_or("");
7420 let username = url.username();
7421 if username.is_empty() {
7422 host.to_string()
7423 } else {
7424 format!("{}@{}", username, host)
7425 }
7426 } else {
7427 display_name.clone()
7428 };
7429
7430 for (index, ancestor) in path.ancestors().enumerate() {
7431 let name = if ancestor == Path::new("/") {
7432 host_label.clone()
7433 } else {
7434 ancestor
7435 .file_name()
7436 .and_then(|n| n.to_str())
7437 .unwrap_or_default()
7438 .to_string()
7439 };
7440
7441 let (name_width, name_text) = if children.is_empty() {
7442 (
7443 text_width_heading(&name),
7444 widget::text::heading(name)
7445 .wrapping(text::Wrapping::None)
7446 .ellipsize(text::Ellipsize::End(
7447 text::EllipsizeHeightLimit::Lines(1),
7448 )),
7449 )
7450 } else {
7451 children.push(
7452 widget::icon::from_name("go-next-symbolic")
7453 .size(16)
7454 .icon()
7455 .into(),
7456 );
7457 w += 16.0;
7458 (
7459 text_width_body(&name),
7460 widget::text::body(name).wrapping(text::Wrapping::None),
7461 )
7462 };
7463
7464 w += 2.0 * f32::from(space_xxxs);
7465
7466 let mut button_row = widget::row::with_capacity(2)
7467 .align_y(Alignment::Center)
7468 .spacing(space_xxxs);
7469 let overflow_offset = 64.0;
7470 let overflow = w + name_width + overflow_offset > size.width && index > 0;
7471 if overflow {
7472 button_row = button_row.push(widget::text::body(excess_str));
7473 w += excess_width;
7474 } else {
7475 button_row = button_row.push(name_text);
7476 w += name_width;
7477 }
7478
7479 let location = self.location.with_path(ancestor.to_path_buf());
7480 children.push(
7481 widget::button::custom(button_row)
7482 .padding(space_xxxs)
7483 .class(theme::Button::Link)
7484 .on_press(if ancestor == path.as_path() {
7485 Message::EditLocation(Some(self.location.clone().into()))
7486 } else {
7487 Message::Location(location)
7488 })
7489 .into(),
7490 );
7491
7492 if ancestor == Path::new("/") || overflow {
7493 break;
7494 }
7495 }
7496 children.reverse();
7497 }
7498 Location::Remote(uri, display_name, None) => {
7499 children.push(
7500 widget::button::custom(widget::text::heading(display_name))
7501 .padding(space_xxxs)
7502 .on_press(Message::Location(Location::Remote(
7503 uri.clone(),
7504 display_name.clone(),
7505 None,
7506 )))
7507 .class(theme::Button::Text)
7508 .into(),
7509 );
7510 }
7511 }
7512
7513 row = row.extend(children);
7514 let mut column = widget::column::with_capacity(4).padding([0, space_s]);
7515 column = column.push(row);
7516 column = column.push(accent_rule);
7517
7518 if self.config.view == View::List && !condensed {
7519 column = column.push(heading_row);
7520 column = column.push(heading_rule);
7521 }
7522
7523 let mouse_area = crate::mouse_area::MouseArea::new(column)
7524 .on_right_press(Message::LocationContextMenuPoint);
7525
7526 let mut popover = widget::popover(mouse_area);
7527 if let (Some(point), Some(index)) = (
7528 self.location_context_menu_point,
7529 self.location_context_menu_index,
7530 ) {
7531 popover = popover
7532 .popup(menu::location_context_menu(index))
7533 .position(widget::popover::Position::Point(point));
7534 }
7535
7536 popover.into()
7537 }
7538
7539 pub fn empty_view(&self, has_hidden: bool, susceptible_hidden: bool) -> Element<'_, Message> {
7540 let cosmic_theme::Spacing { space_xxs, .. } = theme::spacing();
7541
7542 mouse_area::MouseArea::new(widget::column::with_children([widget::container(
7543 match self.mode {
7544 Mode::App | Mode::Dialog(_) => widget::column::with_children([
7545 widget::icon::from_name("folder-symbolic")
7546 .size(64)
7547 .icon()
7548 .into(),
7549 widget::text::body(if has_hidden {
7550 fl!("empty-folder-hidden")
7551 } else if susceptible_hidden {
7552 fl!("empty-folder-susceptible-hidden")
7553 } else if matches!(self.location, Location::Search(..)) {
7554 fl!("no-results")
7555 } else {
7556 fl!("empty-folder")
7557 })
7558 .into(),
7559 ]),
7560 Mode::Desktop => widget::column::with_capacity(0),
7561 }
7562 .align_x(Alignment::Center)
7563 .spacing(space_xxs),
7564 )
7565 .center(Length::Fill)
7566 .into()]))
7567 .on_press(|_| Message::Click(None))
7568 .into()
7569 }
7570
7571 pub fn grid_view(
7572 &self,
7573 ) -> (
7574 Option<Element<'static, Message>>,
7575 Element<'_, Message>,
7576 bool,
7577 ) {
7578 let cosmic_theme::Spacing {
7579 space_xxs,
7580 space_xxxs,
7581 ..
7582 } = theme::spacing();
7583
7584 let TabConfig {
7585 show_hidden,
7586 show_susceptible,
7587 show_as_samples,
7588 mut icon_sizes,
7589 ..
7590 } = self.config;
7591
7592 let mut grid_spacing = space_xxs;
7593 if let Location::Desktop(_path, _output, desktop_config) = &self.location {
7594 icon_sizes.grid = desktop_config.icon_size;
7595 grid_spacing = desktop_config.grid_spacing_for(space_xxs);
7596 }
7597
7598 let text_height = 3 * 20; let item_width = (3 * space_xxs + icon_sizes.grid() + 3 * space_xxs) as usize;
7600 let item_height =
7601 (space_xxxs + icon_sizes.grid() + space_xxxs + text_height + space_xxxs) as usize;
7602
7603 let (width, height) = match self.size_opt.get() {
7604 Some(size) => (
7605 (size.width.floor() as usize)
7606 .saturating_sub(2 * (space_xxs as usize))
7607 .max(item_width),
7608 (size.height.floor() as usize).max(item_height),
7609 ),
7610 None => (item_width, item_height),
7611 };
7612
7613 let (cols, column_spacing) = {
7614 let width_m1 = width.saturating_sub(item_width);
7615 let cols_m1 = width_m1 / (item_width + grid_spacing as usize);
7616 let cols = cols_m1 + 1;
7617 let spacing = width_m1
7618 .checked_div(cols_m1)
7619 .unwrap_or(0)
7620 .saturating_sub(item_width);
7621 (cols, spacing as u16)
7622 };
7623
7624 let rows = {
7625 let height_m1 = height.saturating_sub(item_height);
7626 let rows_m1 = height_m1 / (item_height + grid_spacing as usize);
7627 rows_m1 + 1
7628 };
7629
7630 let visible_rect = {
7632 let max_scroll_y = self
7634 .content_height_opt
7635 .get()
7636 .map(|ch| (ch - height as f32).max(0.0))
7637 .unwrap_or(f32::MAX);
7638 let scroll_y = self
7639 .scroll_opt
7640 .map(|o| o.y.min(max_scroll_y).max(0.0))
7641 .unwrap_or(0.0);
7642 let point = Point::new(0.0, scroll_y);
7643 let size = self.size_opt.get().unwrap_or_else(|| Size::new(0.0, 0.0));
7644 Rectangle::new(point, size)
7645 };
7646
7647 let mut grid = widget::grid()
7648 .column_spacing(column_spacing)
7649 .row_spacing(grid_spacing)
7650 .padding(space_xxs.into());
7651 let mut dnd_items: Vec<(usize, (usize, usize), &Item)> = Vec::new();
7652 let mut drag_w_i = usize::MAX;
7653 let mut drag_n_i = usize::MAX;
7654 let mut drag_e_i = 0;
7655 let mut drag_s_i = 0;
7656
7657 let mut column = widget::column::with_capacity(2);
7658 if let Some(items) = self.column_sort() {
7659 let mut count = 0;
7660 let mut col = 0;
7661 let mut row = 0;
7662 let mut page_row = 0;
7663 let mut hidden = 0;
7664 let mut susceptible_hidden = 0;
7665 let mut grid_elements = Vec::new();
7666 for &(i, item) in &items {
7667 if !show_hidden && item.hidden {
7668 item.pos_opt.set(None);
7669 item.rect_opt.set(None);
7670 hidden += 1;
7671 continue;
7672 }
7673 if matches!(item.metadata.is_susceptible(), Some(true)) && !show_susceptible {
7674 item.pos_opt.set(None);
7675 item.rect_opt.set(None);
7676 susceptible_hidden += 1;
7677 continue;
7678 }
7679 if item
7680 .metadata
7681 .is_groupable_as_sample_tbprofiler_result_item()
7682 && show_as_samples
7683 {
7684 item.pos_opt.set(None);
7685 item.rect_opt.set(None);
7686 continue;
7687 }
7688 if item.metadata.is_tbprofiler_result_as_sample()
7689 && !item
7690 .metadata
7691 .is_groupable_as_sample_tbprofiler_result_item()
7692 && !show_as_samples
7693 {
7694 item.pos_opt.set(None);
7695 item.rect_opt.set(None);
7696 continue;
7697 }
7698 item.pos_opt.set(Some((row, col)));
7699 let item_rect = Rectangle::new(
7700 Point::new(
7701 (col * (item_width + column_spacing as usize) + space_xxs as usize) as f32,
7702 (row * (item_height + grid_spacing as usize) + space_xxs as usize) as f32,
7703 ),
7704 Size::new(item_width as f32, item_height as f32),
7705 );
7706 item.rect_opt.set(Some(item_rect));
7707
7708 while grid_elements.len() <= row {
7710 grid_elements.push(Vec::new());
7711 }
7712
7713 if item_rect.intersects(&visible_rect) {
7715 let buttons: Vec<Element<Message>> = vec![
7717 widget::button::custom(
7718 widget::icon::icon(item.icon_handle_grid.clone())
7719 .content_fit(ContentFit::Contain)
7720 .size(icon_sizes.grid()),
7721 )
7722 .padding(space_xxxs)
7723 .class(button_style(
7724 item.selected,
7725 item.highlighted,
7726 item.cut,
7727 false,
7728 false,
7729 false,
7730 ))
7731 .into(),
7732 widget::tooltip(
7733 widget::button::custom(Item::grid_display_name(&item.display_name))
7734 .id(item.button_id.clone())
7735 .padding([0, space_xxxs])
7736 .class(button_style(
7737 item.selected,
7738 item.highlighted,
7739 item.cut,
7740 true,
7741 true,
7742 matches!(self.mode, Mode::Desktop),
7743 )),
7744 widget::text::body(&item.name),
7745 widget::tooltip::Position::Bottom,
7746 )
7747 .into(),
7748 ];
7749
7750 let mut column = widget::column::with_capacity(buttons.len())
7751 .align_x(Alignment::Center)
7752 .height(Length::Fixed(item_height as f32))
7753 .width(Length::Fixed(item_width as f32));
7754 for button in buttons {
7755 if self.context_menu.is_some() {
7756 column = column.push(button);
7757 } else {
7758 column = column.push(
7759 mouse_area::MouseArea::new(button)
7760 .on_right_press_no_capture()
7761 .wayland_on_right_press_window_position()
7762 .on_right_press(move |point_opt| {
7763 Message::RightClick(point_opt, Some(i))
7764 }),
7765 );
7766 }
7767 }
7768
7769 let column: Element<Message> =
7770 if item.metadata.is_dir() && item.location_opt.is_some() {
7771 self.dnd_dest(&item.location_opt.clone().unwrap(), column)
7772 } else {
7773 column.into()
7774 };
7775
7776 if item.selected {
7777 dnd_items.push((i, (row, col), item));
7778 drag_w_i = drag_w_i.min(col);
7779 drag_n_i = drag_n_i.min(row);
7780 drag_e_i = drag_e_i.max(col);
7781 drag_s_i = drag_s_i.max(row);
7782 }
7783 let mouse_area = crate::mouse_area::MouseArea::new(column)
7784 .on_press(move |_| Message::Click(Some(i)))
7785 .on_double_click(move |_| Message::DoubleClick(Some(i)))
7786 .on_release(move |_| Message::ClickRelease(Some(i)))
7787 .on_middle_press(move |_| Message::MiddleClick(i))
7788 .on_enter(move || Message::HighlightActivate(i))
7789 .on_exit(move || Message::HighlightDeactivate(i));
7790 grid_elements[row].push(Element::from(mouse_area));
7791 } else {
7792 if grid_elements[row].is_empty() {
7794 grid_elements[row].push(Element::from(
7795 widget::column::with_capacity(0)
7796 .width(Length::Fill)
7797 .height(Length::Fixed(item_height as f32)),
7798 ));
7799 }
7800 }
7801
7802 count += 1;
7803 if matches!(self.mode, Mode::Desktop) {
7804 row += 1;
7805 if row >= page_row + rows {
7806 row = 0;
7807 col += 1;
7808 }
7809 if col >= cols {
7810 col = 0;
7811 page_row += rows;
7812 row = page_row;
7813 }
7814 } else {
7815 col += 1;
7816 if col >= cols {
7817 col = 0;
7818 row += 1;
7819 }
7820 }
7821 }
7822
7823 for row_elements in grid_elements {
7824 for element in row_elements {
7825 grid = grid.push(element);
7826 }
7827 grid = grid.insert_row();
7828 }
7829
7830 if count == 0 {
7831 return (
7832 None,
7833 self.empty_view(hidden > 0, susceptible_hidden > 0),
7834 false,
7835 );
7836 }
7837
7838 column = column.push(grid);
7839
7840 {
7842 let mut max_bottom = 0;
7843 for (_, item) in items {
7844 if let Some(rect) = item.rect_opt.get() {
7845 let bottom = (rect.y + rect.height).ceil() as usize;
7846 if bottom > max_bottom {
7847 max_bottom = bottom;
7848 }
7849 }
7850 }
7851
7852 self.content_height_opt.set(Some(max_bottom as f32));
7854
7855 let top_deduct = 7 * (space_xxs as usize);
7856
7857 self.item_view_size_opt
7858 .set(self.size_opt.get().map(|s| Size {
7859 width: s.width,
7860 height: s.height - top_deduct as f32,
7861 }));
7862
7863 let spacer_height = height.saturating_sub(max_bottom + top_deduct);
7864 if spacer_height > 0 {
7865 column = column.push(widget::container(
7866 space::vertical().height(Length::Fixed(spacer_height as f32)),
7867 ));
7868 }
7869 }
7870 }
7871
7872 let drag_list = (!dnd_items.is_empty()).then(|| {
7873 let mut dnd_grid = widget::grid()
7874 .column_spacing(column_spacing)
7875 .row_spacing(grid_spacing)
7876 .padding(space_xxs.into());
7877
7878 let mut dnd_item_i = 0;
7879 for r in drag_n_i..=drag_s_i {
7880 dnd_grid = dnd_grid.insert_row();
7881 for c in drag_w_i..=drag_e_i {
7882 let Some((i, (row, col), item)) = dnd_items.get(dnd_item_i) else {
7883 break;
7884 };
7885 if *row == r && *col == c {
7886 let buttons = vec![
7887 widget::button::custom(
7888 widget::icon::icon(item.icon_handle_grid.clone())
7889 .content_fit(ContentFit::Contain)
7890 .size(icon_sizes.grid()),
7891 )
7892 .on_press(Message::Click(Some(*i)))
7893 .padding(space_xxxs)
7894 .class(button_style(
7895 item.selected,
7896 item.highlighted,
7897 item.cut,
7898 false,
7899 false,
7900 false,
7901 )),
7902 widget::button::custom(Item::grid_display_name(
7903 item.display_name.clone(),
7904 ))
7905 .id(item.button_id.clone())
7906 .on_press(Message::Click(Some(*i)))
7907 .padding([0, space_xxxs])
7908 .class(button_style(
7909 item.selected,
7910 item.highlighted,
7911 item.cut,
7912 true,
7913 true,
7914 false,
7915 )),
7916 ];
7917
7918 let column =
7919 widget::column::with_children(buttons.into_iter().map(Element::from))
7920 .align_x(Alignment::Center)
7921 .height(Length::Fixed(item_height as f32))
7922 .width(Length::Fixed(item_width as f32));
7923
7924 dnd_grid = dnd_grid.push(column);
7925 dnd_item_i += 1;
7926 } else {
7927 dnd_grid = dnd_grid.push(
7928 widget::container(space::vertical().height(item_width as f32))
7929 .height(Length::Fixed(item_height as f32)),
7930 );
7931 }
7932 }
7933 }
7934 Element::from(dnd_grid)
7935 });
7936
7937 let mut mouse_area = mouse_area::MouseArea::new(column.width(Length::Fill))
7938 .on_press(|_| Message::Click(None))
7939 .on_auto_scroll(Message::AutoScroll)
7940 .on_drag_end(|_| Message::DragEnd)
7941 .show_drag_rect(self.mode.multiple())
7942 .on_release(|_| Message::ClickRelease(None));
7943 if self.watch_drag {
7944 mouse_area = mouse_area.on_drag(Message::Drag);
7945 }
7946
7947 (drag_list, mouse_area.into(), true)
7948 }
7949
7950 pub fn list_view(
7951 &self,
7952 ) -> (
7953 Option<Element<'static, Message>>,
7954 Element<'_, Message>,
7955 bool,
7956 ) {
7957 let cosmic_theme::Spacing {
7958 space_s, space_xxs, ..
7959 } = theme::spacing();
7960
7961 let TabConfig {
7962 show_hidden,
7963 show_susceptible,
7964 show_as_samples,
7965 icon_sizes,
7966 ..
7967 } = self.config;
7968
7969 let size = self.size_opt.get().unwrap_or_else(|| Size::new(0.0, 0.0));
7970 let name_width = 300.0;
7972 let modified_width = 200.0;
7973 let size_width = 100.0;
7974 let condensed = size.width < (name_width + modified_width + size_width);
7975 let is_search = matches!(self.location, Location::Search(..));
7976 let icon_size = if condensed || is_search {
7977 icon_sizes.list_condensed()
7978 } else {
7979 icon_sizes.list()
7980 };
7981 let row_height = icon_size + 2 * space_xxs;
7982
7983 let mut column = widget::column::with_capacity(3);
7984 let mut y: f32 = 0.0;
7985
7986 let rule_padding = theme::active().cosmic().corner_radii.radius_xs[0] as u16;
7987
7988 let visible_rect = {
7990 let max_scroll_y = self
7992 .content_height_opt
7993 .get()
7994 .map(|ch| (ch - size.height).max(0.0))
7995 .unwrap_or(f32::MAX);
7996 let scroll_y = self
7997 .scroll_opt
7998 .map(|o| o.y.min(max_scroll_y).max(0.0))
7999 .unwrap_or(0.0);
8000 let point = Point::new(0.0, scroll_y);
8001 let size = self.size_opt.get().unwrap_or_else(|| Size::new(0.0, 0.0));
8002 Rectangle::new(point, size)
8003 };
8004
8005 let mut drag_items = Vec::new();
8006 if let Some(items) = self.column_sort() {
8007 let mut count = 0;
8008 let mut hidden = 0;
8009 let mut susceptible_hidden = 0;
8010 for (i, item) in items {
8011 if item.hidden && !show_hidden {
8012 item.pos_opt.set(None);
8013 item.rect_opt.set(None);
8014 hidden += 1;
8015 continue;
8016 }
8017 if matches!(item.metadata.is_susceptible(), Some(true)) && !show_susceptible {
8018 item.pos_opt.set(None);
8019 item.rect_opt.set(None);
8020 susceptible_hidden += 1;
8021 continue;
8022 }
8023 if item
8026 .metadata
8027 .is_groupable_as_sample_tbprofiler_result_item()
8028 && show_as_samples
8029 {
8030 item.pos_opt.set(None);
8031 item.rect_opt.set(None);
8032 continue;
8033 }
8034 if item.metadata.is_tbprofiler_result_as_sample()
8035 && !item
8036 .metadata
8037 .is_groupable_as_sample_tbprofiler_result_item()
8038 && !show_as_samples
8039 {
8040 item.pos_opt.set(None);
8041 item.rect_opt.set(None);
8042 continue;
8043 }
8044
8045 if count > 0 {
8046 column = column
8047 .push(widget::container(rule::horizontal(1)).padding([0, rule_padding]));
8048 y += 1.0;
8049 }
8050
8051 item.pos_opt.set(Some((count, 0)));
8052 let item_rect = Rectangle::new(
8053 Point::new(f32::from(space_s), y),
8054 Size::new(size.width - f32::from(2 * space_s), f32::from(row_height)),
8055 );
8056 item.rect_opt.set(Some(item_rect));
8057
8058 let button_row = if item_rect.intersects(&visible_rect) {
8060 let modified_text = match &item.metadata {
8061 ItemMetadata::Path { metadata, .. } => match metadata.modified() {
8062 Ok(time) => self.format_time(time).to_string(),
8063 Err(_) => String::new(),
8064 },
8065 ItemMetadata::Trash { entry, .. } => FormatTime::from_secs(
8066 entry.time_deleted,
8067 &self.date_time_formatter,
8068 &self.time_formatter,
8069 )
8070 .map(|t| t.to_string())
8071 .unwrap_or_default(),
8072 #[cfg(feature = "gvfs")]
8073 ItemMetadata::GvfsPath { .. } => match item.metadata.modified() {
8074 Some(mtime) => self.format_time(mtime).to_string(),
8075 None => String::new(),
8076 },
8077 #[cfg(feature = "russh")]
8078 ItemMetadata::RusshPath { .. } => match item.metadata.modified() {
8079 Some(mtime) => self.format_time(mtime).to_string(),
8080 None => String::new(),
8081 },
8082 _ => String::new(),
8083 };
8084
8085 let size_text = match &item.metadata {
8086 ItemMetadata::Path {
8087 metadata,
8088 children_opt,
8089 ..
8090 } => {
8091 if metadata.is_dir() {
8092 if let Some(children) = children_opt {
8094 if *children == 1 {
8095 format!("{children} item")
8096 } else {
8097 format!("{children} items")
8098 }
8099 } else {
8100 String::new()
8101 }
8102 } else {
8103 format_size(metadata.len())
8104 }
8105 }
8106 ItemMetadata::Trash { metadata, .. } => match metadata.size {
8107 trash::TrashItemSize::Entries(entries) => {
8108 if entries == 1 {
8110 format!("{entries} item")
8111 } else {
8112 format!("{entries} items")
8113 }
8114 }
8115 trash::TrashItemSize::Bytes(bytes) => format_size(bytes),
8116 },
8117 ItemMetadata::SimpleDir { entries } => {
8118 if *entries == 1 {
8120 format!("{entries} item")
8121 } else {
8122 format!("{entries} items")
8123 }
8124 }
8125 ItemMetadata::SimpleFile { size } => format_size(*size),
8126 #[cfg(feature = "gvfs")]
8127 ItemMetadata::GvfsPath {
8128 size_opt,
8129 children_opt,
8130 ..
8131 } => match children_opt {
8132 Some(child_count) => {
8133 if *child_count == 1 {
8134 format!("{child_count} item")
8135 } else {
8136 format!("{child_count} items")
8137 }
8138 }
8139 None => format_size(size_opt.unwrap_or_default()),
8140 },
8141 #[cfg(feature = "russh")]
8142 ItemMetadata::RusshPath {
8143 size_opt,
8144 children_opt,
8145 ..
8146 } => match children_opt {
8147 Some(child_count) => {
8148 if *child_count == 1 {
8149 format!("{child_count} item")
8150 } else {
8151 format!("{child_count} items")
8152 }
8153 }
8154 None => format_size(size_opt.unwrap_or_default()),
8155 },
8156 };
8157
8158 let row = if condensed {
8159 widget::row::with_children([
8160 widget::icon::icon(item.icon_handle_list_condensed.clone())
8161 .content_fit(ContentFit::Contain)
8162 .size(icon_size)
8163 .into(),
8164 widget::column::with_children([
8165 Item::list_display_name(item.display_name.clone()).into(),
8166 widget::text::caption(format!("{modified_text} - {size_text}"))
8168 .into(),
8169 ])
8170 .into(),
8171 ])
8172 .height(Length::Fixed(f32::from(row_height)))
8173 .align_y(Alignment::Center)
8174 .spacing(space_xxs)
8175 } else if is_search {
8176 widget::row::with_children([
8177 widget::icon::icon(item.icon_handle_list_condensed.clone())
8178 .content_fit(ContentFit::Contain)
8179 .size(icon_size)
8180 .into(),
8181 widget::column::with_children([
8182 Item::list_display_name(item.display_name.clone()).into(),
8183 widget::text::caption(match item.path_opt() {
8184 Some(path) => path.display().to_string(),
8185 None => String::new(),
8186 })
8187 .into(),
8188 ])
8189 .width(Length::Fill)
8190 .into(),
8191 widget::text::body(modified_text.clone())
8192 .width(Length::Fixed(modified_width))
8193 .into(),
8194 widget::text::body(size_text.clone())
8195 .width(Length::Fixed(size_width))
8196 .into(),
8197 ])
8198 .height(Length::Fixed(f32::from(row_height)))
8199 .align_y(Alignment::Center)
8200 .spacing(space_xxs)
8201 } else {
8202 widget::row::with_children([
8203 widget::icon::icon(item.icon_handle_list.clone())
8204 .content_fit(ContentFit::Contain)
8205 .size(icon_size)
8206 .into(),
8207 Item::list_display_name(item.display_name.clone())
8208 .width(Length::Fill)
8209 .into(),
8210 widget::text::body(modified_text.clone())
8211 .width(Length::Fixed(modified_width))
8212 .into(),
8213 widget::text::body(size_text.clone())
8214 .width(Length::Fixed(size_width))
8215 .into(),
8216 ])
8217 .height(Length::Fixed(f32::from(row_height)))
8218 .align_y(Alignment::Center)
8219 .spacing(space_xxs)
8220 };
8221
8222 let button = |row| {
8223 let mouse_area = crate::mouse_area::MouseArea::new(
8224 widget::button::custom(row)
8225 .width(Length::Fill)
8226 .id(item.button_id.clone())
8227 .padding([0, space_xxs])
8228 .class(button_style(
8229 item.selected,
8230 item.highlighted,
8231 item.cut,
8232 true,
8233 true,
8234 false,
8235 )),
8236 )
8237 .on_press(move |_| Message::Click(Some(i)))
8238 .on_double_click(move |_| Message::DoubleClick(Some(i)))
8239 .on_release(move |_| Message::ClickRelease(Some(i)))
8240 .on_middle_press(move |_| Message::MiddleClick(i))
8241 .on_enter(move || Message::HighlightActivate(i))
8242 .on_exit(move || Message::HighlightDeactivate(i));
8243
8244 if self.context_menu.is_some() {
8245 mouse_area
8246 } else {
8247 mouse_area
8248 .on_right_press_no_capture()
8249 .wayland_on_right_press_window_position()
8250 .on_right_press(move |point_opt| {
8251 Message::RightClick(point_opt, Some(i))
8252 })
8253 }
8254 };
8255
8256 let button_row = button(row.into());
8257 let button_row: Element<_> = if item.metadata.is_dir()
8258 && let Some(location) = item.location_opt.as_ref()
8259 {
8260 self.dnd_dest(location, button_row)
8261 } else {
8262 button_row.into()
8263 };
8264
8265 if item.selected || !drag_items.is_empty() {
8266 let dnd_row = if !item.selected {
8267 Element::from(
8268 space::vertical().height(Length::Fixed(f32::from(row_height))),
8269 )
8270 } else if condensed {
8271 widget::row::with_children([
8272 widget::icon::icon(item.icon_handle_list_condensed.clone())
8273 .content_fit(ContentFit::Contain)
8274 .size(icon_size)
8275 .into(),
8276 widget::column::with_children([
8277 Item::list_display_name(item.display_name.clone()).into(),
8278 widget::text::body(format!("{modified_text} - {size_text}"))
8280 .into(),
8281 ])
8282 .into(),
8283 ])
8284 .align_y(Alignment::Center)
8285 .spacing(space_xxs)
8286 .into()
8287 } else if is_search {
8288 widget::row::with_children([
8289 widget::icon::icon(item.icon_handle_list_condensed.clone())
8290 .content_fit(ContentFit::Contain)
8291 .size(icon_size)
8292 .into(),
8293 widget::column::with_children([
8294 Item::list_display_name(item.display_name.clone()).into(),
8295 widget::text::caption(match item.path_opt() {
8296 Some(path) => path.display().to_string(),
8297 None => String::new(),
8298 })
8299 .into(),
8300 ])
8301 .width(Length::Fill)
8302 .into(),
8303 widget::text::body(modified_text.clone())
8304 .width(Length::Fixed(modified_width))
8305 .into(),
8306 widget::text::body(size_text.clone())
8307 .width(Length::Fixed(size_width))
8308 .into(),
8309 ])
8310 .align_y(Alignment::Center)
8311 .spacing(space_xxs)
8312 .into()
8313 } else {
8314 widget::row::with_children([
8315 widget::icon::icon(item.icon_handle_list.clone())
8316 .content_fit(ContentFit::Contain)
8317 .size(icon_size)
8318 .into(),
8319 Item::list_display_name(item.display_name.clone())
8320 .width(Length::Fill)
8321 .into(),
8322 widget::text(modified_text)
8323 .width(Length::Fixed(modified_width))
8324 .into(),
8325 widget::text::body(size_text)
8326 .width(Length::Fixed(size_width))
8327 .into(),
8328 ])
8329 .align_y(Alignment::Center)
8330 .spacing(space_xxs)
8331 .into()
8332 };
8333 if item.selected {
8334 drag_items.push(
8335 widget::container(button(dnd_row))
8336 .width(Length::Shrink)
8337 .into(),
8338 );
8339 } else {
8340 drag_items.push(dnd_row);
8341 }
8342 }
8343
8344 button_row
8345 } else {
8346 widget::column::with_capacity(0)
8347 .width(Length::Fill)
8348 .height(Length::Fixed(f32::from(row_height)))
8349 .into()
8350 };
8351
8352 count += 1;
8353 y += f32::from(row_height);
8354 column = column.push(button_row);
8355 }
8356
8357 if count == 0 {
8358 return (
8359 None,
8360 self.empty_view(hidden > 0, susceptible_hidden > 0),
8361 false,
8362 );
8363 }
8364
8365 self.content_height_opt.set(Some(y));
8367 }
8368 {
8370 let top_deduct = (if condensed || is_search { 6 } else { 9 }) * space_xxs;
8371
8372 self.item_view_size_opt
8373 .set(self.size_opt.get().map(|s| Size {
8374 width: s.width,
8375 height: s.height - f32::from(top_deduct),
8376 }));
8377
8378 let spacer_height = size.height - y - f32::from(top_deduct);
8379 if spacer_height > 0. {
8380 column = column.push(widget::container(space::vertical().height(spacer_height)));
8381 }
8382 }
8383 let drag_col = (!drag_items.is_empty())
8384 .then(|| Element::from(widget::column::with_children(drag_items)));
8385
8386 let mut mouse_area = mouse_area::MouseArea::new(column.padding([0, space_s]))
8387 .with_id(Id::new("list-view"))
8388 .on_press(|_| Message::Click(None))
8389 .on_auto_scroll(Message::AutoScroll)
8390 .on_drag_end(|_| Message::DragEnd)
8391 .show_drag_rect(self.mode.multiple())
8392 .on_release(|_| Message::ClickRelease(None));
8393 if self.watch_drag {
8394 mouse_area = mouse_area.on_drag(Message::Drag);
8395 }
8396
8397 (drag_col, mouse_area.into(), true)
8398 }
8399
8400 pub fn view_responsive<'a>(
8401 &'a self,
8402 key_binds: &'a HashMap<KeyBind, Action>,
8403 modifiers: &'a Modifiers,
8404 size: Size,
8405 clipboard_paste_available: bool,
8406 context_actions: &'a [ContextActionPreset],
8407 ) -> Element<'a, Message> {
8408 self.size_opt.set(Some(size));
8410
8411 let cosmic_theme::Spacing {
8412 space_xxxs,
8413 space_xxs,
8414 space_xs,
8415 ..
8416 } = theme::spacing();
8417
8418 let location_view_opt = if matches!(self.mode, Mode::Desktop) {
8419 None
8420 } else {
8421 Some(self.location_view())
8422 };
8423 let (drag_list, mut item_view, can_scroll) = match self.config.view {
8424 View::Grid => self.grid_view(),
8425 View::List => self.list_view(),
8426 };
8427 item_view = widget::container(item_view).width(Length::Fill).into();
8428 let files = self
8429 .items_opt
8430 .as_ref()
8431 .map(|items| {
8432 items
8433 .iter()
8434 .filter_map(|item| {
8435 if item.selected {
8436 item.path_opt().cloned()
8437 } else {
8438 None
8439 }
8440 })
8441 .collect::<Box<[PathBuf]>>()
8442 })
8443 .unwrap_or_default();
8444 let item_view =
8445 DndSource::<Message, ClipboardCopy>::with_id(item_view, Id::new("tab-view"));
8446
8447 let view = self.config.view;
8448 let item_view = match drag_list {
8449 Some(drag_list) if self.selected_clicked => {
8450 let drag_list = RcElementWrapper::new(drag_list);
8451 item_view
8452 .drag_content(move || {
8453 ClipboardCopy::new(crate::clipboard::ClipboardKind::Copy, &files)
8454 })
8455 .drag_icon(move |_| {
8456 let state: tree::State = Widget::<Message, _, _>::state(&drag_list);
8457 (
8458 Element::from(drag_list.clone()).map(|_m| ()),
8459 state,
8460 match view {
8461 View::Grid => Vector::new(
8463 f32::from(space_xxs).mul_add(-3.0, -f32::from(space_xxxs)),
8464 -4. * f32::from(space_xxxs),
8465 ),
8466 View::List => Vector::ZERO,
8467 },
8468 )
8469 })
8470 }
8471 _ => item_view,
8472 };
8473
8474 let tab_location = self.location.clone();
8475 let mouse_area = mouse_area::MouseArea::new(item_view)
8476 .on_press(move |_point_opt| Message::Click(None))
8477 .on_release(|_| Message::ClickRelease(None))
8478 .on_resize(Message::Resize)
8479 .on_back_press(move |_point_opt| Message::GoPrevious)
8480 .on_forward_press(move |_point_opt| Message::GoNext)
8481 .on_scroll(|delta| respond_to_scroll_direction(delta, modifiers))
8482 .on_right_press(move |p| {
8483 Message::ContextMenu(
8484 if self.context_menu.is_some() { None } else { p },
8485 self.window_id,
8486 )
8487 })
8488 .wayland_on_right_press_window_position();
8489
8490 let mut popover = widget::popover(mouse_area);
8491 if let Some(point) = self.context_menu
8492 && (!cfg!(feature = "wayland") || !crate::is_wayland())
8493 {
8494 let context_menu = menu::context_menu(
8495 self,
8496 key_binds,
8497 modifiers,
8498 clipboard_paste_available,
8499 &self.tb_config,
8500 context_actions,
8501 );
8502 popover = popover
8503 .popup(context_menu)
8504 .position(widget::popover::Position::Point(point));
8505 }
8506
8507 let mut tab_column = widget::column::with_capacity(3);
8508 if let Some(location_view) = location_view_opt {
8509 tab_column = tab_column.push(location_view);
8510 }
8511 if can_scroll {
8512 tab_column = tab_column.push(
8513 widget::id_container(
8517 widget::scrollable(popover)
8518 .id(self.scrollable_id.clone())
8519 .on_scroll(Message::Scroll)
8520 .width(Length::Fill)
8521 .height(Length::Fill),
8522 widget::Id::new(format!("{}-scrollable", self.scrollable_id)),
8523 ),
8524 );
8525 } else {
8526 tab_column = tab_column.push(popover);
8527 }
8528 match &self.location {
8529 Location::Trash | Location::Search(SearchLocation::Trash, ..) => {
8530 if let Some(items) = self.items_opt()
8531 && !items.is_empty()
8532 {
8533 tab_column = tab_column.push(
8534 widget::layer_container(widget::row::with_children([
8535 widget::space::horizontal().into(),
8536 widget::button::standard(fl!("empty-trash"))
8537 .on_press(Message::EmptyTrash)
8538 .into(),
8539 ]))
8540 .padding([space_xxs, space_xs])
8541 .layer(cosmic_theme::Layer::Primary)
8542 .apply(widget::container)
8543 .padding([0, 0, 7, 0]),
8544 );
8545 }
8546 }
8547 Location::Recents | Location::Search(SearchLocation::Recents, ..) => {
8548 if let Some(items) = self.items_opt()
8549 && !items.is_empty()
8550 {
8551 tab_column = tab_column.push(
8552 widget::layer_container(widget::row::with_children([
8553 widget::space::horizontal().into(),
8554 widget::button::standard(fl!("clear-recents-history"))
8555 .on_press(Message::ClearRecents)
8556 .into(),
8557 ]))
8558 .padding([space_xxs, space_xs])
8559 .layer(cosmic_theme::Layer::Primary)
8560 .apply(widget::container)
8561 .padding([0, 0, 7, 0]),
8562 );
8563 }
8564 }
8565 Location::Network(uri, _display_name, _path) if uri == "network:///" => {
8566 tab_column = tab_column.push(
8567 widget::layer_container(widget::row::with_children([
8568 widget::space::horizontal().into(),
8569 widget::button::standard(fl!("add-network-drive"))
8570 .on_press(Message::AddNetworkDrive)
8571 .into(),
8572 ]))
8573 .padding([space_xxs, space_xs])
8574 .layer(cosmic_theme::Layer::Primary)
8575 .apply(widget::container)
8576 .padding([0, 0, 7, 0]),
8577 );
8578 }
8579 Location::Remote(uri, _display_name, _path) if uri == "ssh:///" => {
8580 tab_column = tab_column.push(
8581 widget::layer_container(widget::row::with_children([
8582 widget::space::horizontal().into(),
8583 widget::button::standard(fl!("add-remote-drive"))
8584 .on_press(Message::AddRemoteDrive)
8585 .into(),
8586 ]))
8587 .padding([space_xxs, space_xs])
8588 .layer(cosmic_theme::Layer::Primary)
8589 .apply(widget::container)
8590 .padding([0, 0, 7, 0]),
8591 );
8592 }
8593 Location::Remote(uri, _display_name, path)
8594 if path.as_ref().is_some_and(|p| {
8595 let expected = Path::new(&self.tb_config.out_dir).join("results");
8596 p == &expected
8597 }) =>
8598 {
8599 tab_column = tab_column.push(
8600 widget::layer_container(widget::row::with_children([
8601 widget::space::horizontal().into(),
8602 widget::button::standard(fl!("delete-tb-profiler-results"))
8603 .on_press(Message::DeleteTbProfilerResults(
8604 uri.to_string(),
8605 self.tb_config.clone(),
8606 ))
8607 .into(),
8608 ]))
8609 .padding([space_xxs, space_xs])
8610 .layer(cosmic_theme::Layer::Primary)
8611 .apply(widget::container)
8612 .padding([0, 0, 7, 0]),
8613 );
8614 }
8615 _ => {}
8616 }
8617 let mut tab_view = widget::container(tab_column)
8618 .height(Length::Fill)
8619 .width(Length::Fill);
8620
8621 if self.dnd_hovered.as_ref().map(|(l, _)| l) == Some(&tab_location)
8623 && !matches!(self.mode, Mode::Desktop)
8624 {
8625 tab_view = tab_view.style(|t| {
8626 let mut a = widget::container::Style::default();
8627 let c = t.cosmic();
8628 a.border = cosmic::iced::core::Border {
8629 color: (c.accent_color()).into(),
8630 width: 1.,
8631 radius: c.radius_0().into(),
8632 };
8633 a
8634 });
8635 }
8636
8637 let tab_location_2 = self.location.clone();
8638 let tab_location_3 = self.location.clone();
8639 let dnd_dest = DndDestination::for_data(tab_view, move |data, action| {
8640 if let Some(mut data) = data {
8641 if action == DndAction::Copy {
8642 Message::Drop(Some((tab_location.clone(), data)))
8643 } else if action == DndAction::Move {
8644 data.kind = ClipboardKind::Cut { is_dnd: true };
8645 Message::Drop(Some((tab_location.clone(), data)))
8646 } else {
8647 log::warn!("unsupported action: {action:?}");
8648 Message::Drop(None)
8649 }
8650 } else {
8651 Message::Drop(None)
8652 }
8653 })
8654 .on_enter(move |_, _, _| Message::DndEnter(tab_location_2.clone()))
8655 .on_leave(move || Message::DndLeave(tab_location_3.clone()));
8656
8657 dnd_dest.into()
8658 }
8659 pub fn multi_preview_view<'a>(
8660 &'a self,
8661 mime_app_cache_opt: Option<&'a mime_app::MimeAppCache>,
8662 ) -> Element<'a, Message> {
8663 let cosmic_theme::Spacing {
8664 space_xxxs,
8665 space_m,
8666 ..
8667 } = theme::spacing();
8668
8669 let mut column = widget::column::with_capacity(4).spacing(space_m);
8670
8671 let handle = widget::icon::from_name("text-x-generic")
8672 .size(IconSizes::default().grid())
8673 .handle();
8674
8675 let icon = widget::icon::icon(handle.clone())
8676 .content_fit(ContentFit::Contain)
8677 .size(IconSizes::default().grid());
8678
8679 let icon_container1 = widget::container(icon.clone()).padding(padding::bottom(10).left(10));
8680 let icon_container2 =
8681 widget::container(icon.clone()).padding(padding::top(5).bottom(5).left(5).right(5));
8682 let icon_container3 = widget::container(icon).padding(padding::top(10).right(10));
8683 let stack = stack![icon_container1, icon_container2, icon_container3];
8684
8685 column = column.push(
8686 widget::container(stack)
8687 .center_x(Length::Fill)
8688 .max_height(THUMBNAIL_SIZE as f32),
8689 );
8690
8691 let selected_items: Vec<&Item> = self.items_opt().map_or(Vec::new(), |items| {
8692 items
8693 .iter()
8694 .filter(|item| {
8695 if !item.selected {
8696 return false;
8697 }
8698 #[cfg(feature = "russh")]
8701 if item.metadata.is_tbprofiler_result_as_sample()
8702 && let Some(Location::Remote(..)) = &item.location_opt
8703 {
8704 return true;
8705 }
8706 item.location_opt
8707 .as_ref()
8708 .and_then(Location::path_opt)
8709 .is_some()
8710 })
8711 .collect()
8712 });
8713
8714 let mut details = widget::column::with_capacity(3).spacing(space_xxxs);
8715 details = details.push(widget::text::body(fl!(
8716 "items",
8717 items = selected_items.len()
8718 )));
8719
8720 let mut total_size: u64 = 0;
8721 let mut mime_type_counts: BTreeMap<String, u64> = BTreeMap::new();
8722 let _user_name: BTreeSet<String> = BTreeSet::new();
8723 let _mode_user: BTreeSet<u32> = BTreeSet::new();
8724 let _group_name: BTreeSet<String> = BTreeSet::new();
8725 let _mode_group: BTreeSet<u32> = BTreeSet::new();
8726 let _mode_other: BTreeSet<u32> = BTreeSet::new();
8727 let mut calculating_dir_size = false;
8728 let mut dir_size_error: Option<String> = None;
8729 let mut show_size = true;
8730
8731 for item in selected_items.iter() {
8732 *mime_type_counts.entry(item.mime.to_string()).or_insert(0) += 1;
8733
8734 if let Some(metadata) = item.file_metadata() {
8735 if metadata.is_dir() {
8736 match &item.dir_size {
8737 DirSize::Calculating(_) => {
8738 calculating_dir_size = true;
8739 }
8740 DirSize::Directory(size) => {
8741 total_size = total_size.saturating_add(*size);
8742 }
8743 DirSize::NotDirectory => (),
8744 DirSize::Error(err) => {
8745 dir_size_error = Some(err.clone());
8746 }
8747 };
8748 } else {
8749 total_size = total_size.saturating_add(metadata.len());
8750 }
8751 #[cfg(unix)]
8752 {
8753 let mode = metadata.mode();
8754 user_name.insert(
8755 uzers::get_user_by_uid(metadata.uid())
8756 .and_then(|user| user.name().to_str().map(ToOwned::to_owned))
8757 .unwrap_or_default(),
8758 );
8759 mode_user.insert(get_mode_part(mode, MODE_SHIFT_USER));
8760 group_name.insert(
8761 uzers::get_group_by_gid(metadata.gid())
8762 .and_then(|group| group.name().to_str().map(ToOwned::to_owned))
8763 .unwrap_or_default(),
8764 );
8765 mode_group.insert(get_mode_part(mode, MODE_SHIFT_GROUP));
8766 mode_other.insert(get_mode_part(mode, MODE_SHIFT_OTHER));
8767 }
8768 } else {
8769 match item.metadata {
8770 #[cfg(feature = "russh")]
8771 ItemMetadata::RusshPath { .. } => {
8772 if item.metadata.is_dir() {
8773 show_size = false;
8774 } else if let Some(size) = item.metadata.file_size() {
8775 total_size = total_size.saturating_add(size);
8776 }
8777 }
8778 _ => (),
8779 }
8780 }
8781 }
8782 let mut mime_types: Vec<(String, u64)> = mime_type_counts.into_iter().collect();
8783 mime_types.sort_by(|(_, v1), (_, v2)| v2.cmp(v1));
8784
8785 let limit = usize::min(10, mime_types.len());
8787
8788 let mut mime_type_strings: Vec<String> = mime_types[..limit]
8789 .iter()
8790 .map(|(mime, count)| format!("{} ({})", mime, count))
8791 .collect();
8792
8793 if mime_types.len() > limit {
8794 mime_type_strings.push("...".to_string());
8795 }
8796
8797 details = details.push(widget::text::body(fl!(
8798 "type",
8799 mime = mime_type_strings.join(", ")
8800 )));
8801
8802 let size = {
8803 if calculating_dir_size {
8804 fl!("calculating")
8805 } else if let Some(error) = dir_size_error {
8806 error
8807 } else {
8808 format_size(total_size)
8809 }
8810 };
8811
8812 if show_size {
8813 details = details.push(widget::text::body(fl!("item-size", size = size)));
8814 }
8815
8816 column = column.push(details);
8817
8818 let action_button = {
8819 #[cfg(feature = "russh")]
8820 {
8821 let remote_paths_uris: Vec<(PathBuf, String)> = {
8822 let mut seen = std::collections::HashSet::new();
8823 selected_items
8824 .iter()
8825 .flat_map(|item| {
8826 if let ItemMetadata::RusshPath { .. } = &item.metadata
8827 && let Some(Location::Remote(uri, _, path_opt)) = &item.location_opt
8828 {
8829 if item.metadata.is_tbprofiler_result_as_sample() {
8830 let mut result = Vec::new();
8832 if let Some(p) = item.metadata.json_path() {
8833 result.push((p.clone(), uri.clone()));
8834 }
8835 if let Some(p) = item.metadata.csv_path() {
8836 result.push((p.clone(), uri.clone()));
8837 }
8838 if let Some(p) = item.metadata.docx_path() {
8839 result.push((p.clone(), uri.clone()));
8840 }
8841 return result;
8842 } else if let Some(path) = path_opt {
8843 return vec![(path.clone(), uri.clone())];
8844 }
8845 }
8846 vec![]
8847 })
8848 .filter(|(path, _)| seen.insert(path.clone()))
8849 .collect()
8850 };
8851
8852 if !remote_paths_uris.is_empty() {
8853 let (paths, uris) = remote_paths_uris.into_iter().unzip();
8854 Some(
8855 widget::button::standard(fl!("download"))
8856 .on_press(Message::DownloadMany(paths, uris)),
8857 )
8858 } else if matches!(self.location, Location::Remote(..)) {
8859 None
8860 } else {
8861 Some(widget::button::standard(fl!("open")).on_press(Message::Open(None)))
8862 }
8863 }
8864 #[cfg(not(feature = "russh"))]
8865 Some(widget::button::standard(fl!("open")).on_press(Message::Open(None)))
8866 };
8867
8868 if let Some(btn) = action_button {
8869 column = column.push(btn);
8870 }
8871
8872 let mut settings = Vec::new();
8873 if mime_types.len() == 1
8875 && let Some(mime) = mime_types
8876 .first()
8877 .and_then(|(mime, _)| mime.parse::<Mime>().ok())
8878 && let Some(mime_app_cache) = mime_app_cache_opt
8879 {
8880 let mime_apps = mime_app_cache.get_apps_for_mime(&mime, false);
8881 if !mime_apps.is_empty() {
8882 let mime_closure = mime.clone();
8883 let (names, icons) = mime_apps
8884 .iter()
8885 .map(|(app, _)| (Cow::Owned(app.name.clone()), app.icon()))
8886 .collect::<(Vec<_>, Vec<_>)>();
8887 settings.push(
8888 widget::settings::item::builder(fl!("open-with")).control(
8889 Element::from(
8890 widget::dropdown(
8891 names,
8892 mime_apps.iter().position(|(x, _)| x.is_default(&mime)),
8893 move |index| (index, mime_closure.clone()),
8894 )
8895 .icons(Cow::Owned(icons)),
8896 )
8897 .map(move |(index, mime)| {
8898 let mime_app = &mime_apps[index].0;
8899 Message::SetOpenWith(mime, mime_app.id.clone())
8900 }),
8901 ),
8902 );
8903 }
8904 }
8905
8906 #[cfg(unix)]
8907 {
8908 fn selected_mode_part(mut modes: BTreeSet<u32>) -> Option<usize> {
8910 match (modes.pop_first(), modes.pop_first()) {
8911 (Some(mode), None) => Some(mode.try_into().unwrap()),
8912 _ => None,
8913 }
8914 }
8915
8916 fn join_set(set: BTreeSet<String>) -> String {
8918 let limit = 5;
8919 let mut title = set.into_iter().collect::<Vec<String>>();
8920 if title.len() > limit {
8921 title.truncate(limit);
8922 title.push("...".to_string());
8923 }
8924 title.join(", ")
8925 }
8926
8927 let mode_part_user = selected_mode_part(mode_user);
8928 settings.push(
8929 widget::settings::item::builder(join_set(user_name))
8930 .description(fl!("owner"))
8931 .control(
8932 widget::dropdown(
8933 Cow::Borrowed(MODE_NAMES.as_slice()),
8934 mode_part_user,
8935 move |selected| {
8936 Message::ShiftPermissions(
8937 None,
8938 MODE_SHIFT_USER,
8939 selected.try_into().unwrap(),
8940 )
8941 },
8942 )
8943 .placeholder(fl!("mixed")),
8944 ),
8945 );
8946
8947 let mode_part_group = selected_mode_part(mode_group);
8948 settings.push(
8949 widget::settings::item::builder(join_set(group_name))
8950 .description(fl!("group"))
8951 .control(
8952 widget::dropdown(
8953 Cow::Borrowed(MODE_NAMES.as_slice()),
8954 mode_part_group,
8955 move |selected| {
8956 Message::ShiftPermissions(
8957 None,
8958 MODE_SHIFT_GROUP,
8959 selected.try_into().unwrap(),
8960 )
8961 },
8962 )
8963 .placeholder(fl!("mixed")),
8964 ),
8965 );
8966
8967 let mode_part_other = selected_mode_part(mode_other);
8968 settings.push(
8969 widget::settings::item::builder(fl!("other")).control(
8970 widget::dropdown(
8971 Cow::Borrowed(MODE_NAMES.as_slice()),
8972 mode_part_other,
8973 move |selected| {
8974 Message::ShiftPermissions(
8975 None,
8976 MODE_SHIFT_OTHER,
8977 selected.try_into().unwrap(),
8978 )
8979 },
8980 )
8981 .placeholder(fl!("mixed")),
8982 ),
8983 );
8984 }
8985
8986 if !settings.is_empty() {
8987 let mut section = widget::settings::section();
8988 section = section.extend(settings);
8989 column = column.push(section);
8990 }
8991
8992 column.into()
8993 }
8994 pub fn view<'a>(
8995 &'a self,
8996 key_binds: &'a HashMap<KeyBind, Action>,
8997 modifiers: &'a Modifiers,
8998 clipboard_paste_available: bool,
8999 context_actions: &'a [ContextActionPreset],
9000 ) -> Element<'a, Message> {
9001 widget::responsive(move |size| {
9002 widget::id_container(
9003 self.view_responsive(
9004 key_binds,
9005 modifiers,
9006 size,
9007 clipboard_paste_available,
9008 context_actions,
9009 ),
9010 Id::new(format!(
9011 "tab-{}-{}",
9012 self.scrollable_id, self.location_title
9013 )),
9014 )
9015 .into()
9016 })
9017 .into()
9018 }
9019
9020 pub fn subscription(&self, preview: bool) -> Subscription<Message> {
9021 let jobs = self.thumb_config.jobs.get() as usize;
9023 let mut subscriptions = Vec::with_capacity(jobs + 3);
9024
9025 if let Some(items) = &self.items_opt {
9026 let visible_rect = {
9028 let point = match self.scroll_opt {
9029 Some(offset) => Point::new(0.0, offset.y),
9030 None => Point::new(0.0, 0.0),
9031 };
9032 let size = self.size_opt.get().unwrap_or_else(|| Size::new(0.0, 0.0));
9033 Rectangle::new(point, size)
9034 };
9035
9036 for item in items {
9037 if item.thumbnail_opt.is_some() {
9038 continue;
9040 }
9041
9042 match item.rect_opt.get() {
9043 Some(rect) => {
9044 if !rect.intersects(&visible_rect) {
9045 continue;
9047 }
9048 }
9049 None => {
9050 continue;
9052 }
9053 }
9054
9055 let Some(path) = item.path_opt().cloned() else {
9056 continue;
9057 };
9058
9059 let metadata = item.metadata.clone();
9060 let can_thumbnail = match metadata {
9061 ItemMetadata::Path { .. } => true,
9062 #[cfg(feature = "gvfs")]
9063 ItemMetadata::GvfsPath { .. } => true,
9064 _ => false,
9065 };
9066 if can_thumbnail {
9067 let mime = item.mime.clone();
9068 let max_jobs = jobs;
9069 let max_mb = u64::from(self.thumb_config.max_mem_mb.get());
9070 let max_size = u64::from(self.thumb_config.max_size_mb.get());
9071
9072 let (effective_max_mb, effective_jobs) = if mime.type_() == mime::IMAGE {
9074 match item.image_dimensions {
9075 Some((width, height)) => {
9076 let (_use_dedicated, eff_mb, eff_jobs) =
9077 should_use_dedicated_worker(width, height, max_mb, max_jobs);
9078 (eff_mb, eff_jobs)
9079 }
9080 None => (max_mb, max_jobs),
9081 }
9082 } else {
9083 (max_mb, max_jobs)
9084 };
9085
9086 #[derive(Clone)]
9087 struct Wrapper {
9088 path: PathBuf,
9089 metadata: ItemMetadata,
9090 mime: mime::Mime,
9091 effective_max_mb: u64,
9092 effective_jobs: usize,
9093 max_size: u64,
9094 }
9095
9096 impl Hash for Wrapper {
9097 fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
9098 self.path.hash(state);
9099 }
9100 }
9101
9102 subscriptions.push(Subscription::run_with(
9103 Wrapper {
9104 path: path.clone(),
9105 metadata,
9106 mime,
9107 effective_max_mb,
9108 effective_jobs,
9109 max_size,
9110 },
9111 |wrapper| {
9112 let Wrapper {
9113 path,
9114 metadata,
9115 mime,
9116 effective_max_mb,
9117 effective_jobs,
9118 max_size,
9119 } = wrapper.clone();
9120 stream::channel(
9121 1,
9122 move |mut output: futures::channel::mpsc::Sender<_>| async move {
9123 while crate::operation::is_actively_writing_to(&path) {
9124 crate::operation::actively_writing_tick().await;
9125 }
9126
9127 let message = {
9128 let path = path.clone();
9129
9130 _ = THUMB_SEMAPHORE.acquire().await;
9132
9133 tokio::task::spawn_blocking(move || {
9134 let start = Instant::now();
9135 let thumbnail = ItemThumbnail::new(
9136 &path,
9137 metadata,
9138 mime,
9139 THUMBNAIL_SIZE,
9140 effective_max_mb,
9141 effective_jobs,
9142 max_size,
9143 );
9144 log::debug!(
9145 "thumbnailed {} in {:?}",
9146 path.display(),
9147 start.elapsed()
9148 );
9149 Message::Thumbnail(path, thumbnail)
9150 })
9151 .await
9152 .unwrap()
9153 };
9154
9155 match output.send(message).await {
9156 Ok(()) => {}
9157 Err(err) => {
9158 log::warn!(
9159 "failed to send thumbnail for {}: {}",
9160 path.display(),
9161 err
9162 );
9163 }
9164 }
9165
9166 std::future::pending().await
9167 },
9168 )
9169 },
9170 ));
9171 }
9172
9173 if subscriptions.len() >= jobs {
9174 break;
9175 }
9176 }
9177
9178 if preview {
9179 let mut selected_items: Vec<&Item> =
9182 items.iter().filter(|item| item.selected).collect();
9183
9184 if selected_items.is_empty()
9185 && let Some(p) = self.parent_item_opt.as_ref()
9186 {
9187 selected_items.push(p)
9188 }
9189 for item in selected_items {
9190 if let Some(path) = item.path_opt().cloned() {
9192 if let DirSize::Calculating(controller) = &item.dir_size {
9194 struct Wrapper {
9195 path: PathBuf,
9196 controller: Controller,
9197 }
9198 impl Hash for Wrapper {
9199 fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
9200 self.path.hash(state);
9201 }
9202 }
9203 subscriptions.push(Subscription::run_with(
9204 Wrapper { path: path.clone(), controller: controller.clone() },
9205 |Wrapper { path, controller }| {
9206 let path = path.clone();
9207 let controller = controller.clone();
9208 stream::channel(1, |mut output: futures::channel::mpsc::Sender<_>| async move {
9209 let message = {
9210 let start = Instant::now();
9211 match calculate_dir_size(&path, controller).await {
9212 Ok(size) => {
9213 log::debug!(
9214 "calculated directory size of {} in {:?}",
9215 path.display(),
9216 start.elapsed()
9217 );
9218 Message::DirectorySize(
9219 path.clone(),
9220 DirSize::Directory(size),
9221 )
9222 }
9223 Err(err) => {
9224 log::warn!(
9225 "failed to calculate directory size of {}: {}",
9226 path.display(),
9227 err
9228 );
9229 Message::DirectorySize(
9230 path.clone(),
9231 DirSize::Error(err.to_string()),
9232 )
9233 }
9234 }
9235 };
9236
9237 match output.send(message).await {
9238 Ok(()) => {}
9239 Err(err) => {
9240 log::warn!(
9241 "failed to send directory size for {}: {}",
9242 path.display(),
9243 err
9244 );
9245 }
9246 }
9247
9248 std::future::pending().await
9249 })
9250 }
9251 ));
9252 }
9253 }
9254 }
9255 }
9256 }
9257
9258 if let Location::Search(search_location, term, show_hidden, start) = &self.location {
9260 let location = self.location.clone();
9261 let search_location = search_location.clone();
9262 let term = term.clone();
9263 let show_hidden = *show_hidden;
9264 let start = *start;
9265 #[derive(Debug, Hash, Clone)]
9266 struct Wrapper {
9267 location: Location,
9268 search_location: SearchLocation,
9269 term: String,
9270 show_hidden: bool,
9271 start: Instant,
9272 }
9273
9274 subscriptions.push(Subscription::run_with(
9275 Wrapper {
9276 location: location.clone(),
9277 search_location: search_location.clone(),
9278 term: term.clone(),
9279 show_hidden,
9280 start,
9281 },
9282 |wrapper| {
9283 let wrapper = wrapper.clone();
9284 stream::channel(
9285 2,
9286 move |mut output: futures::channel::mpsc::Sender<Message>| async move {
9287 let Wrapper {
9288 location,
9289 search_location,
9290 term,
9291 show_hidden,
9292 start,
9293 } = wrapper;
9294 let (results_tx, results_rx) = mpsc::channel(65536);
9296
9297 let ready = Arc::new(atomic::AtomicBool::new(false));
9298 let last_modified_opt = Arc::new(RwLock::new(None));
9299 output
9300 .send(Message::SearchContext(
9301 location.clone(),
9302 SearchContextWrapper(Some(SearchContext {
9303 results_rx,
9304 ready: ready.clone(),
9305 last_modified_opt: last_modified_opt.clone(),
9306 })),
9307 ))
9308 .await
9309 .unwrap();
9310
9311 let (watch_tx, mut watch_rx) = tokio::sync::watch::channel(true);
9312 {
9313 tokio::task::spawn_blocking(move || {
9314 scan_search(
9315 &search_location,
9316 &term,
9317 show_hidden,
9318 move |search_item| -> bool {
9319 if let Some(last_modified) =
9321 *last_modified_opt.read().unwrap()
9322 && let SearchItem::Path(_, _, ref metadata) =
9323 search_item
9324 {
9325 if let Ok(modified) = metadata.modified() {
9326 if modified < last_modified {
9327 return true;
9328 }
9329 } else {
9330 return true;
9331 }
9332 }
9333
9334 match results_tx.blocking_send(search_item) {
9335 Ok(()) => {
9336 if ready.swap(true, atomic::Ordering::SeqCst) {
9337 true
9338 } else {
9339 watch_tx.send(false).is_ok()
9341 }
9342 }
9343 Err(_) => false,
9344 }
9345 },
9346 );
9347 log::info!(
9348 "searched for {:?} in {} in {:?}",
9349 term,
9350 search_location,
9351 start.elapsed(),
9352 );
9353 });
9354 }
9355
9356 while watch_rx.changed().await.is_ok() {
9357 let is_ready = *watch_rx.borrow_and_update();
9358 let _ = output.send(Message::SearchReady(is_ready)).await;
9359 }
9360
9361 let _ = output.send(Message::SearchReady(true)).await;
9363
9364 std::future::pending().await
9365 },
9366 )
9367 },
9368 ));
9369 }
9370
9371 if let Some(path) = self
9372 .edit_location
9373 .as_ref()
9374 .and_then(|x| x.location.path_opt())
9375 .cloned()
9376 {
9377 subscriptions.push(Subscription::run_with(
9378 ("tab_complete", path.clone()),
9379 |(_, path)| {
9380 let path = path.clone();
9381 stream::channel(
9382 1,
9383 |mut output: futures::channel::mpsc::Sender<_>| async move {
9384 let message = {
9385 let path = path.clone();
9386 tokio::task::spawn_blocking(move || {
9387 let start = Instant::now();
9388 match tab_complete(&path) {
9389 Ok(completions) => {
9390 log::info!(
9391 "tab completed {} in {:?}",
9392 path.display(),
9393 start.elapsed()
9394 );
9395 Message::TabComplete(path.clone(), completions)
9396 }
9397 Err(err) => {
9398 log::warn!(
9399 "failed to tab complete {}: {}",
9400 path.display(),
9401 err
9402 );
9403 Message::TabComplete(path.clone(), Vec::new())
9404 }
9405 }
9406 })
9407 .await
9408 .unwrap()
9409 };
9410
9411 match output.send(message).await {
9412 Ok(()) => {}
9413 Err(err) => {
9414 log::warn!(
9415 "failed to send tab completion for {}: {}",
9416 path.display(),
9417 err
9418 );
9419 }
9420 }
9421
9422 std::future::pending().await
9423 },
9424 )
9425 },
9426 ));
9427 }
9428
9429 Subscription::batch(subscriptions)
9430 }
9431
9432 const fn format_time(&self, time: SystemTime) -> FormatTime<'_> {
9433 format_time(time, &self.date_time_formatter, &self.time_formatter)
9434 }
9435}
9436
9437pub fn respond_to_scroll_direction(delta: ScrollDelta, modifiers: &Modifiers) -> Option<Message> {
9438 if !modifiers.control() {
9439 return None;
9440 }
9441
9442 let delta_y = match delta {
9443 ScrollDelta::Lines { y, .. } => y,
9444 ScrollDelta::Pixels { y, .. } => y,
9445 };
9446
9447 if delta_y > 0.0 {
9448 return Some(Message::ZoomIn);
9449 }
9450
9451 if delta_y < 0.0 {
9452 return Some(Message::ZoomOut);
9453 }
9454
9455 None
9456}
9457
9458fn text_editor_class(
9459 theme: &cosmic::Theme,
9460 status: cosmic::widget::text_editor::Status,
9461) -> cosmic::iced::widget::text_editor::Style {
9462 let cosmic = theme.cosmic();
9463 let container = theme.current_container();
9464
9465 let mut background: cosmic::iced::Color = container.component.base.into();
9466 background.a = 0.25;
9467 let selection = cosmic.accent.base.into();
9468 let value = cosmic.palette.neutral_9.into();
9469 let mut placeholder = cosmic.palette.neutral_9;
9470 placeholder.alpha = 0.7;
9471 let placeholder = placeholder.into();
9472
9473 match status {
9474 cosmic::iced::widget::text_editor::Status::Active
9475 | cosmic::iced::widget::text_editor::Status::Disabled => {
9476 cosmic::iced::widget::text_editor::Style {
9477 background: background.into(),
9478 border: cosmic::iced::Border {
9479 radius: cosmic.corner_radii.radius_m.into(),
9480 width: 2.0,
9481 color: container.component.divider.into(),
9482 },
9483 placeholder,
9484 value,
9485 selection,
9486 }
9487 }
9488 cosmic::iced::widget::text_editor::Status::Hovered
9489 | cosmic::iced::widget::text_editor::Status::Focused { .. } => {
9490 cosmic::iced::widget::text_editor::Style {
9491 background: background.into(),
9492 border: cosmic::iced::Border {
9493 radius: cosmic.corner_radii.radius_m.into(),
9494 width: 2.0,
9495 color: cosmic::iced::Color::from(cosmic.accent.base),
9496 },
9497 placeholder,
9498 value,
9499 selection,
9500 }
9501 }
9502 }
9503}
9504
9505#[cfg(test)]
9506mod tests {
9507 use std::path::PathBuf;
9508 use std::{fs, io};
9509
9510 use cosmic::iced::mouse::ScrollDelta;
9511 use cosmic::iced::runtime::keyboard::Modifiers;
9512 use cosmic::widget;
9513 use log::{debug, trace};
9514 use mime_guess::mime;
9515 use tempfile::TempDir;
9516 use test_log::test;
9517
9518 use super::{
9519 ItemMetadata, ItemThumbnail, Location, Message, Tab, respond_to_scroll_direction, scan_path,
9520 };
9521 use crate::app::test_utils::{
9522 NAME_LEN, NUM_DIRS, NUM_FILES, NUM_HIDDEN, NUM_NESTED, assert_eq_tab_path, empty_fs,
9523 eq_path_item, filter_dirs, read_dir_sorted, simple_fs, tab_click_new,
9524 };
9525 use crate::config::{IconSizes, TBConfig, TabConfig, ThumbCfg};
9526 use crate::sequencing::SusceptibilityCalls;
9527
9528 fn tab_selects_item(
9530 clicks: &[usize],
9531 modifiers: Modifiers,
9532 expected_selected: &[bool],
9533 ) -> io::Result<()> {
9534 let (_fs, mut tab) = tab_click_new(NUM_FILES, NUM_NESTED, NUM_DIRS, NUM_NESTED, NAME_LEN)?;
9535
9536 for &click in clicks {
9538 debug!("Emitting Message::Click(Some({click})) with modifiers: {modifiers:?}");
9539 tab.update(Message::Click(Some(click)), modifiers);
9540 }
9541
9542 let items = tab
9543 .items_opt
9544 .as_deref()
9545 .expect("tab should be populated with items");
9546
9547 for (i, (&expected, actual)) in expected_selected.iter().zip(items).enumerate() {
9548 assert_eq!(
9549 expected,
9550 actual.selected,
9551 "expected index {i} to be {}",
9552 if expected {
9553 "selected but it was deselected"
9554 } else {
9555 "deselected but it was selected"
9556 }
9557 );
9558 }
9559
9560 Ok(())
9561 }
9562
9563 fn tab_history() -> io::Result<(TempDir, Tab, Vec<PathBuf>)> {
9564 let fs = simple_fs(NUM_FILES, NUM_NESTED, NUM_DIRS, NUM_NESTED, NAME_LEN)?;
9565 let path = fs.path();
9566 let mut tab = Tab::new(
9567 Location::Path(path.into()),
9568 TabConfig::default(),
9569 TBConfig::default(),
9570 ThumbCfg::default(),
9571 None,
9572 widget::Id::unique(),
9573 None,
9574 );
9575
9576 let dirs: Vec<PathBuf> = {
9578 let top_level = filter_dirs(path)?;
9579 let mut result = Vec::new();
9580 for dir in top_level {
9581 let nested_dirs = filter_dirs(&dir)?;
9582 result.push(dir);
9583 result.extend(nested_dirs);
9584 }
9585 result
9586 };
9587 assert!(
9588 dirs.len() == NUM_DIRS + NUM_DIRS * NUM_NESTED,
9589 "Sanity check: Have {} dirs instead of {}",
9590 dirs.len(),
9591 NUM_DIRS + NUM_DIRS * NUM_NESTED
9592 );
9593
9594 debug!("Building history by emitting Message::Location");
9595 for dir in &dirs {
9596 debug!(
9597 "Emitting Message::Location(Location::Path(\"{}\"))",
9598 dir.display()
9599 );
9600 tab.update(
9601 Message::Location(Location::Path(dir.clone())),
9602 Modifiers::empty(),
9603 );
9604 }
9605 trace!("Tab history: {:?}", tab.history);
9606
9607 Ok((fs, tab, dirs))
9608 }
9609
9610 #[test]
9611 fn scan_path_succeeds_on_valid_path() -> io::Result<()> {
9612 let fs = simple_fs(NUM_FILES, NUM_HIDDEN, NUM_DIRS, NUM_NESTED, NAME_LEN)?;
9613 let path = fs.path();
9614
9615 let entries = read_dir_sorted(path)?;
9617
9618 debug!("Calling scan_path(\"{}\")", path.display());
9619 let actual = scan_path(&path.to_owned(), IconSizes::default());
9620
9621 assert_eq!(entries.len(), actual.len());
9623
9624 assert!(
9626 entries
9627 .into_iter()
9628 .zip(actual.into_iter())
9629 .all(|(path, item)| eq_path_item(&path, &item))
9630 );
9631
9632 Ok(())
9633 }
9634
9635 #[test]
9636 fn scan_path_returns_empty_vec_for_invalid_path() -> io::Result<()> {
9637 let fs = simple_fs(NUM_FILES, NUM_NESTED, NUM_DIRS, NUM_NESTED, NAME_LEN)?;
9638 let path = fs.path();
9639
9640 let invalid_path = path.join("ferris");
9642 assert!(!invalid_path.exists());
9643
9644 debug!("Calling scan_path(\"{}\")", invalid_path.display());
9645 let actual = scan_path(&invalid_path, IconSizes::default());
9646
9647 assert!(actual.is_empty());
9648
9649 Ok(())
9650 }
9651
9652 #[test]
9653 fn scan_path_empty_dir_returns_empty_vec() -> io::Result<()> {
9654 let fs = empty_fs()?;
9655 let path = fs.path();
9656
9657 debug!("Calling scan_path(\"{}\")", path.display());
9658 let actual = scan_path(&path.to_owned(), IconSizes::default());
9659
9660 assert_eq!(0, path.read_dir()?.count());
9661 assert!(actual.is_empty());
9662
9663 Ok(())
9664 }
9665
9666 #[test]
9667 fn tab_location_changes_location() -> io::Result<()> {
9668 let fs = simple_fs(NUM_FILES, NUM_NESTED, NUM_DIRS, NUM_NESTED, NAME_LEN)?;
9669 let path = fs.path();
9670
9671 let next_dir = filter_dirs(path)?
9674 .next()
9675 .expect("temp directory should have at least one directory");
9676
9677 let mut tab = Tab::new(
9678 Location::Path(path.to_owned()),
9679 TabConfig::default(),
9680 TBConfig::default(),
9681 ThumbCfg::default(),
9682 None,
9683 widget::Id::unique(),
9684 None,
9685 );
9686 debug!(
9687 "Emitting Message::Location(Location::Path(\"{}\"))",
9688 next_dir.display()
9689 );
9690 tab.update(
9691 Message::Location(Location::Path(next_dir.clone())),
9692 Modifiers::empty(),
9693 );
9694
9695 assert_eq_tab_path(&tab, &next_dir);
9699 assert!(
9700 tab.items_opt.is_none(),
9701 "Tab's `items` is not None which means this test needs to be updated"
9702 );
9703
9704 Ok(())
9705 }
9706
9707 #[test]
9708 fn tab_click_single_selects_item() -> io::Result<()> {
9709 tab_selects_item(&[1], Modifiers::empty(), &[false, true])
9711 }
9712
9713 #[test]
9714 fn tab_click_double_opens_folder() -> io::Result<()> {
9715 let (fs, mut tab) = tab_click_new(NUM_FILES, NUM_NESTED, NUM_DIRS, NUM_NESTED, NAME_LEN)?;
9716 let path = fs.path();
9717
9718 debug!("Emitting double click Message::DoubleClick(Some(1))");
9720 tab.update(Message::DoubleClick(Some(1)), Modifiers::empty());
9721
9722 let second_dir = read_dir_sorted(path)?
9724 .into_iter()
9725 .filter(|p| p.is_dir())
9726 .nth(1)
9727 .expect("should be at least two directories");
9728
9729 assert_eq_tab_path(&tab, &second_dir);
9731
9732 Ok(())
9733 }
9734
9735 #[test]
9736 fn tab_click_ctrl_selects_multiple() -> io::Result<()> {
9737 tab_selects_item(&[0, 1], Modifiers::CTRL, &[true, true])
9739 }
9740
9741 #[test]
9742 fn tab_gonext_moves_forward_in_history() -> io::Result<()> {
9743 let (fs, mut tab, dirs) = tab_history()?;
9744 let path = fs.path();
9745
9746 for _ in 0..dirs.len() {
9748 debug!("Emitting Message::GoPrevious to rewind to the start",);
9749 tab.update(Message::GoPrevious, Modifiers::empty());
9750 }
9751 assert_eq_tab_path(&tab, path);
9752
9753 for dir in dirs {
9755 debug!("Emitting Message::GoNext",);
9756 tab.update(Message::GoNext, Modifiers::empty());
9757 assert_eq_tab_path(&tab, &dir);
9758 }
9759
9760 Ok(())
9761 }
9762
9763 #[test]
9764 fn tab_goprev_moves_backward_in_history() -> io::Result<()> {
9765 let (fs, mut tab, dirs) = tab_history()?;
9766 let path = fs.path();
9767
9768 for dir in dirs.into_iter().rev() {
9769 assert_eq_tab_path(&tab, &dir);
9770 debug!("Emitting Message::GoPrevious",);
9771 tab.update(Message::GoPrevious, Modifiers::empty());
9772 }
9773 assert_eq_tab_path(&tab, path);
9774
9775 Ok(())
9776 }
9777
9778 #[test]
9779 fn tab_scroll_up_with_ctrl_modifier_zooms() -> io::Result<()> {
9780 let message_maybe =
9781 respond_to_scroll_direction(ScrollDelta::Pixels { x: 0.0, y: 1.0 }, &Modifiers::CTRL);
9782 assert!(message_maybe.is_some());
9783 assert!(matches!(message_maybe.unwrap(), Message::ZoomIn));
9784 Ok(())
9785 }
9786
9787 #[test]
9788 fn tab_scroll_up_without_ctrl_modifier_does_not_zoom() -> io::Result<()> {
9789 let message_maybe = respond_to_scroll_direction(
9790 ScrollDelta::Pixels { x: 0.0, y: 1.0 },
9791 &Modifiers::empty(),
9792 );
9793 assert!(message_maybe.is_none());
9794 Ok(())
9795 }
9796
9797 #[test]
9798 fn tab_scroll_down_with_ctrl_modifier_zooms() -> io::Result<()> {
9799 let message_maybe =
9800 respond_to_scroll_direction(ScrollDelta::Pixels { x: 0.0, y: -1.0 }, &Modifiers::CTRL);
9801 assert!(message_maybe.is_some());
9802 assert!(matches!(message_maybe.unwrap(), Message::ZoomOut));
9803 Ok(())
9804 }
9805
9806 #[test]
9807 fn tab_scroll_down_without_ctrl_modifier_does_not_zoom() -> io::Result<()> {
9808 let message_maybe = respond_to_scroll_direction(
9809 ScrollDelta::Pixels { x: 0.0, y: -1.0 },
9810 &Modifiers::empty(),
9811 );
9812 assert!(message_maybe.is_none());
9813 Ok(())
9814 }
9815 #[test]
9816 fn tab_empty_history_does_nothing_on_prev_next() -> io::Result<()> {
9817 let fs = simple_fs(0, NUM_NESTED, NUM_DIRS, 0, NAME_LEN)?;
9818 let path = fs.path();
9819 let mut tab = Tab::new(
9820 Location::Path(path.into()),
9821 TabConfig::default(),
9822 TBConfig::default(),
9823 ThumbCfg::default(),
9824 None,
9825 widget::Id::unique(),
9826 None,
9827 );
9828
9829 debug!("Emitting Message::GoPrevious",);
9831 tab.update(Message::GoPrevious, Modifiers::empty());
9832 assert_eq_tab_path(&tab, path);
9833
9834 debug!("Emitting Message::GoNext",);
9835 tab.update(Message::GoNext, Modifiers::empty());
9836 assert_eq_tab_path(&tab, path);
9837
9838 Ok(())
9839 }
9840
9841 #[test]
9842 fn tab_locationup_moves_up_hierarchy() -> io::Result<()> {
9843 let fs = simple_fs(0, NUM_NESTED, NUM_DIRS, 0, NAME_LEN)?;
9844 let path = fs.path();
9845 let mut next_dir = filter_dirs(path)?
9846 .next()
9847 .expect("should be at least one directory");
9848
9849 let mut tab = Tab::new(
9850 Location::Path(next_dir.clone()),
9851 TabConfig::default(),
9852 TBConfig::default(),
9853 ThumbCfg::default(),
9854 None,
9855 widget::Id::unique(),
9856 None,
9857 );
9858 while next_dir.pop() {
9860 debug!("Emitting Message::LocationUp",);
9861 tab.update(Message::LocationUp, Modifiers::empty());
9862 assert_eq_tab_path(&tab, &next_dir);
9863 }
9864
9865 Ok(())
9866 }
9867
9868 #[test]
9869 fn sort_long_number_file_names() -> io::Result<()> {
9870 let fs = empty_fs()?;
9871 let path = fs.path();
9872
9873 let mut base_nums: Vec<_> = ('0'..='9').collect();
9877 fastrand::shuffle(&mut base_nums);
9878 debug!("Shuffled numbers for paths: {base_nums:?}");
9879 let paths: Vec<_> = base_nums
9880 .iter()
9881 .copied()
9882 .map(|base| path.join(std::iter::repeat_n(base, 255).collect::<String>()))
9883 .collect();
9884
9885 for (file, base) in paths.iter().zip(base_nums.into_iter()) {
9886 trace!("Creating long file name for {base}");
9887 fs::File::create(file)?;
9888 }
9889
9890 debug!("Creating tab for directory of long file names");
9891 Tab::new(
9892 Location::Path(path.into()),
9893 TabConfig::default(),
9894 TBConfig::default(),
9895 ThumbCfg::default(),
9896 None,
9897 widget::Id::unique(),
9898 None,
9899 );
9900
9901 Ok(())
9902 }
9903
9904 #[test]
9905 fn mode_calculations() {
9906 use super::{
9907 MODE_SHIFT_GROUP, MODE_SHIFT_OTHER, MODE_SHIFT_USER, get_mode_part, set_mode_part,
9908 };
9909 for user in 0..=7 {
9910 for group in 0..=7 {
9911 for other in 0..=7 {
9912 let mode = (user << MODE_SHIFT_USER)
9913 | (group << MODE_SHIFT_GROUP)
9914 | (other << MODE_SHIFT_OTHER);
9915 assert_eq!(format!("{mode:03o}"), format!("{user:o}{group:o}{other:o}"),);
9916 assert_eq!(get_mode_part(mode, MODE_SHIFT_USER), user);
9917 assert_eq!(get_mode_part(mode, MODE_SHIFT_GROUP), group);
9918 assert_eq!(get_mode_part(mode, MODE_SHIFT_OTHER), other);
9919
9920 let mode_no_user = (group << MODE_SHIFT_GROUP) | (other << MODE_SHIFT_OTHER);
9921 assert_eq!(
9922 format!("{mode_no_user:03o}"),
9923 format!("0{group:o}{other:o}")
9924 );
9925 assert_eq!(set_mode_part(mode_no_user, MODE_SHIFT_USER, user), mode);
9926
9927 let mode_no_group = (user << MODE_SHIFT_USER) | (other << MODE_SHIFT_OTHER);
9928 assert_eq!(
9929 format!("{mode_no_group:03o}"),
9930 format!("{user:o}0{other:o}")
9931 );
9932 assert_eq!(set_mode_part(mode_no_group, MODE_SHIFT_GROUP, group), mode);
9933
9934 let mode_no_other = (user << MODE_SHIFT_USER) | (group << MODE_SHIFT_GROUP);
9935 assert_eq!(
9936 format!("{mode_no_other:03o}"),
9937 format!("{user:o}{group:o}0")
9938 );
9939 assert_eq!(set_mode_part(mode_no_other, MODE_SHIFT_OTHER, other), mode);
9940 }
9941 }
9942 }
9943 }
9944
9945 #[test]
9946 fn item_thumbnail_text_preview_small_utf8_returns_text() -> io::Result<()> {
9947 let dir = TempDir::new()?;
9948 let path = dir.path().join("preview.txt");
9949 fs::write(&path, "Hello, world!")?;
9950 let metadata = fs::metadata(&path)?;
9951 let item_metadata = ItemMetadata::Path {
9952 metadata,
9953 children_opt: None,
9954 tbprofilerjson_opt: None,
9955 is_ab1: false,
9956 sequence_opt: None,
9957 is_tbprofiler_result_as_sample: false,
9958 is_tbprofiler_groupable_raw_result_file: false,
9959 sample_json_path_opt: None,
9960 sample_csv_path_opt: None,
9961 sample_docx_path_opt: None,
9962 is_susceptible: None,
9963 susceptibility_calls: SusceptibilityCalls::default(),
9964 };
9965 let thumb = ItemThumbnail::new(
9966 &path,
9967 item_metadata,
9968 mime::TEXT_PLAIN,
9969 128,
9970 100 * 1024 * 1024,
9971 1,
9972 8,
9973 );
9974 assert!(
9975 matches!(thumb, ItemThumbnail::Text(_)),
9976 "small text file should produce Text thumbnail"
9977 );
9978 Ok(())
9979 }
9980
9981 #[test]
9982 fn item_thumbnail_text_preview_empty_file_returns_not_image() -> io::Result<()> {
9983 let dir = TempDir::new()?;
9984 let path = dir.path().join("empty.txt");
9985 fs::File::create(&path)?;
9986 let metadata = fs::metadata(&path)?;
9987 let item_metadata = ItemMetadata::Path {
9988 metadata,
9989 children_opt: None,
9990 tbprofilerjson_opt: None,
9991 is_ab1: false,
9992 sequence_opt: None,
9993 is_tbprofiler_result_as_sample: false,
9994 is_tbprofiler_groupable_raw_result_file: false,
9995 sample_json_path_opt: None,
9996 sample_csv_path_opt: None,
9997 sample_docx_path_opt: None,
9998 is_susceptible: None,
9999 susceptibility_calls: SusceptibilityCalls::default(),
10000 };
10001 let thumb = ItemThumbnail::new(
10002 &path,
10003 item_metadata,
10004 mime::TEXT_PLAIN,
10005 128,
10006 100 * 1024 * 1024,
10007 1,
10008 8,
10009 );
10010 assert!(
10011 matches!(thumb, ItemThumbnail::NotImage),
10012 "empty text file should produce NotImage (no read)"
10013 );
10014 Ok(())
10015 }
10016
10017 #[test]
10018 fn item_thumbnail_text_preview_invalid_utf8_uses_valid_prefix() -> io::Result<()> {
10019 let dir = TempDir::new()?;
10020 let path = dir.path().join("invalid_utf8.txt");
10021 fs::write(&path, b"ab\xff\xfe\xfdc")?;
10023 let metadata = fs::metadata(&path)?;
10024 let item_metadata = ItemMetadata::Path {
10025 metadata,
10026 children_opt: None,
10027 tbprofilerjson_opt: None,
10028 is_ab1: false,
10029 sequence_opt: None,
10030 is_tbprofiler_result_as_sample: false,
10031 is_tbprofiler_groupable_raw_result_file: false,
10032 sample_json_path_opt: None,
10033 sample_csv_path_opt: None,
10034 sample_docx_path_opt: None,
10035 is_susceptible: None,
10036 susceptibility_calls: SusceptibilityCalls::default(),
10037 };
10038 let thumb = ItemThumbnail::new(
10039 &path,
10040 item_metadata,
10041 mime::TEXT_PLAIN,
10042 128,
10043 100 * 1024 * 1024,
10044 1,
10045 8,
10046 );
10047 match &thumb {
10048 ItemThumbnail::Text(content) => {
10049 assert_eq!(content.text().trim_end(), "ab");
10051 }
10052 _ => panic!(
10053 "expected Text thumbnail with valid prefix only, got {:?}",
10054 thumb
10055 ),
10056 }
10057 Ok(())
10058 }
10059}