diff --git a/CHANGELOG.md b/CHANGELOG.md index a8a54cf3..3a797668 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - Updated sysinfo to version `0.32` [#161](https://github.com/fluxxcode/egui-file-dialog/pull/161) - Made default egui fonts an optional feature `default_fonts` [#163](https://github.com/fluxxcode/egui-file-dialog/pull/163) (thanks [@StarStarJ](https://github.com/StarStarJ)!) - Filter directory when loading to improve performance [#169](https://github.com/fluxxcode/egui-file-dialog/pull/169) +- Implement non blocking directory loading [#177](https://github.com/fluxxcode/egui-file-dialog/pull/177) ### 📚 Documentation - Updated `README.md` to include latest features [#176](https://github.com/fluxxcode/egui-file-dialog/pull/176) diff --git a/src/config/mod.rs b/src/config/mod.rs index 58ad044c..cdff8dde 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -101,6 +101,10 @@ pub struct FileDialogConfig { pub directory_separator: String, /// If the paths in the file dialog should be canonicalized before use. pub canonicalize_paths: bool, + /// If the directory content should be loaded via a separate thread. + /// This prevents the application from blocking when loading large directories + /// or from slow hard drives. + pub load_via_thread: bool, /// The icon that is used to display error messages. pub err_icon: String, @@ -213,6 +217,7 @@ impl Default for FileDialogConfig { allow_path_edit_to_save_file_without_extension: false, directory_separator: String::from(">"), canonicalize_paths: true, + load_via_thread: true, err_icon: String::from("⚠"), warn_icon: String::from("⚠"), diff --git a/src/data/directory_content.rs b/src/data/directory_content.rs index dd5732d9..41ec3557 100644 --- a/src/data/directory_content.rs +++ b/src/data/directory_content.rs @@ -1,5 +1,9 @@ use std::path::{Path, PathBuf}; -use std::{fs, io}; +use std::sync::{mpsc, Arc}; +use std::time::SystemTime; +use std::{fs, io, thread}; + +use egui::mutex::Mutex; use crate::config::{FileDialogConfig, FileFilter}; @@ -110,18 +114,63 @@ impl DirectoryEntry { } } +/// Contains the state of the directory content. +#[derive(Debug, PartialEq, Eq)] +pub enum DirectoryContentState { + /// If we are currently waiting for the loading process on another thread. + /// The value is the timestamp when the loading process started. + Pending(SystemTime), + /// If loading the direcotry content finished since the last update call. + /// This is only returned once. + Finished, + /// If loading the directory content was successfull. + Success, + /// If there was an error loading the directory content. + /// The value contains the error message. + Errored(String), +} + +type DirectoryContentReceiver = + Option, std::io::Error>>>>>; + /// Contains the content of a directory. -#[derive(Default, Debug)] pub struct DirectoryContent { + /// Current state of the directory content. + state: DirectoryContentState, + /// The loaded directory contents. content: Vec, + /// Receiver when the content is loaded on a different thread. + content_recv: DirectoryContentReceiver, } -impl DirectoryContent { - /// Create a new object with empty content - pub const fn new() -> Self { - Self { content: vec![] } +impl Default for DirectoryContent { + fn default() -> Self { + Self { + state: DirectoryContentState::Success, + content: Vec::new(), + content_recv: None, + } + } +} + +impl std::fmt::Debug for DirectoryContent { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("DirectoryContent") + .field("state", &self.state) + .field("content", &self.content) + .field( + "content_recv", + if self.content_recv.is_some() { + &"" + } else { + &"None" + }, + ) + .finish() } +} +impl DirectoryContent { /// Create a new `DirectoryContent` object and loads the contents of the given path. /// Use `include_files` to include or exclude files in the content list. pub fn from_path( @@ -131,17 +180,133 @@ impl DirectoryContent { show_hidden: bool, show_system_files: bool, file_filter: Option<&FileFilter>, - ) -> io::Result { - Ok(Self { - content: load_directory( + ) -> Self { + if config.load_via_thread { + Self::with_thread( config, path, include_files, show_hidden, show_system_files, file_filter, - )?, - }) + ) + } else { + Self::without_thread( + config, + path, + include_files, + show_hidden, + show_system_files, + file_filter, + ) + } + } + + fn with_thread( + config: &FileDialogConfig, + path: &Path, + include_files: bool, + show_hidden: bool, + show_system_files: bool, + file_filter: Option<&FileFilter>, + ) -> Self { + let (tx, rx) = mpsc::channel(); + + let c = config.clone(); + let p = path.to_path_buf(); + let f = file_filter.cloned(); + thread::spawn(move || { + let _ = tx.send(load_directory( + &c, + &p, + include_files, + show_hidden, + show_system_files, + f.as_ref(), + )); + }); + + Self { + state: DirectoryContentState::Pending(SystemTime::now()), + content: Vec::new(), + content_recv: Some(Arc::new(Mutex::new(rx))), + } + } + + fn without_thread( + config: &FileDialogConfig, + path: &Path, + include_files: bool, + show_hidden: bool, + show_system_files: bool, + file_filter: Option<&FileFilter>, + ) -> Self { + match load_directory( + config, + path, + include_files, + show_hidden, + show_system_files, + file_filter, + ) { + Ok(c) => Self { + state: DirectoryContentState::Success, + content: c, + content_recv: None, + }, + Err(err) => Self { + state: DirectoryContentState::Errored(err.to_string()), + content: Vec::new(), + content_recv: None, + }, + } + } + + pub fn update(&mut self) -> &DirectoryContentState { + if self.state == DirectoryContentState::Finished { + self.state = DirectoryContentState::Success; + } + + if !matches!(self.state, DirectoryContentState::Pending(_)) { + return &self.state; + } + + self.update_pending_state() + } + + fn update_pending_state(&mut self) -> &DirectoryContentState { + let rx = std::mem::take(&mut self.content_recv); + let mut update_content_recv = true; + + if let Some(recv) = &rx { + let value = recv.lock().try_recv(); + match value { + Ok(result) => match result { + Ok(content) => { + self.state = DirectoryContentState::Finished; + self.content = content; + update_content_recv = false; + } + Err(err) => { + self.state = DirectoryContentState::Errored(err.to_string()); + update_content_recv = false; + } + }, + Err(err) => { + if mpsc::TryRecvError::Disconnected == err { + self.state = + DirectoryContentState::Errored("thread ended unexpectedly".to_owned()); + update_content_recv = false; + } + } + } + } + + if update_content_recv { + self.content_recv = rx; + } + + &self.state } pub fn filtered_iter<'s>( @@ -177,11 +342,6 @@ impl DirectoryContent { pub fn push(&mut self, item: DirectoryEntry) { self.content.push(item); } - - /// Clears the items inside the directory. - pub fn clear(&mut self) { - self.content.clear(); - } } fn apply_search_value(entry: &DirectoryEntry, value: &str) -> bool { @@ -266,7 +426,8 @@ fn is_path_hidden(item: &DirectoryEntry) -> bool { } /// Generates the icon for the specific path. -/// The default icon configuration is taken into account, as well as any configured file icon filters. +/// The default icon configuration is taken into account, as well as any configured +/// file icon filters. fn gen_path_icon(config: &FileDialogConfig, path: &Path) -> String { for def in &config.file_icon_filters { if (def.filter)(path) { diff --git a/src/data/mod.rs b/src/data/mod.rs index aa2fdc95..c267555f 100644 --- a/src/data/mod.rs +++ b/src/data/mod.rs @@ -1,5 +1,5 @@ mod directory_content; -pub use directory_content::{DirectoryContent, DirectoryEntry}; +pub use directory_content::{DirectoryContent, DirectoryContentState, DirectoryEntry}; mod disks; pub use disks::{Disk, Disks}; diff --git a/src/file_dialog.rs b/src/file_dialog.rs index 3a6e6873..9e62cc9d 100644 --- a/src/file_dialog.rs +++ b/src/file_dialog.rs @@ -9,7 +9,9 @@ use crate::config::{ Filter, QuickAccess, }; use crate::create_directory_dialog::CreateDirectoryDialog; -use crate::data::{DirectoryContent, DirectoryEntry, Disk, Disks, UserDirectories}; +use crate::data::{ + DirectoryContent, DirectoryContentState, DirectoryEntry, Disk, Disks, UserDirectories, +}; use crate::modals::{FileDialogModal, ModalAction, ModalState, OverwriteFileModal}; /// Represents the mode the file dialog is currently in. @@ -116,8 +118,6 @@ pub struct FileDialog { directory_offset: usize, /// The content of the currently open directory directory_content: DirectoryContent, - /// This variable contains the error message if an error occurred while loading the directory. - directory_error: Option, /// The dialog that is shown when the user wants to create a new directory. create_directory_dialog: CreateDirectoryDialog, @@ -210,8 +210,7 @@ impl FileDialog { directory_stack: Vec::new(), directory_offset: 0, - directory_content: DirectoryContent::new(), - directory_error: None, + directory_content: DirectoryContent::default(), create_directory_dialog: CreateDirectoryDialog::new(), @@ -338,7 +337,10 @@ impl FileDialog { .id .map_or_else(|| egui::Id::new(self.get_window_title()), |id| id); - self.load_directory(&self.gen_initial_directory(&self.config.initial_directory)) + self.load_directory(&self.gen_initial_directory(&self.config.initial_directory)); + + // TODO: Dont return a result from this method + Ok(()) } /// Shortcut function to open the file dialog to prompt the user to select a directory. @@ -613,6 +615,14 @@ impl FileDialog { self } + /// If the directory content should be loaded via a separate thread. + /// This prevents the application from blocking when loading large directories + /// or from slow hard drives. + pub const fn load_via_thread(mut self, load_via_thread: bool) -> Self { + self.config.load_via_thread = load_via_thread; + self + } + /// Sets the icon that is used to display errors. pub fn err_icon(mut self, icon: &str) -> Self { self.config.err_icon = icon.to_string(); @@ -1297,7 +1307,7 @@ impl FileDialog { if self.config.show_parent_button { if let Some(x) = self.current_directory() { if self.ui_button_sized(ui, x.parent().is_some(), button_size, "⏶", None) { - let _ = self.load_parent_directory(); + self.load_parent_directory(); } } else { let _ = self.ui_button_sized(ui, false, button_size, "⏶", None); @@ -1313,13 +1323,13 @@ impl FileDialog { None, ) { - let _ = self.load_previous_directory(); + self.load_previous_directory(); } if self.config.show_forward_button && self.ui_button_sized(ui, self.directory_offset != 0, button_size, "⏵", None) { - let _ = self.load_next_directory(); + self.load_next_directory(); } if self.config.show_new_folder_button @@ -1424,7 +1434,7 @@ impl FileDialog { } if ui.button(file_name).clicked() { - let _ = self.load_directory(path.as_path()); + self.load_directory(path.as_path()); return; } } @@ -1613,7 +1623,7 @@ impl FileDialog { let response = ui.selectable_label(self.current_directory() == Some(path), display_name); if response.clicked() { - let _ = self.load_directory(path); + self.load_directory(path); } response @@ -1994,13 +2004,7 @@ impl FileDialog { /// Updates the central panel, including the list of items in the currently open directory. #[allow(clippy::too_many_lines)] // TODO: Refactor fn ui_update_central_panel(&mut self, ui: &mut egui::Ui) { - if let Some(err) = &self.directory_error { - ui.centered_and_justified(|ui| { - ui.colored_label( - ui.style().visuals.error_fg_color, - format!("{} {}", self.config.err_icon, err), - ); - }); + if self.update_directory_content(ui) { return; } @@ -2099,10 +2103,10 @@ impl FileDialog { } // The user double clicked on the directory entry. - // Either open the directory of submit the dialog. + // Either open the directory or submit the dialog. if re.double_clicked() && !ui.input(|i| i.modifiers.ctrl) { if item.is_dir() { - let _ = self.load_directory(&item.to_path_buf()); + self.load_directory(&item.to_path_buf()); return; } @@ -2146,6 +2150,43 @@ impl FileDialog { }); } + fn update_directory_content(&mut self, ui: &mut egui::Ui) -> bool { + const SHOW_SPINNER_AFTER: f32 = 0.2; + + match self.directory_content.update() { + DirectoryContentState::Pending(timestamp) => { + let now = std::time::SystemTime::now(); + + if now + .duration_since(*timestamp) + .unwrap_or_default() + .as_secs_f32() + > SHOW_SPINNER_AFTER + { + ui.centered_and_justified(egui::Ui::spinner); + } + + // Prevent egui from not updating the UI when there is no user input + ui.ctx().request_repaint(); + + true + } + DirectoryContentState::Errored(err) => { + ui.centered_and_justified(|ui| ui.colored_label(ui.visuals().error_fg_color, err)); + true + } + DirectoryContentState::Finished => { + if let Some(dir) = self.current_directory() { + let mut dir_entry = DirectoryEntry::from_path(&self.config, dir); + self.select_item(&mut dir_entry); + } + + false + } + DirectoryContentState::Success => false, + } + } + /// Selects every item inside the `directory_content` between `item_a` and `item_b`, /// excluding both given items. fn batch_select_between( @@ -2307,15 +2348,15 @@ impl FileDialog { } if FileDialogKeyBindings::any_pressed(ctx, &keybindings.parent, true) { - let _ = self.load_parent_directory(); + self.load_parent_directory(); } if FileDialogKeyBindings::any_pressed(ctx, &keybindings.back, true) { - let _ = self.load_previous_directory(); + self.load_previous_directory(); } if FileDialogKeyBindings::any_pressed(ctx, &keybindings.forward, true) { - let _ = self.load_next_directory(); + self.load_next_directory(); } if FileDialogKeyBindings::any_pressed(ctx, &keybindings.reload, true) { @@ -2333,7 +2374,7 @@ impl FileDialog { if FileDialogKeyBindings::any_pressed(ctx, &keybindings.home_edit_path, true) { if let Some(dirs) = &self.user_directories { if let Some(home) = dirs.home_dir() { - let _ = self.load_directory(home.to_path_buf().as_path()); + self.load_directory(home.to_path_buf().as_path()); self.open_path_edit(); } } @@ -2390,7 +2431,7 @@ impl FileDialog { .any(|p| p.path_eq(item)); if is_visible && item.is_dir() { - let _ = self.load_directory(&item.to_path_buf()); + self.load_directory(&item.to_path_buf()); return; } } @@ -2553,7 +2594,7 @@ impl FileDialog { self.user_directories = UserDirectories::new(self.config.canonicalize_paths); self.system_disks = Disks::new_with_refreshed_list(self.config.canonicalize_paths); - let _ = self.reload_directory(); + self.reload_directory(); } /// Submits the current selection and tries to finish the dialog, if the selection is valid. @@ -2830,7 +2871,7 @@ impl FileDialog { return; } - let _ = self.load_directory(&path); + self.load_directory(&path); } /// Closes the text field at the top to edit the current path without loading @@ -2843,52 +2884,46 @@ impl FileDialog { /// If `directory_offset` is 0 and there is no other directory to load, `Ok()` is returned and /// nothing changes. /// Otherwise, the result of the directory loading operation is returned. - fn load_next_directory(&mut self) -> io::Result<()> { + fn load_next_directory(&mut self) { if self.directory_offset == 0 { // There is no next directory that can be loaded - return Ok(()); + return; } self.directory_offset -= 1; // Copy path and load directory if let Some(path) = self.current_directory() { - return self.load_directory_content(path.to_path_buf().as_path()); + self.load_directory_content(path.to_path_buf().as_path()); } - - Ok(()) } /// Loads the previous directory the user opened. /// If there is no previous directory left, `Ok()` is returned and nothing changes. /// Otherwise, the result of the directory loading operation is returned. - fn load_previous_directory(&mut self) -> io::Result<()> { + fn load_previous_directory(&mut self) { if self.directory_offset + 1 >= self.directory_stack.len() { // There is no previous directory that can be loaded - return Ok(()); + return; } self.directory_offset += 1; // Copy path and load directory if let Some(path) = self.current_directory() { - return self.load_directory_content(path.to_path_buf().as_path()); + self.load_directory_content(path.to_path_buf().as_path()); } - - Ok(()) } /// Loads the parent directory of the currently open directory. /// If the directory doesn't have a parent, `Ok()` is returned and nothing changes. /// Otherwise, the result of the directory loading operation is returned. - fn load_parent_directory(&mut self) -> io::Result<()> { + fn load_parent_directory(&mut self) { if let Some(x) = self.current_directory() { if let Some(x) = x.to_path_buf().parent() { - return self.load_directory(x); + self.load_directory(x); } } - - Ok(()) } /// Reloads the currently open directory. @@ -2897,12 +2932,10 @@ impl FileDialog { /// /// In most cases, this function should not be called directly. /// Instead, `refresh` should be used to reload all other data like system disks too. - fn reload_directory(&mut self) -> io::Result<()> { + fn reload_directory(&mut self) { if let Some(x) = self.current_directory() { - return self.load_directory_content(x.to_path_buf().as_path()); + self.load_directory_content(x.to_path_buf().as_path()); } - - Ok(()) } /// Loads the given directory and updates the `directory_stack`. @@ -2910,12 +2943,12 @@ impl FileDialog { /// stored in the vector before the `directory_offset`. /// /// The function also sets the loaded directory as the selected item. - fn load_directory(&mut self, path: &Path) -> io::Result<()> { + fn load_directory(&mut self, path: &Path) { // Do not load the same directory again. // Use reload_directory if the content of the directory should be updated. if let Some(x) = self.current_directory() { if x == path { - return Ok(()); + return; } } @@ -2927,38 +2960,23 @@ impl FileDialog { self.directory_stack.push(path.to_path_buf()); self.directory_offset = 0; - self.load_directory_content(path)?; - - let mut dir_entry = DirectoryEntry::from_path(&self.config, path); - self.select_item(&mut dir_entry); + self.load_directory_content(path); // Clear the entry filter buffer. // It's unlikely the user wants to keep the current filter when entering a new directory. self.search_value.clear(); - - Ok(()) } /// Loads the directory content of the given path. - fn load_directory_content(&mut self, path: &Path) -> io::Result<()> { - self.directory_error = None; - - self.directory_content = match DirectoryContent::from_path( + fn load_directory_content(&mut self, path: &Path) { + self.directory_content = DirectoryContent::from_path( &self.config, path, self.show_files, self.config.storage.show_hidden, self.config.storage.show_system_files, self.get_selected_file_filter(), - ) { - Ok(content) => content, - Err(err) => { - self.directory_content.clear(); - self.selected_item = None; - self.directory_error = Some(err.to_string()); - return Err(err); - } - }; + ); self.create_directory_dialog.close(); self.scroll_to_selection = true; @@ -2966,7 +2984,5 @@ impl FileDialog { if self.mode == DialogMode::SaveFile { self.file_name_input_error = self.validate_file_name_input(); } - - Ok(()) } }