From e25cd37f2d8621f45fec6e0d7096dda95ade1616 Mon Sep 17 00:00:00 2001 From: Jeremy Soller Date: Wed, 9 Oct 2024 15:41:10 -0600 Subject: [PATCH] Search redesign, fixes #550, fixes #287, fixes part of #224 --- src/app.rs | 146 ++++++-------- src/config.rs | 2 +- src/dialog.rs | 151 +++++++------- src/lib.rs | 2 + src/menu.rs | 29 ++- src/mounter/mod.rs | 3 + src/tab.rs | 491 ++++++++++++++++++++++++--------------------- 7 files changed, 414 insertions(+), 410 deletions(-) diff --git a/src/app.rs b/src/app.rs index 3fd0499..66fb79d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -59,13 +59,11 @@ use wayland_client::{protocol::wl_output::WlOutput, Proxy}; use crate::{ clipboard::{ClipboardCopy, ClipboardKind, ClipboardPaste}, config::{AppTheme, Config, DesktopConfig, Favorite, IconSizes, TabConfig}, - desktop_dir, fl, home_dir, + fl, home_dir, key_bind::key_binds, localize::LANGUAGE_SORTER, menu, mime_app, mime_icon, - mounter::{ - mounters, MounterAuth, MounterItem, MounterItems, MounterKey, MounterMessage, Mounters, - }, + mounter::{MounterAuth, MounterItem, MounterItems, MounterKey, MounterMessage, MOUNTERS}, operation::{Operation, ReplaceResult}, spawn_detached::spawn_detached, tab::{self, HeadingOptions, ItemMetadata, Location, Tab, HOVER_DURATION}, @@ -308,7 +306,6 @@ pub enum Message { SearchActivate, SearchClear, SearchInput(String), - SearchSubmit, SystemThemeModeChange(cosmic_theme::ThemeMode), TabActivate(Entity), TabNext, @@ -491,7 +488,6 @@ pub struct App { dialog_text_input: widget::Id, key_binds: HashMap, modifiers: Modifiers, - mounters: Mounters, mounter_items: HashMap, network_drive_connecting: Option<(MounterKey, String)>, network_drive_input: String, @@ -501,9 +497,7 @@ pub struct App { pending_operations: BTreeMap, complete_operations: BTreeMap, failed_operations: BTreeMap, - search_active: bool, search_id: widget::Id, - search_input: String, #[cfg(feature = "wayland")] surface_ids: HashMap, #[cfg(feature = "wayland")] @@ -588,17 +582,11 @@ impl App { selection_path: Option, ) -> Command { log::info!("rescan_tab {entity:?} {location:?} {selection_path:?}"); - let desktop_config = self.config.desktop; - let mounters = self.mounters.clone(); let icon_sizes = self.config.tab.icon_sizes; Command::perform( async move { let location2 = location.clone(); - match tokio::task::spawn_blocking(move || { - location2.scan(desktop_config, mounters, icon_sizes) - }) - .await - { + match tokio::task::spawn_blocking(move || location2.scan(icon_sizes)).await { Ok(items) => { message::app(Message::TabRescan(entity, location, items, selection_path)) } @@ -630,25 +618,55 @@ impl App { } fn search(&mut self) -> Command { + if let Some(term) = self.search_get() { + self.search_set(Some(term.to_string())) + } else { + Command::none() + } + } + + fn search_get(&self) -> Option<&str> { + let entity = self.tab_model.active(); + let tab = self.tab_model.data::(entity)?; + match &tab.location { + Location::Search(_, term, ..) => Some(term), + _ => None, + } + } + + fn search_set(&mut self, term_opt: Option) -> Command { let entity = self.tab_model.active(); let mut title_location_opt = None; if let Some(tab) = self.tab_model.data_mut::(entity) { - if let Some(path) = tab.location.path_opt() { - let location = if !self.search_input.is_empty() { - Location::Search(path.to_path_buf(), self.search_input.clone()) - } else { - Location::Path(path.to_path_buf()) - }; + let location_opt = match term_opt { + Some(term) => match &tab.location { + Location::Path(path) | Location::Search(path, ..) => Some(( + Location::Search(path.to_path_buf(), term, Instant::now()), + true, + )), + _ => None, + }, + None => match &tab.location { + Location::Search(path, ..) => Some((Location::Path(path.to_path_buf()), false)), + _ => None, + }, + }; + if let Some((location, focus_search)) = location_opt { tab.change_location(&location, None); - title_location_opt = Some((tab.title(), tab.location.clone())); + title_location_opt = Some((tab.title(), tab.location.clone(), focus_search)); } } - if let Some((title, location)) = title_location_opt { + if let Some((title, location, focus_search)) = title_location_opt { self.tab_model.text_set(entity, title); return Command::batch([ self.update_title(), self.update_watcher(), self.rescan_tab(entity, location, None), + if focus_search { + widget::text_input::focus(self.search_id.clone()) + } else { + Command::none() + }, ]); } Command::none() @@ -760,7 +778,7 @@ impl App { .divider_above() }); - if !self.mounters.is_empty() { + if !MOUNTERS.is_empty() { nav_model = nav_model.insert(|b| { b.text(fl!("networks")) .icon(widget::icon::icon( @@ -1226,7 +1244,6 @@ impl Application for App { dialog_text_input: widget::Id::unique(), key_binds, modifiers: Modifiers::empty(), - mounters: mounters(), mounter_items: HashMap::new(), network_drive_connecting: None, network_drive_input: String::new(), @@ -1236,9 +1253,7 @@ impl Application for App { pending_operations: BTreeMap::new(), complete_operations: BTreeMap::new(), failed_operations: BTreeMap::new(), - search_active: false, search_id: widget::Id::unique(), - search_input: String::new(), #[cfg(feature = "wayland")] surface_ids: HashMap::new(), #[cfg(feature = "wayland")] @@ -1364,9 +1379,6 @@ impl Application for App { } fn on_nav_select(&mut self, entity: Entity) -> Command { - self.search_active = false; - self.search_input.clear(); - self.nav_model.activate(entity); if let Some(location) = self.nav_model.data::(entity) { let message = Message::TabMessage(None, tab::Message::Location(location.clone())); @@ -1374,7 +1386,7 @@ impl Application for App { } if let Some(data) = self.nav_model.data::(entity).clone() { - if let Some(mounter) = self.mounters.get(&data.0) { + if let Some(mounter) = MOUNTERS.get(&data.0) { return mounter.mount(data.1.clone()).map(|_| message::none()); } } @@ -1391,7 +1403,7 @@ impl Application for App { fn on_context_drawer(&mut self) -> Command { match self.context_page { - ContextPage::Preview(_, _) => { + ContextPage::Preview(..) => { // Persist state of preview page if self.core.window.show_context != self.config.show_details { return self.update(Message::Preview(None)); @@ -1426,10 +1438,9 @@ impl Application for App { self.set_show_context(false); return Command::none(); } - if self.search_active { + if self.search_get().is_some() { // Close search if open - self.search_active = false; - return Command::none(); + return self.search_set(None); } if let Some(tab) = self.tab_model.data_mut::(entity) { if tab.context_menu.is_some() { @@ -1815,7 +1826,7 @@ impl Application for App { } Message::NetworkDriveSubmit => { //TODO: know which mounter to use for network drives - for (mounter_key, mounter) in self.mounters.iter() { + for (mounter_key, mounter) in MOUNTERS.iter() { self.network_drive_connecting = Some((*mounter_key, self.network_drive_input.clone())); return mounter @@ -2171,11 +2182,8 @@ impl Application for App { commands.push(self.update_notification()); // Manually rescan any trash tabs after any operation is completed commands.push(self.rescan_trash()); - // if search is active, update "search" tab view - if !self.search_input.is_empty() { - commands.push(self.search()); - } + commands.push(self.search()); return Command::batch(commands); } Message::PendingError(id, err) => { @@ -2327,33 +2335,17 @@ impl Application for App { } } Message::SearchActivate => { - self.search_active = true; - return widget::text_input::focus(self.search_id.clone()); + return if self.search_get().is_none() { + self.search_set(Some(String::new())) + } else { + widget::text_input::focus(self.search_id.clone()) + }; } Message::SearchClear => { - self.search_active = false; - self.search_input.clear(); + return self.search_set(None); } Message::SearchInput(input) => { - if input != self.search_input { - self.search_input = input; - /*TODO: live search? (probably needs subscription for streaming results) - // This performs live search - if !self.search_input.is_empty() { - return self.search(); - } - */ - } - } - Message::SearchSubmit => { - if !self.search_input.is_empty() { - return self.search(); - } else { - // rescan the tab to get the contents back - // and exit search - self.search_active = false; - return self.search(); - } + return self.search_set(Some(input)); } Message::SystemThemeModeChange(_theme_mode) => { return self.update_config(); @@ -2364,14 +2356,7 @@ impl Application for App { if let Some(tab) = self.tab_model.data::(entity) { self.activate_nav_model_location(&tab.location.clone()); } - let mut commands = vec![]; - commands.push(self.update_title()); - // if the tab was in an active search mode - // search again in case files were modified/deleted - if !self.search_input.is_empty() { - commands.push(self.search()); - } - return Command::batch(commands); + return self.update_title(); } Message::TabNext => { let len = self.tab_model.iter().count(); @@ -2658,15 +2643,11 @@ impl Application for App { self.toasts.remove(id); let mut paths = Vec::with_capacity(recently_trashed.len()); - let desktop_config = self.config.desktop; - let mounters = self.mounters.clone(); let icon_sizes = self.config.tab.icon_sizes; return cosmic::command::future(async move { - match tokio::task::spawn_blocking(move || { - Location::Trash.scan(desktop_config, mounters, icon_sizes) - }) - .await + match tokio::task::spawn_blocking(move || Location::Trash.scan(icon_sizes)) + .await { Ok(items) => { for path in &*recently_trashed { @@ -2885,7 +2866,7 @@ impl Application for App { Message::NavBarClose(entity) => { if let Some(data) = self.nav_model.data::(entity) { - if let Some(mounter) = self.mounters.get(&data.0) { + if let Some(mounter) = MOUNTERS.get(&data.0) { return mounter.unmount(data.1.clone()).map(|_| message::none()); } } @@ -3005,7 +2986,7 @@ impl Application for App { }; let (entity, command) = self.open_tab_entity( - Location::Desktop(desktop_dir(), display), + Location::Desktop(crate::desktop_dir(), display), false, None, ); @@ -3614,14 +3595,13 @@ impl Application for App { ); } - if self.search_active { + if let Some(term) = self.search_get() { elements.push( - widget::text_input::search_input("", &self.search_input) + widget::text_input::search_input("", term) .width(Length::Fixed(240.0)) .id(self.search_id.clone()) .on_clear(Message::SearchClear) .on_input(Message::SearchInput) - .on_submit(Message::SearchSubmit) .into(), ) } else { @@ -3955,7 +3935,7 @@ impl Application for App { ), ]; - for (key, mounter) in self.mounters.iter() { + for (key, mounter) in MOUNTERS.iter() { let key = *key; subscriptions.push(mounter.subscription().map(move |mounter_message| { match mounter_message { diff --git a/src/config.rs b/src/config.rs index 229d943..a527a88 100644 --- a/src/config.rs +++ b/src/config.rs @@ -148,7 +148,7 @@ impl Default for Config { } } -#[derive(Clone, Copy, Debug, Eq, PartialEq, CosmicConfigEntry, Deserialize, Serialize)] +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, CosmicConfigEntry, Deserialize, Serialize)] #[serde(default)] pub struct DesktopConfig { pub show_content: bool, diff --git a/src/dialog.rs b/src/dialog.rs index b8a1dab..323eda4 100644 --- a/src/dialog.rs +++ b/src/dialog.rs @@ -35,7 +35,7 @@ use std::{ env, fmt, fs, path::PathBuf, str::FromStr, - time, + time::{self, Instant}, }; use crate::{ @@ -45,7 +45,7 @@ use crate::{ key_bind::key_binds, localize::LANGUAGE_SORTER, menu, - mounter::{mounters, MounterItem, MounterItems, MounterKey, MounterMessage, Mounters}, + mounter::{MounterItem, MounterItems, MounterKey, MounterMessage, MOUNTERS}, tab::{self, ItemMetadata, Location, Tab}, }; @@ -323,7 +323,6 @@ enum Message { SearchActivate, SearchClear, SearchInput(String), - SearchSubmit, TabMessage(tab::Message), TabRescan(Vec), } @@ -380,22 +379,31 @@ struct App { filter_selected: Option, filename_id: widget::Id, modifiers: Modifiers, - mounters: Mounters, mounter_items: HashMap, nav_model: segmented_button::SingleSelectModel, result_opt: Option, - search_active: bool, search_id: widget::Id, - search_input: String, tab: Tab, key_binds: HashMap, watcher_opt: Option<(Debouncer, HashSet)>, } impl App { - fn button_row(&self) -> Element { + fn button_view(&self) -> Element { let cosmic_theme::Spacing { space_xxs, .. } = theme::active().cosmic().spacing; + let mut col = widget::column::with_capacity(2) + .spacing(space_xxs) + .padding(space_xxs); + if let DialogKind::SaveFile { filename } = &self.flags.kind { + col = col.push( + widget::text_input("", filename) + .id(self.filename_id.clone()) + .on_input(Message::Filename) + .on_submit(Message::Save(false)), + ); + } + let mut row = widget::row::with_capacity( if !self.filters.is_empty() { 1 } else { 0 } + self.choices.len() * 2 + 3, ) @@ -429,16 +437,7 @@ impl App { } } } - if let DialogKind::SaveFile { filename } = &self.flags.kind { - row = row.push( - widget::text_input("", filename) - .id(self.filename_id.clone()) - .on_input(Message::Filename) - .on_submit(Message::Save(false)), - ); - } else { - row = row.push(widget::horizontal_space(Length::Fill)); - } + row = row.push(widget::horizontal_space(Length::Fill)); row = row.push(widget::button::standard(fl!("cancel")).on_press(Message::Cancel)); row = row.push(if self.flags.kind.save() { widget::button::suggested(&self.accept_label).on_press(Message::Save(false)) @@ -446,7 +445,9 @@ impl App { widget::button::suggested(&self.accept_label).on_press(Message::Open) }); - row.into() + col = col.push(row); + + col.into() } fn preview(&self, kind: &PreviewKind) -> Element { @@ -485,16 +486,10 @@ impl App { fn rescan_tab(&self) -> Command { let location = self.tab.location.clone(); - let desktop_config = self.flags.config.desktop; - let mounters = self.mounters.clone(); let icon_sizes = self.tab.config.icon_sizes; Command::perform( async move { - match tokio::task::spawn_blocking(move || { - location.scan(desktop_config, mounters, icon_sizes) - }) - .await - { + match tokio::task::spawn_blocking(move || location.scan(icon_sizes)).await { Ok(items) => message::app(Message::TabRescan(items)), Err(err) => { log::warn!("failed to rescan: {}", err); @@ -506,21 +501,43 @@ impl App { ) } - fn search(&mut self) -> Command { + fn search_get(&self) -> Option<&str> { match &self.tab.location { - Location::Path(path) | Location::Search(path, ..) => { - let location = if !self.search_input.is_empty() { - Location::Search(path.clone(), self.search_input.clone()) - } else { - Location::Path(path.clone()) - }; - self.tab.change_location(&location, None); - Command::batch([self.update_watcher(), self.rescan_tab()]) - } - _ => Command::none(), + Location::Search(_, term, ..) => Some(term), + _ => None, } } + fn search_set(&mut self, term_opt: Option) -> Command { + let location_opt = match term_opt { + Some(term) => match &self.tab.location { + Location::Path(path) | Location::Search(path, ..) => Some(( + Location::Search(path.to_path_buf(), term, Instant::now()), + true, + )), + _ => None, + }, + None => match &self.tab.location { + Location::Search(path, ..) => Some((Location::Path(path.to_path_buf()), false)), + _ => None, + }, + }; + if let Some((location, focus_search)) = location_opt { + self.tab.change_location(&location, None); + return Command::batch([ + self.update_title(), + self.update_watcher(), + self.rescan_tab(), + if focus_search { + widget::text_input::focus(self.search_id.clone()) + } else { + Command::none() + }, + ]); + } + Command::none() + } + fn update_config(&mut self) -> Command { self.update_nav_model(); Command::none() @@ -729,13 +746,10 @@ impl Application for App { filter_selected: None, filename_id: widget::Id::unique(), modifiers: Modifiers::empty(), - mounters: mounters(), mounter_items: HashMap::new(), nav_model: segmented_button::ModelBuilder::default().build(), result_opt: None, - search_active: false, search_id: widget::Id::unique(), - search_input: String::new(), tab, key_binds, watcher_opt: None, @@ -773,7 +787,7 @@ impl Application for App { widget::column::with_children(vec![ self.tab.gallery_view().map(Message::TabMessage), // Draw button row as part of the overlay - widget::container(self.button_row()) + widget::container(self.button_view()) .width(Length::Fill) .style(theme::Container::WindowBackground) .into(), @@ -867,14 +881,13 @@ impl Application for App { fn header_end(&self) -> Vec> { let mut elements = Vec::with_capacity(3); - if self.search_active { + if let Some(term) = self.search_get() { elements.push( - widget::text_input::search_input("", &self.search_input) + widget::text_input::search_input("", term) .width(Length::Fixed(240.0)) .id(self.search_id.clone()) .on_clear(Message::SearchClear) .on_input(Message::SearchInput) - .on_submit(Message::SearchSubmit) .into(), ) } else { @@ -942,9 +955,6 @@ impl Application for App { } fn on_nav_select(&mut self, entity: segmented_button::Entity) -> Command { - self.search_active = false; - self.search_input.clear(); - self.nav_model.activate(entity); if let Some(location) = self.nav_model.data::(entity) { let message = Message::TabMessage(tab::Message::Location(location.clone())); @@ -952,7 +962,7 @@ impl Application for App { } if let Some(data) = self.nav_model.data::(entity).clone() { - if let Some(mounter) = self.mounters.get(&data.0) { + if let Some(mounter) = MOUNTERS.get(&data.0) { return mounter.mount(data.1.clone()).map(|_| message::none()); } } @@ -966,10 +976,9 @@ impl Application for App { return Command::none(); } - if self.search_active { + if self.search_get().is_some() { // Close search if open - self.search_active = false; - return Command::none(); + return self.search_set(None); } if self.tab.context_menu.is_some() { @@ -1251,7 +1260,7 @@ impl Application for App { } } Message::Preview => match self.context_page { - ContextPage::Preview(_, _) => { + ContextPage::Preview(..) => { self.core.window.show_context = !self.core.window.show_context; } _ => { @@ -1283,33 +1292,17 @@ impl Application for App { } } Message::SearchActivate => { - self.search_active = true; - return widget::text_input::focus(self.search_id.clone()); + return if self.search_get().is_none() { + self.search_set(Some(String::new())) + } else { + widget::text_input::focus(self.search_id.clone()) + }; } Message::SearchClear => { - self.search_active = false; - self.search_input.clear(); + return self.search_set(None); } Message::SearchInput(input) => { - if input != self.search_input { - self.search_input = input; - /*TODO: live search? (probably needs subscription for streaming results) - // This performs live search - if !self.search_input.is_empty() { - return self.search(); - } - */ - } - } - Message::SearchSubmit => { - if !self.search_input.is_empty() { - return self.search(); - } else { - // rescan the tab to get the contents back - // and exit search - self.search_active = false; - return self.search(); - } + return self.search_set(Some(input)); } Message::TabMessage(tab_message) => { let click_i_opt = match tab_message { @@ -1438,7 +1431,11 @@ impl Application for App { self.tab.set_items(items); // Reset focus on location change - return widget::text_input::focus(self.filename_id.clone()); + if self.search_get().is_some() { + return widget::text_input::focus(self.search_id.clone()); + } else { + return widget::text_input::focus(self.filename_id.clone()); + } } } @@ -1456,7 +1453,7 @@ impl Application for App { .map(move |message| Message::TabMessage(message)), ); - tab_column = tab_column.push(self.button_row()); + tab_column = tab_column.push(self.button_view()); let content: Element<_> = tab_column.into(); @@ -1566,7 +1563,7 @@ impl Application for App { self.tab.subscription().map(Message::TabMessage), ]; - for (key, mounter) in self.mounters.iter() { + for (key, mounter) in MOUNTERS.iter() { let key = *key; subscriptions.push(mounter.subscription().map(move |mounter_message| { match mounter_message { diff --git a/src/lib.rs b/src/lib.rs index 96f1f91..bbb09b3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -82,6 +82,7 @@ pub fn desktop() -> Result<(), Box> { /// Runs application with these settings #[rustfmt::skip] pub fn main() -> Result<(), Box> { + /* #[cfg(all(unix, not(target_os = "redox")))] match fork::daemon(true, true) { Ok(fork::Fork::Child) => (), @@ -91,6 +92,7 @@ pub fn main() -> Result<(), Box> { process::exit(1); } } + */ env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init(); diff --git a/src/menu.rs b/src/menu.rs index 9768f23..61a55b8 100644 --- a/src/menu.rs +++ b/src/menu.rs @@ -58,12 +58,13 @@ pub fn context_menu<'a>( .on_press(tab::Message::ContextAction(action)) }; + let (sort_name, sort_direction) = tab.sort_options(); let sort_item = |label, variant| { menu_item( format!( "{} {}", label, - match (tab.sort_name == variant, tab.sort_direction) { + match (sort_name == variant, sort_direction) { (true, true) => "\u{2B07}", (true, false) => "\u{2B06}", _ => "", @@ -95,10 +96,7 @@ pub fn context_menu<'a>( match (&tab.mode, &tab.location) { ( tab::Mode::App | tab::Mode::Desktop, - Location::Desktop(_, _) - | Location::Path(_) - | Location::Search(_, _) - | Location::Recents, + Location::Desktop(..) | Location::Path(..) | Location::Search(..) | Location::Recents, ) => { if selected > 0 { if selected_dir == 1 && selected == 1 || selected_dir == 0 { @@ -111,7 +109,7 @@ pub fn context_menu<'a>( .push(menu_item(fl!("open-in-terminal"), Action::OpenTerminal).into()); } } - if matches!(tab.location, Location::Search(_, _)) { + if matches!(tab.location, Location::Search(..)) { children.push( menu_item(fl!("open-item-location"), Action::OpenItemLocation).into(), ); @@ -200,16 +198,13 @@ pub fn context_menu<'a>( } ( tab::Mode::Dialog(dialog_kind), - Location::Desktop(_, _) - | Location::Path(_) - | Location::Search(_, _) - | Location::Recents, + Location::Desktop(..) | Location::Path(..) | Location::Search(..) | Location::Recents, ) => { if selected > 0 { if selected_dir == 1 && selected == 1 || selected_dir == 0 { children.push(menu_item(fl!("open"), Action::Open).into()); } - if matches!(tab.location, Location::Search(_, _)) { + if matches!(tab.location, Location::Search(..)) { children.push( menu_item(fl!("open-item-location"), Action::OpenItemLocation).into(), ); @@ -231,7 +226,7 @@ pub fn context_menu<'a>( children.push(sort_item(fl!("sort-by-size"), HeadingOptions::Size)); } } - (_, Location::Network(_, _)) => { + (_, Location::Network(..)) => { if selected > 0 { if selected_dir == 1 && selected == 1 || selected_dir == 0 { children.push(menu_item(fl!("open"), Action::Open).into()); @@ -295,10 +290,11 @@ pub fn dialog_menu<'a>( tab: &Tab, key_binds: &HashMap, ) -> Element<'static, Message> { + let (sort_name, sort_direction) = tab.sort_options(); let sort_item = |label, sort, dir| { menu::Item::CheckBox( label, - tab.sort_name == sort && tab.sort_direction == dir, + sort_name == sort && sort_direction == dir, Action::SetSort(sort, dir), ) }; @@ -328,7 +324,7 @@ pub fn dialog_menu<'a>( ), ), menu::Tree::with_children( - widget::button::icon(widget::icon::from_name(if tab.sort_direction { + widget::button::icon(widget::icon::from_name(if sort_direction { "view-sort-ascending-symbolic" } else { "view-sort-descending-symbolic" @@ -383,11 +379,12 @@ pub fn menu_bar<'a>( config: &Config, key_binds: &HashMap, ) -> Element<'a, Message> { + let sort_options = tab_opt.map(|tab| tab.sort_options()); let sort_item = |label, sort, dir| { menu::Item::CheckBox( label, - tab_opt.map_or(false, |tab| { - tab.sort_name == sort && tab.sort_direction == dir + sort_options.map_or(false, |(sort_name, sort_direction)| { + sort_name == sort && sort_direction == dir }), Action::SetSort(sort, dir), ) diff --git a/src/mounter/mod.rs b/src/mounter/mod.rs index 7c39e81..a109852 100644 --- a/src/mounter/mod.rs +++ b/src/mounter/mod.rs @@ -1,4 +1,5 @@ use cosmic::{iced::subscription, widget, Command}; +use once_cell::sync::Lazy; use std::{collections::BTreeMap, fmt, path::PathBuf, sync::Arc}; use tokio::sync::mpsc; @@ -114,3 +115,5 @@ pub fn mounters() -> Mounters { Mounters::new(mounters) } + +pub static MOUNTERS: Lazy = Lazy::new(|| mounters()); diff --git a/src/tab.rs b/src/tab.rs index 88c8670..bbadb2c 100644 --- a/src/tab.rs +++ b/src/tab.rs @@ -8,6 +8,7 @@ use cosmic::{ alignment::{Horizontal, Vertical}, clipboard::dnd::DndAction, event, + futures, futures::SinkExt, keyboard::Modifiers, subscription::{self, Subscription}, @@ -64,7 +65,7 @@ use crate::{ menu, mime_app::{mime_apps, MimeApp}, mime_icon::{mime_for_path, mime_icon}, - mounter::Mounters, + mounter::MOUNTERS, mouse_area, thumbnailer::thumbnailer, }; @@ -73,6 +74,8 @@ use uzers::{get_group_by_gid, get_user_by_uid}; pub const DOUBLE_CLICK_DURATION: Duration = Duration::from_millis(500); pub const HOVER_DURATION: Duration = Duration::from_millis(1600); +//TODO: best limit for search items +const MAX_SEARCH_RESULTS: usize = 1000; //TODO: adjust for locales? const DATE_TIME_FORMAT: &'static str = "%b %-d, %-Y, %-I:%M %p"; @@ -525,10 +528,15 @@ pub fn scan_path(tab_path: &PathBuf, sizes: IconSizes) -> Vec { items } -pub fn scan_search(tab_path: &PathBuf, term: &str, sizes: IconSizes) -> Vec { - use rayon::prelude::ParallelSliceMut; - - let start = Instant::now(); +pub fn scan_search bool + Sync>( + tab_path: &PathBuf, + term: &str, + sizes: IconSizes, + callback: F, +) { + if term.is_empty() { + return; + } let pattern = regex::escape(&term); let regex = match regex::RegexBuilder::new(&pattern) @@ -538,11 +546,10 @@ pub fn scan_search(tab_path: &PathBuf, term: &str, sizes: IconSizes) -> Vec ok, Err(err) => { log::warn!("failed to parse regex {:?}: {}", pattern, err); - return Vec::new(); + return; } }; - let items_arc = Arc::new(Mutex::new(Vec::new())); //TODO: do we want to ignore files? ignore::WalkBuilder::new(tab_path) //TODO: only use this on supported targets @@ -571,50 +578,19 @@ pub fn scan_search(tab_path: &PathBuf, term: &str, sizes: IconSizes) -> Vec metadata.modified().ok(), - _ => None, - }; - - // Sort with latest modified first - let a_modified = get_modified(a); - let b_modified = get_modified(b); - b_modified.cmp(&a_modified) - }); - - let duration = start.elapsed(); - log::info!("sorted {} items in {:?}", items.len(), duration); - - //TODO: ideal number of search results, pages? - items.truncate(100); - - items } // This config statement is from trash::os_limited, inverted @@ -786,8 +762,8 @@ pub fn scan_recents(sizes: IconSizes) -> Vec { recents.into_iter().take(50).map(|(item, _)| item).collect() } -pub fn scan_network(uri: &str, mounters: Mounters, sizes: IconSizes) -> Vec { - for (_key, mounter) in mounters.iter() { +pub fn scan_network(uri: &str, sizes: IconSizes) -> Vec { + for (_key, mounter) in MOUNTERS.iter() { match mounter.network_scan(uri, sizes) { Some(Ok(items)) => return items, Some(Err(err)) => { @@ -802,9 +778,8 @@ pub fn scan_network(uri: &str, mounters: Mounters, sizes: IconSizes) -> Vec Vec { let mut items = Vec::new(); @@ -814,7 +789,7 @@ pub fn scan_desktop( } if desktop_config.show_mounted_drives { - for (_mounter_key, mounter) in mounters.iter() { + for (_mounter_key, mounter) in MOUNTERS.iter() { for mounter_item in mounter.items(sizes).unwrap_or_default() { let Some(path) = mounter_item.path() else { continue; @@ -885,24 +860,26 @@ pub fn scan_desktop( items } -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, Hash, PartialEq)] pub enum Location { - Desktop(PathBuf, String), + Desktop(PathBuf, String, DesktopConfig), Network(String, String), Path(PathBuf), Recents, - Search(PathBuf, String), + Search(PathBuf, String, Instant), Trash, } impl std::fmt::Display for Location { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::Desktop(path, display) => write!(f, "{} on display {display}", path.display()), - Self::Network(uri, _) => write!(f, "{}", uri), + Self::Desktop(path, display, ..) => { + write!(f, "{} on display {display}", path.display()) + } + Self::Network(uri, ..) => write!(f, "{}", uri), Self::Path(path) => write!(f, "{}", path.display()), Self::Recents => write!(f, "recents"), - Self::Search(path, term) => write!(f, "search {} for {}", path.display(), term), + Self::Search(path, term, ..) => write!(f, "search {} for {}", path.display(), term), Self::Trash => write!(f, "trash"), } } @@ -911,28 +888,37 @@ impl std::fmt::Display for Location { impl Location { pub fn path_opt(&self) -> Option<&PathBuf> { match self { - Self::Desktop(path, _display) => Some(&path), + Self::Desktop(path, ..) => Some(&path), Self::Path(path) => Some(&path), - Self::Search(path, _) => Some(&path), + Self::Search(path, ..) => Some(&path), _ => None, } } - pub fn scan( - &self, - desktop_config: DesktopConfig, - mounters: Mounters, - sizes: IconSizes, - ) -> Vec { + pub fn with_path(&self, path: PathBuf) -> Self { match self { - Self::Desktop(path, display) => { - scan_desktop(path, display, desktop_config, mounters, sizes) + Self::Desktop(_, display, desktop_config) => { + Self::Desktop(path, display.clone(), *desktop_config) + } + Self::Path(..) => Self::Path(path), + Self::Search(_, term, ..) => Self::Search(path, term.clone(), Instant::now()), + other => other.clone(), + } + } + + pub fn scan(&self, sizes: IconSizes) -> Vec { + match self { + Self::Desktop(path, display, desktop_config) => { + scan_desktop(path, display, *desktop_config, sizes) } Self::Path(path) => scan_path(path, sizes), - Self::Search(path, term) => scan_search(path, term, sizes), + Self::Search(..) => { + // Search is done incrementally + Vec::new() + } Self::Trash => scan_trash(sizes), Self::Recents => scan_recents(sizes), - Self::Network(uri, _) => scan_network(uri, mounters, sizes), + Self::Network(uri, _) => scan_network(uri, sizes), } } } @@ -990,6 +976,7 @@ pub enum Message { MiddleClick(usize), Scroll(Viewport), ScrollToFocus, + SearchItem(Location, Item), SelectAll, SetSort(HeadingOptions, bool), Thumbnail(PathBuf, ItemThumbnail), @@ -1544,7 +1531,7 @@ impl Tab { pub fn title(&self) -> String { match &self.location { - Location::Desktop(path, _display) => { + Location::Desktop(path, _, _) => { let (name, _) = folder_name(path); name } @@ -1552,7 +1539,7 @@ impl Tab { let (name, _) = folder_name(path); name } - Location::Search(path, term) => { + Location::Search(path, term, ..) => { //TODO: translate let (name, _) = folder_name(path); format!("Search \"{}\": {}", term, name) @@ -1934,7 +1921,8 @@ impl Tab { if let Some(range) = self.select_range { let min = range.0.min(range.1); let max = range.0.max(range.1); - if self.sort_name == HeadingOptions::Name && self.sort_direction { + let (sort_name, sort_direction) = self.sort_options(); + if sort_name == HeadingOptions::Name && sort_direction { // A default/unsorted tab's view is consistent with how the // Items are laid out internally (items_opt), so Items can be // linearly selected @@ -2074,13 +2062,10 @@ impl Tab { Message::LocationMenuAction(action) => { self.location_context_menu_index = None; let path_for_index = |ancestor_index| { - match self.location { - Location::Path(ref path) => Some(path), - Location::Search(ref path, _) => Some(path), - _ => None, - } - .and_then(|path| path.ancestors().nth(ancestor_index)) - .map(|path| path.to_path_buf()) + self.location + .path_opt() + .and_then(|path| path.ancestors().nth(ancestor_index)) + .map(|path| path.to_path_buf()) }; match action { LocationMenuAction::OpenInNewTab(ancestor_index) => { @@ -2461,6 +2446,27 @@ impl Tab { ))); } } + Message::SearchItem(location, item) => { + if location == self.location { + if let Some(items) = &mut self.items_opt { + items.push(item); + } else { + log::warn!("tried to load items in {:?} without items array", location); + } + } else { + log::warn!( + "search item found in {:?} instead of {:?}", + location, + self.location + ); + } + + //TODO: optimize + self.column_sort(); + if let Some(items) = &mut self.items_opt { + items.truncate(MAX_SEARCH_RESULTS); + } + } Message::SelectAll => { self.select_all(); if self.select_focus.take().is_some() { @@ -2469,8 +2475,10 @@ impl Tab { } } Message::SetSort(heading_option, dir) => { - self.sort_name = heading_option; - self.sort_direction = dir; + if !matches!(self.location, Location::Search(..)) { + self.sort_name = heading_option; + self.sort_direction = dir; + } } Message::Thumbnail(path, thumbnail) => { if let Some(ref mut items) = self.items_opt { @@ -2509,14 +2517,16 @@ impl Tab { self.config.view = view; } Message::ToggleSort(heading_option) => { - let heading_sort = if self.sort_name == heading_option { - !self.sort_direction - } else { - // Default modified to descending, and others to ascending. - heading_option != HeadingOptions::Modified - }; - self.sort_direction = heading_sort; - self.sort_name = heading_option; + if !matches!(self.location, Location::Search(..)) { + let heading_sort = if self.sort_name == heading_option { + !self.sort_direction + } else { + // Default modified to descending, and others to ascending. + heading_option != HeadingOptions::Modified + }; + self.sort_direction = heading_sort; + self.sort_name = heading_option; + } } Message::Drop(Some((to, mut from))) => { self.dnd_hovered = None; @@ -2608,11 +2618,7 @@ impl Tab { _ => {} } } else if location != self.location { - if match &location { - Location::Path(path) => path.is_dir(), - Location::Search(path, _term) => path.is_dir(), - _ => true, - } { + if location.path_opt().map_or(true, |path| path.is_dir()) { let prev_path = if let Some(path) = self.location.path_opt() { Some(path.to_path_buf()) } else { @@ -2629,6 +2635,13 @@ impl Tab { commands } + pub(crate) fn sort_options(&self) -> (HeadingOptions, bool) { + match self.location { + Location::Search(..) => (HeadingOptions::Modified, false), + _ => (self.sort_name, self.sort_direction), + } + } + fn column_sort(&self) -> Option> { let check_reverse = |ord: Ordering, sort: bool| { if sort { @@ -2638,8 +2651,8 @@ impl Tab { } }; let mut items: Vec<_> = self.items_opt.as_ref()?.iter().enumerate().collect(); - let heading_sort = self.sort_direction; - match self.sort_name { + let (sort_name, sort_direction) = self.sort_options(); + match sort_name { HeadingOptions::Size => { items.sort_by(|a, b| { // entries take precedence over size @@ -2665,7 +2678,7 @@ impl Tab { match (a_is_entry, b_is_entry) { (true, false) => Ordering::Less, (false, true) => Ordering::Greater, - _ => check_reverse(a_size.cmp(&b_size), heading_sort), + _ => check_reverse(a_size.cmp(&b_size), sort_direction), } }) } @@ -2676,13 +2689,13 @@ impl Tab { (false, true) => Ordering::Greater, _ => check_reverse( LANGUAGE_SORTER.compare(&a.1.display_name, &b.1.display_name), - heading_sort, + sort_direction, ), } } else { check_reverse( LANGUAGE_SORTER.compare(&a.1.display_name, &b.1.display_name), - heading_sort, + sort_direction, ) } }), @@ -2699,10 +2712,10 @@ impl Tab { match (a.1.metadata.is_dir(), b.1.metadata.is_dir()) { (true, false) => Ordering::Less, (false, true) => Ordering::Greater, - _ => check_reverse(a_modified.cmp(&b_modified), heading_sort), + _ => check_reverse(a_modified.cmp(&b_modified), sort_direction), } } else { - check_reverse(a_modified.cmp(&b_modified), heading_sort) + check_reverse(a_modified.cmp(&b_modified), sort_direction) } }); } @@ -2719,10 +2732,10 @@ impl Tab { match (a.1.metadata.is_dir(), b.1.metadata.is_dir()) { (true, false) => Ordering::Less, (false, true) => Ordering::Greater, - _ => check_reverse(a_time_deleted.cmp(&b_time_deleted), heading_sort), + _ => check_reverse(a_time_deleted.cmp(&b_time_deleted), sort_direction), } } else { - check_reverse(b_time_deleted.cmp(&a_time_deleted), heading_sort) + check_reverse(b_time_deleted.cmp(&a_time_deleted), sort_direction) } }); } @@ -2977,13 +2990,14 @@ impl Tab { let size_width = 100.0; let condensed = size.width < (name_width + modified_width + size_width); + let (sort_name, sort_direction) = self.sort_options(); let heading_item = |name, width, msg| { let mut row = widget::row::with_capacity(2) .align_items(Alignment::Center) .spacing(space_xxs) .width(width); row = row.push(widget::text::heading(name)); - match (self.sort_name == msg, self.sort_direction) { + match (sort_name == msg, sort_direction) { (true, true) => { row = row.push(widget::icon::from_name("pan-down-symbolic").size(16)); } @@ -3021,46 +3035,42 @@ impl Tab { .spacing(space_xxs); if let Some(location) = &self.edit_location { - match location { - Location::Path(path) => { - row = row.push( - widget::button::custom( - widget::icon::from_name("window-close-symbolic").size(16), - ) - .on_press(Message::EditLocation(None)) - .padding(space_xxs) - .style(theme::Button::Icon), - ); - row = row.push( - widget::text_input("", path.to_string_lossy()) - .id(self.edit_location_id.clone()) - .on_input(|input| { - Message::EditLocation(Some(Location::Path(PathBuf::from(input)))) - }) - .on_submit(Message::Location(location.clone())) - .line_height(1.0), - ); - let mut column = widget::column::with_capacity(4).padding([0, space_s]); - column = column.push(row); - column = column.push(horizontal_rule(1).style(theme::Rule::Custom(Box::new( - |theme: &Theme| rule::Appearance { - color: theme.cosmic().accent_color().into(), - width: 1, - radius: 0.0.into(), - fill_mode: rule::FillMode::Full, - }, - )))); - if self.config.view == View::List && !condensed { - column = column.push(heading_row); - column = column.push(widget::divider::horizontal::default()); - } - return column.into(); - } - _ => { - //TODO: allow editing other locations + //TODO: allow editing other locations + if let Some(path) = location.path_opt() { + row = row.push( + widget::button::custom( + widget::icon::from_name("window-close-symbolic").size(16), + ) + .on_press(Message::EditLocation(None)) + .padding(space_xxs) + .style(theme::Button::Icon), + ); + row = row.push( + widget::text_input("", path.to_string_lossy()) + .id(self.edit_location_id.clone()) + .on_input(|input| { + Message::EditLocation(Some(location.with_path(PathBuf::from(input)))) + }) + .on_submit(Message::Location(location.clone())) + .line_height(1.0), + ); + let mut column = widget::column::with_capacity(4).padding([0, space_s]); + column = column.push(row); + column = column.push(horizontal_rule(1).style(theme::Rule::Custom(Box::new( + |theme: &Theme| rule::Appearance { + color: theme.cosmic().accent_color().into(), + width: 1, + radius: 0.0.into(), + fill_mode: rule::FillMode::Full, + }, + )))); + if self.config.view == View::List && !condensed { + column = column.push(heading_row); + column = column.push(widget::divider::horizontal::default()); } + return column.into(); } - } else if let Location::Path(path) = &self.location { + } else if let Some(path) = self.location.path_opt() { row = row.push( crate::mouse_area::MouseArea::new( widget::button::custom(widget::icon::from_name("edit-symbolic").size(16)) @@ -3071,26 +3081,11 @@ impl Tab { .on_middle_press(move |_| Message::OpenInNewTab(path.clone())), ); w += 16.0 + 2.0 * space_xxs as f32; - } else if let Location::Search(_, term) = &self.location { - row = row.push( - widget::button::custom( - widget::row::with_children(vec![ - widget::icon::from_name("system-search-symbolic") - .size(16) - .into(), - widget::text::body(term).wrap(text::Wrap::None).into(), - ]) - .spacing(space_xxs), - ) - .padding(space_xxs) - .style(theme::Button::Icon), - ); - w += text_width_body(term) + 16.0 + 3.0 * space_xxs as f32; } let mut children: Vec> = Vec::new(); match &self.location { - Location::Desktop(path, _) | Location::Path(path) | Location::Search(path, _) => { + Location::Desktop(path, ..) | Location::Path(path) | Location::Search(path, ..) => { let excess_str = "..."; let excess_width = text_width_body(excess_str); for (index, ancestor) in path.ancestors().enumerate() { @@ -3131,14 +3126,7 @@ impl Tab { w += name_width; } - let location = match &self.location { - Location::Path(_) => Location::Path(ancestor.to_path_buf()), - Location::Search(_, term) => { - Location::Search(ancestor.to_path_buf(), term.clone()) - } - other => other.clone(), - }; - + let location = self.location.with_path(ancestor.to_path_buf()); let mut mouse_area = crate::mouse_area::MouseArea::new( widget::button::custom(row) .padding(space_xxxs) @@ -3251,7 +3239,7 @@ impl Tab { .into(), widget::text(if has_hidden { fl!("empty-folder-hidden") - } else if matches!(self.location, Location::Search(_, _)) { + } else if matches!(self.location, Location::Search(..)) { fl!("no-results") } else { fl!("empty-folder") @@ -3583,7 +3571,7 @@ impl Tab { let modified_width = 200.0; let size_width = 100.0; let condensed = size.width < (name_width + modified_width + size_width); - let is_search = matches!(self.location, Location::Search(_, _)); + let is_search = matches!(self.location, Location::Search(..)); let icon_size = if condensed || is_search { icon_sizes.list_condensed() } else { @@ -4056,86 +4044,123 @@ impl Tab { } pub fn subscription(&self) -> Subscription { - if let Some(items) = &self.items_opt { - //TODO: how many thumbnail loads should be in flight at once? - let jobs = 8; - let mut subscriptions = Vec::with_capacity(jobs); + let Some(items) = &self.items_opt else { + return Subscription::none(); + }; - //TODO: move to function - let visible_rect = { - let point = match self.scroll_opt { - Some(offset) => Point::new(0.0, offset.y), - None => Point::new(0.0, 0.0), - }; - let size = self.size_opt.get().unwrap_or_else(|| Size::new(0.0, 0.0)); - Rectangle::new(point, size) + // Load search items incrementally + if let Location::Search(path, term, start) = &self.location { + let location = self.location.clone(); + let path = path.clone(); + let term = term.clone(); + let start = start.clone(); + return subscription::channel(location.clone(), 100, move |output| async move { + let output = tokio::sync::Mutex::new(output); + + tokio::task::spawn_blocking(move || { + //TODO: use correct icon sizes, or fetch icons lazily? + //TODO: getting mime types for search results is expensive, and not necessary if the results + // are not used. Perhaps they can be gathered when the item is scrolled to, like thumbnails + scan_search(&path, &term, IconSizes::default(), move |item| -> bool { + futures::executor::block_on(async { + output + .lock() + .await + .send(Message::SearchItem(location.clone(), item)) + .await + }) + .is_ok() + }); + log::info!( + "searched for {:?} in {:?} in {:?}", + term, + path, + start.elapsed(), + ); + }) + .await + .unwrap(); + + std::future::pending().await + }); + } + + //TODO: how many thumbnail loads should be in flight at once? + let jobs = 8; + let mut subscriptions = Vec::with_capacity(jobs); + + //TODO: move to function + let visible_rect = { + let point = match self.scroll_opt { + Some(offset) => Point::new(0.0, offset.y), + None => Point::new(0.0, 0.0), }; + let size = self.size_opt.get().unwrap_or_else(|| Size::new(0.0, 0.0)); + Rectangle::new(point, size) + }; - //TODO: HACK to ensure positions are up to date since subscription runs before view - match self.config.view { - View::Grid => _ = self.grid_view(), - View::List => _ = self.list_view(), - }; + //TODO: HACK to ensure positions are up to date since subscription runs before view + match self.config.view { + View::Grid => _ = self.grid_view(), + View::List => _ = self.list_view(), + }; - for item in items.iter() { - if item.thumbnail_opt.is_some() { - // Skip items that already have a thumbnail - continue; - } + for item in items.iter() { + if item.thumbnail_opt.is_some() { + // Skip items that already have a thumbnail + continue; + } - match item.rect_opt.get() { - Some(rect) => { - if !rect.intersects(&visible_rect) { - // Skip items that are not visible - continue; - } - } - None => { - // Skip items with no determined rect (this should include hidden items) + match item.rect_opt.get() { + Some(rect) => { + if !rect.intersects(&visible_rect) { + // Skip items that are not visible continue; } } - - if let Some(path) = item.path_opt().map(|path| path.to_path_buf()) { - let mime = item.mime.clone(); - subscriptions.push(subscription::channel( - path.clone(), - 1, - |mut output| async move { - let (path, thumbnail) = tokio::task::spawn_blocking(move || { - let start = std::time::Instant::now(); - //TODO: configurable thumbnail size? - let thumbnail_size = (ICON_SIZE_GRID * ICON_SCALE_MAX) as u32; - let thumbnail = ItemThumbnail::new(&path, mime, thumbnail_size); - log::debug!("thumbnailed {:?} in {:?}", path, start.elapsed()); - (path, thumbnail) - }) - .await - .unwrap(); - - match output - .send(Message::Thumbnail(path.clone(), thumbnail)) - .await - { - Ok(()) => {} - Err(err) => { - log::warn!("failed to send thumbnail for {:?}: {}", path, err); - } - } - - std::future::pending().await - }, - )); - } - - if subscriptions.len() >= jobs { - break; + None => { + // Skip items with no determined rect (this should include hidden items) + continue; } } - Subscription::batch(subscriptions) - } else { - Subscription::none() + + if let Some(path) = item.path_opt().map(|path| path.to_path_buf()) { + let mime = item.mime.clone(); + subscriptions.push(subscription::channel( + path.clone(), + 1, + |mut output| async move { + let (path, thumbnail) = tokio::task::spawn_blocking(move || { + let start = std::time::Instant::now(); + //TODO: configurable thumbnail size? + let thumbnail_size = (ICON_SIZE_GRID * ICON_SCALE_MAX) as u32; + let thumbnail = ItemThumbnail::new(&path, mime, thumbnail_size); + log::debug!("thumbnailed {:?} in {:?}", path, start.elapsed()); + (path, thumbnail) + }) + .await + .unwrap(); + + match output + .send(Message::Thumbnail(path.clone(), thumbnail)) + .await + { + Ok(()) => {} + Err(err) => { + log::warn!("failed to send thumbnail for {:?}: {}", path, err); + } + } + + std::future::pending().await + }, + )); + } + + if subscriptions.len() >= jobs { + break; + } } + Subscription::batch(subscriptions) } }