From 7efe6efa30bb61fc630bd0d981ba4e6fec279688 Mon Sep 17 00:00:00 2001 From: Jeremy Soller Date: Fri, 5 Jan 2024 08:55:18 -0700 Subject: [PATCH] Rescan in background and describe empty folders --- Cargo.toml | 2 +- i18n/en/cosmic_files.ftl | 3 + src/main.rs | 62 +++++++--- src/mime_icon.rs | 6 +- src/tab.rs | 260 +++++++++++++++++++++++---------------- 5 files changed, 211 insertions(+), 122 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 3ce8960..17e89b0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ env_logger = "0.10" lazy_static = "1" log = "0.4" serde = { version = "1", features = ["serde_derive"] } -tokio = { version = "1", features = ["sync"] } +tokio = { version = "1" } # Internationalization i18n-embed = { version = "0.13", features = ["fluent-system", "desktop-requester"] } i18n-embed-fl = "0.6" diff --git a/i18n/en/cosmic_files.ftl b/i18n/en/cosmic_files.ftl index 30938f9..e852c52 100644 --- a/i18n/en/cosmic_files.ftl +++ b/i18n/en/cosmic_files.ftl @@ -1,3 +1,6 @@ +empty-folder = Empty folder +empty-folder-hidden = Empty folder (has hidden items) + # Context Pages ## Settings diff --git a/src/main.rs b/src/main.rs index 2c6a60f..cf1917a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,7 +2,7 @@ // SPDX-License-Identifier: GPL-3.0-only use cosmic::{ - app::{Command, Core, Settings}, + app::{message, Command, Core, Settings}, cosmic_config::{self, CosmicConfigEntry}, cosmic_theme, executor, iced::{subscription::Subscription, widget::row, window, Alignment, Length, Point}, @@ -131,6 +131,7 @@ pub enum Message { TabContextMenu(segmented_button::Entity, Option), TabMessage(segmented_button::Entity, tab::Message), TabNew, + TabRescan(segmented_button::Entity, Vec), ToggleContextPage(ContextPage), } @@ -159,14 +160,37 @@ pub struct App { impl App { fn open_tab>(&mut self, path: P) -> Command { - let tab = Tab::new(path); - self.tab_model + let path = path.into(); + let tab = Tab::new(path.clone()); + let entity = self + .tab_model .insert() .text(tab.title()) .data(tab) .closable() - .activate(); - self.update_title() + .activate() + .id(); + Command::batch([self.update_title(), self.rescan_tab(entity, path)]) + } + + fn rescan_tab>( + &mut self, + entity: segmented_button::Entity, + path: P, + ) -> Command { + let path = path.into(); + Command::perform( + async move { + match tokio::task::spawn_blocking(move || tab::rescan(path)).await { + Ok(items) => message::app(Message::TabRescan(entity, items)), + Err(err) => { + log::warn!("failed to rescan: {}", err); + message::none() + } + } + }, + |x| x, + ) } fn update_config(&mut self) -> Command { @@ -258,17 +282,17 @@ impl Application for App { context_page: ContextPage::Settings, }; + let mut commands = Vec::new(); + for arg in env::args().skip(1) { - let _ = app.open_tab(arg); + commands.push(app.open_tab(arg)); } if app.tab_model.iter().next().is_none() { - let _ = app.open_tab(home_dir()); + commands.push(app.open_tab(home_dir())); } - let command = app.update_title(); - - (app, command) + (app, Command::batch(commands)) } /// Handle application events here. @@ -339,18 +363,21 @@ impl Application for App { } } Message::TabMessage(entity, tab_message) => { - let mut tab_title_opt = None; + let mut update_opt = None; match self.tab_model.data_mut::(entity) { Some(tab) => { if tab.update(tab_message) { - tab_title_opt = Some(tab.title()); + update_opt = Some((tab.title(), tab.path.clone())); } } _ => (), } - if let Some(tab_title) = tab_title_opt { + if let Some((tab_title, tab_path)) = update_opt { self.tab_model.text_set(entity, tab_title); - return self.update_title(); + return Command::batch([ + self.update_title(), + self.rescan_tab(entity, tab_path), + ]); } } Message::TabNew => { @@ -361,6 +388,13 @@ impl Application for App { }; return self.open_tab(path); } + Message::TabRescan(entity, items) => match self.tab_model.data_mut::(entity) { + Some(tab) => { + tab.items_opt = Some(items); + } + _ => (), + }, + //TODO: TABRELOAD Message::ToggleContextPage(context_page) => { if self.context_page == context_page { self.core.window.show_context = !self.core.window.show_context; diff --git a/src/mime_icon.rs b/src/mime_icon.rs index 40fca06..a9b13f1 100644 --- a/src/mime_icon.rs +++ b/src/mime_icon.rs @@ -43,7 +43,7 @@ lazy_static::lazy_static! { static ref MIME_ICON_CACHE: Mutex = Mutex::new(MimeIconCache::new()); } -pub fn mime_icon>(path: P, size: u16) -> icon::Icon { +pub fn mime_icon>(path: P, size: u16) -> icon::Handle { //TODO: smarter path handling let path = path .as_ref() @@ -52,7 +52,7 @@ pub fn mime_icon>(path: P, size: u16) -> icon::Icon { .to_owned(); let mut mime_icon_cache = MIME_ICON_CACHE.lock().unwrap(); match mime_icon_cache.get(MimeIconKey { path, size }) { - Some(handle) => icon::icon(handle).size(size), - None => icon::from_name(FALLBACK_MIME_ICON).size(size).icon(), + Some(handle) => handle, + None => icon::from_name(FALLBACK_MIME_ICON).size(size).handle(), } } diff --git a/src/tab.rs b/src/tab.rs index 70dc5d9..99cfce3 100644 --- a/src/tab.rs +++ b/src/tab.rs @@ -1,19 +1,22 @@ use cosmic::{ app::Core, cosmic_theme, - iced::{Alignment, Length, Point}, + iced::{ + alignment::{Horizontal, Vertical}, + Alignment, Length, Point, + }, theme, widget, Element, }; use std::{ cmp::Ordering, collections::HashMap, - fs, + fmt, fs, path::PathBuf, process, time::{Duration, Instant}, }; -use crate::mime_icon::mime_icon; +use crate::{fl, mime_icon::mime_icon}; const DOUBLE_CLICK_DURATION: Duration = Duration::from_millis(500); @@ -51,10 +54,10 @@ lazy_static::lazy_static! { }; } -fn folder_icon(path: &PathBuf, icon_size: u16) -> widget::icon::Icon { +fn folder_icon(path: &PathBuf, icon_size: u16) -> widget::icon::Handle { widget::icon::from_name(SPECIAL_DIRS.get(path).map_or("folder", |x| *x)) .size(icon_size) - .icon() + .handle() } #[cfg(not(target_os = "windows"))] @@ -115,26 +118,98 @@ pub enum Message { Parent, } +#[derive(Clone)] pub struct Item { pub name: String, pub path: PathBuf, pub hidden: bool, pub is_dir: bool, - pub icon: widget::icon::Icon, + pub icon_handle: widget::icon::Handle, pub select_time: Option, } +impl fmt::Debug for Item { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Item") + .field("name", &self.name) + .field("path", &self.path) + .field("hidden", &self.hidden) + .field("is_dir", &self.is_dir) + //icon_handle + .field("select_time", &self.select_time) + .finish() + } +} + +#[derive(Clone, Debug)] pub struct Tab { pub path: PathBuf, //TODO pub context_menu: Option, - pub items: Vec, + pub items_opt: Option>, +} + +pub fn rescan(tab_path: PathBuf) -> Vec { + let mut items = Vec::new(); + match fs::read_dir(&tab_path) { + Ok(entries) => { + for entry_res in entries { + let entry = match entry_res { + Ok(ok) => ok, + Err(err) => { + log::warn!("failed to read entry in {:?}: {}", tab_path, err); + continue; + } + }; + + let name = match entry.file_name().into_string() { + Ok(some) => some, + Err(name_os) => { + log::warn!( + "failed to parse entry in {:?}: {:?} is not valid UTF-8", + tab_path, + name_os, + ); + continue; + } + }; + + let path = entry.path(); + let hidden = name.starts_with(".") || hidden_attribute(&path); + let is_dir = path.is_dir(); + //TODO: configurable size + let icon_size = 32; + let icon_handle = if is_dir { + folder_icon(&path, icon_size) + } else { + mime_icon(&path, icon_size) + }; + + items.push(Item { + name, + path, + hidden, + is_dir, + icon_handle, + select_time: None, + }); + } + } + Err(err) => { + log::warn!("failed to read directory {:?}: {}", tab_path, err); + } + } + items.sort_by(|a, b| match (a.is_dir, b.is_dir) { + (true, false) => Ordering::Less, + (false, true) => Ordering::Greater, + _ => a.name.cmp(&b.name), + }); + items } impl Tab { - pub fn new>(path: P) -> Self { - let path = path.into(); - let mut tab = Self { + pub fn new(path: PathBuf) -> Self { + Self { path: match fs::canonicalize(&path) { Ok(absolute) => absolute, Err(err) => { @@ -143,67 +218,8 @@ impl Tab { } }, context_menu: None, - items: Vec::new(), - }; - tab.rescan(); - tab - } - - pub fn rescan(&mut self) { - self.items.clear(); - match fs::read_dir(&self.path) { - Ok(entries) => { - for entry_res in entries { - let entry = match entry_res { - Ok(ok) => ok, - Err(err) => { - log::warn!("failed to read entry in {:?}: {}", self.path, err); - continue; - } - }; - - let name = match entry.file_name().into_string() { - Ok(some) => some, - Err(name_os) => { - log::warn!( - "failed to parse entry in {:?}: {:?} is not valid UTF-8", - self.path, - name_os, - ); - continue; - } - }; - - let path = entry.path(); - let hidden = name.starts_with(".") || hidden_attribute(&path); - let is_dir = path.is_dir(); - //TODO: configurable size - let icon_size = 32; - let icon = if is_dir { - folder_icon(&path, icon_size) - } else { - mime_icon(&path, icon_size) - }; - - self.items.push(Item { - name, - path, - hidden, - is_dir, - icon, - select_time: None, - }); - } - } - Err(err) => { - log::warn!("failed to read directory {:?}: {}", self.path, err); - } + items_opt: None, } - self.items.sort_by(|a, b| match (a.is_dir, b.is_dir) { - (true, false) => Ordering::Less, - (false, true) => Ordering::Greater, - _ => a.name.cmp(&b.name), - }); } pub fn title(&self) -> String { @@ -215,27 +231,33 @@ impl Tab { let mut cd = None; match message { Message::Click(click_i) => { - for (i, item) in self.items.iter_mut().enumerate() { - if i == click_i { - if let Some(select_time) = item.select_time { - if select_time.elapsed() < DOUBLE_CLICK_DURATION { - if item.is_dir { - cd = Some(item.path.clone()); - } else { - let mut command = open_command(&item.path); - match command.spawn() { - Ok(_) => (), - Err(err) => { - log::warn!("failed to open {:?}: {}", item.path, err); + if let Some(ref mut items) = self.items_opt { + for (i, item) in items.iter_mut().enumerate() { + if i == click_i { + if let Some(select_time) = item.select_time { + if select_time.elapsed() < DOUBLE_CLICK_DURATION { + if item.is_dir { + cd = Some(item.path.clone()); + } else { + let mut command = open_command(&item.path); + match command.spawn() { + Ok(_) => (), + Err(err) => { + log::warn!( + "failed to open {:?}: {}", + item.path, + err + ); + } } } } } + //TODO: prevent triple-click and beyond from opening file + item.select_time = Some(Instant::now()); + } else { + item.select_time = None; } - //TODO: prevent triple-click and beyond from opening file - item.select_time = Some(Instant::now()); - } else { - item.select_time = None; } } } @@ -250,7 +272,7 @@ impl Tab { } if let Some(path) = cd { self.path = path; - self.rescan(); + self.items_opt = None; true } else { false @@ -261,30 +283,60 @@ impl Tab { let cosmic_theme::Spacing { space_xxs, .. } = core.system_theme().cosmic().spacing; let mut column = widget::column(); - for (i, item) in self.items.iter().enumerate() { - if item.hidden { - //TODO: SHOW HIDDEN OPTION - continue; + if let Some(ref items) = self.items_opt { + let mut count = 0; + let mut hidden = 0; + for (i, item) in items.iter().enumerate() { + if item.hidden { + hidden += 1; + //TODO: SHOW HIDDEN OPTION + continue; + } + + column = column.push( + widget::button( + widget::row::with_children(vec![ + widget::icon::icon(item.icon_handle.clone()).into(), + widget::text(item.name.clone()).into(), + ]) + .align_items(Alignment::Center) + .spacing(space_xxs), + ) + //TODO: improve style + .style(if item.select_time.is_some() { + theme::Button::Standard + } else { + theme::Button::AppletMenu + }) + .width(Length::Fill) + .on_press(Message::Click(i)), + ); + count += 1; } - column = column.push( - widget::button( - widget::row::with_children(vec![ - item.icon.clone().into(), - widget::text(item.name.clone()).into(), + if count == 0 { + return widget::container( + widget::column::with_children(vec![ + widget::icon::from_name("folder-symbolic") + .size(64) + .icon() + .into(), + widget::text(if hidden > 0 { + fl!("empty-folder-hidden") + } else { + fl!("empty-folder") + }) + .into(), ]) .align_items(Alignment::Center) .spacing(space_xxs), ) - //TODO: improve style - .style(if item.select_time.is_some() { - theme::Button::Standard - } else { - theme::Button::AppletMenu - }) + .align_x(Horizontal::Center) + .align_y(Vertical::Center) + .height(Length::Fill) .width(Length::Fill) - .on_press(Message::Click(i)), - ); + .into(); + } } widget::scrollable(column.width(Length::Fill)).into() }