diff --git a/Cargo.lock b/Cargo.lock index 0043346..7044db1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3242,6 +3242,7 @@ name = "pwsp-gui" version = "1.13.0" dependencies = [ "anyhow", + "dirs", "eframe", "egui", "egui_dnd", diff --git a/pwsp-gui/Cargo.toml b/pwsp-gui/Cargo.toml index 05843e4..11b84c6 100644 --- a/pwsp-gui/Cargo.toml +++ b/pwsp-gui/Cargo.toml @@ -20,6 +20,7 @@ anyhow.workspace = true serde.workspace = true serde_json.workspace = true +dirs.workspace = true egui.workspace = true eframe.workspace = true diff --git a/pwsp-gui/locales/app.toml b/pwsp-gui/locales/app.toml index 4ed9518..94244a7 100644 --- a/pwsp-gui/locales/app.toml +++ b/pwsp-gui/locales/app.toml @@ -353,6 +353,17 @@ kz = "Ыстық пернелерді іздеу..." he = "חפש מקשי קיצור..." pt-BR = "Buscar atalhos..." +[gui.search_truncated_warning] +en = "... and %{count} more results" +ru = "... и ещё %{count} результатов" +es = "... y %{count} resultados más" +fr = "... et %{count} résultats de plus" +zh = "... 还有 %{count} 个结果" +ar = "... و %{count} نتائج أخرى" +kz = "... және тағы %{count} нәтиже" +he = "... ועוד %{count} תוצאות" +pt-BR = "... e mais %{count} resultados" + [gui.hotkeys.add_command_select] en = "Add Command" ru = "Добавить команду" diff --git a/pwsp-gui/src/gui/input.rs b/pwsp-gui/src/gui/input.rs index ae5b495..796430b 100644 --- a/pwsp-gui/src/gui/input.rs +++ b/pwsp-gui/src/gui/input.rs @@ -92,7 +92,7 @@ impl SoundpadGui { } pub fn handle_input(&mut self, ctx: &Context) { - let _modifiers = self.modifiers(ctx); + let modifiers = self.modifiers(ctx); let search_focused = { if let Some(focused_id) = self.get_focused(ctx) && let Some(search_id) = self.app_state.search_field_id @@ -185,14 +185,15 @@ impl SoundpadGui { } // Focus search field - if self.key_pressed(ctx, Key::Slash) { - if search_focused { - ctx.memory_mut(|m| { - m.request_focus(Id::NULL); - }); - } else { - self.app_state.force_focus_search = true; - } + if modifiers.ctrl && self.key_pressed(ctx, Key::F) { + self.app_state.force_focus_search = true; + } + + // Unfocus search field + if search_focused && self.key_pressed(ctx, Key::Escape) { + ctx.memory_mut(|m| { + m.request_focus(Id::NULL); + }); } // Check for hotkey chord triggers diff --git a/pwsp-gui/src/gui/mod.rs b/pwsp-gui/src/gui/mod.rs index 826d64e..e1bfead 100644 --- a/pwsp-gui/src/gui/mod.rs +++ b/pwsp-gui/src/gui/mod.rs @@ -21,9 +21,12 @@ use pwsp_lib::{ use rfd::FileDialog; use std::{ cmp::Ordering, + collections::{HashMap, HashSet}, fs, + hash::{DefaultHasher, Hash, Hasher}, path::{Path, PathBuf}, sync::{Arc, Mutex}, + thread, }; use system_fonts::{FontStyle, FoundFontSource, find_for_locale}; @@ -31,6 +34,16 @@ const SUPPORTED_EXTENSIONS: [&str; 13] = [ "mp3", "wav", "ogg", "flac", "mp4", "m4a", "aac", "mov", "mkv", "mka", "webm", "avi", "opus", ]; +fn get_cache_file_for(path: &Path) -> PathBuf { + let mut hasher = DefaultHasher::new(); + path.hash(&mut hasher); + let hash = hasher.finish(); + dirs::cache_dir() + .unwrap_or_else(|| PathBuf::from("/tmp")) + .join("pwsp") + .join(format!("{:x}.json", hash)) +} + struct SoundpadGui { pub app_state: AppState, pub config: GuiConfig, @@ -106,19 +119,119 @@ impl SoundpadGui { } } - pub fn open_dir(&mut self, path: &PathBuf) { + pub fn open_dir(&mut self, path: &PathBuf, ctx: Option, force_rescan: bool) { self.app_state.current_dir = Some(path.clone()); - match path.read_dir() { - Ok(read_dir) => { - self.app_state.listed_files = read_dir - .filter_map(|res| res.ok()) - .map(|entry| entry.path()) - .collect(); - } - Err(e) => { - eprintln!("Failed to read directory {:?}: {}", path, e); - self.app_state.listed_files.clear(); + + if !self.app_state.dir_cache.contains_key(path) || force_rescan { + match fs::read_dir(path) { + Ok(read_dir) => { + let files = read_dir + .filter_map(|res| res.ok()) + .map(|entry| entry.path()) + .collect::>(); + self.app_state.listed_files = files.iter().cloned().collect(); + self.app_state.dir_cache.insert(path.clone(), files); + } + Err(e) => { + eprintln!("Failed to read directory {:?}: {}", path, e); + self.app_state.listed_files.clear(); + } } + } else { + self.app_state.listed_files = self + .app_state + .dir_cache + .get(path) + .unwrap() + .clone() + .into_iter() + .collect(); + } + + let is_scanning = self.app_state.scanning_dirs.lock().unwrap().contains(path); + let is_cached = self.app_state.recursive_files_cache.contains_key(path); + let has_scanned_this_session = self.app_state.scanned_this_session.contains(path); + + let needs_scan = force_rescan || !has_scanned_this_session || !is_cached; + + if !is_scanning && needs_scan { + self.app_state + .scanning_dirs + .lock() + .unwrap() + .insert(path.clone()); + self.app_state.scanned_this_session.insert(path.clone()); + + let finished_scans = self.app_state.finished_scans.clone(); + let scanning_dirs = self.app_state.scanning_dirs.clone(); + let path_clone = path.clone(); + + thread::spawn(move || { + let cache_file = get_cache_file_for(&path_clone); + + // 1. Try to load from disk cache if we don't have it in memory yet + if !is_cached + && let Ok(data) = fs::read_to_string(&cache_file) + && let Ok((all_files, dir_updates)) = serde_json::from_str::<( + Vec, + HashMap>, + )>(&data) + { + finished_scans.lock().unwrap().push(( + path_clone.clone(), + all_files, + dir_updates, + )); + if let Some(ctx) = ctx.as_ref() { + ctx.request_repaint(); + } + } + + // 2. Scan recursively + let mut all_files = Vec::new(); + let mut dir_updates = HashMap::new(); + let mut dirs_to_visit = vec![path_clone.clone()]; + + while let Some(dir) = dirs_to_visit.pop() { + if let Ok(entries) = fs::read_dir(&dir) { + let mut children = Vec::new(); + for entry in entries.filter_map(|e| e.ok()) { + let p = entry.path(); + if p.is_dir() { + dirs_to_visit.push(p.clone()); + children.push(p); + } else if crate::gui::SUPPORTED_EXTENSIONS.contains( + &p.extension() + .unwrap_or_default() + .to_str() + .unwrap_or_default(), + ) { + all_files.push(p.clone()); + children.push(p); + } + } + dir_updates.insert(dir, children); + } + } + + // 3. Save to disk cache + if let Some(parent) = cache_file.parent() { + fs::create_dir_all(parent).ok(); + } + if let Ok(json) = serde_json::to_string(&(all_files.clone(), dir_updates.clone())) { + fs::write(&cache_file, json).ok(); + } + + // 4. Send to UI + finished_scans + .lock() + .unwrap() + .push((path_clone.clone(), all_files, dir_updates)); + scanning_dirs.lock().unwrap().remove(&path_clone); + if let Some(ctx) = ctx { + ctx.request_repaint(); + } + }); } } @@ -157,7 +270,14 @@ impl SoundpadGui { make_request_async(Request::play_hotkey(slot)); } - pub fn get_filtered_files(&self) -> Vec { + pub fn get_filtered_files( + &self, + matching_dirs: &HashSet, + matching_files: &HashSet, + ) -> Vec { + let search_query = self.app_state.search_query.to_lowercase(); + let search_query = search_query.trim(); + let mut files: Vec = self.app_state.listed_files.iter().cloned().collect(); let sort_order = self .app_state @@ -178,37 +298,51 @@ impl SoundpadGui { } }); - let search_query = self.app_state.search_query.to_lowercase(); - let search_query = search_query.trim(); + let is_cached = self + .app_state + .current_dir + .as_ref() + .is_some_and(|d| self.app_state.recursive_files_cache.contains_key(d)); - files - .into_iter() - .filter(|entry_path| { - if entry_path.is_dir() { - return true; - } - - if !SUPPORTED_EXTENSIONS.contains( - &entry_path - .extension() - .unwrap_or_default() - .to_str() - .unwrap_or_default(), - ) { - return false; - } - - if !search_query.is_empty() { - let file_name = entry_path.file_name().unwrap_or_default().to_string_lossy(); - - if !file_name.to_lowercase().contains(search_query) { + if !search_query.is_empty() && is_cached { + files + .into_iter() + .filter(|p| { + if p.is_dir() { + matching_dirs.contains(p) + } else { + matching_files.contains(p) + } + }) + .collect() + } else { + files + .into_iter() + .filter(|entry_path| { + if !entry_path.is_dir() + && !SUPPORTED_EXTENSIONS.contains( + &entry_path + .extension() + .unwrap_or_default() + .to_str() + .unwrap_or_default(), + ) + { return false; } - } - true - }) - .collect() + if !search_query.is_empty() { + let file_name = + entry_path.file_name().unwrap_or_default().to_string_lossy(); + if !file_name.to_lowercase().contains(search_query) { + return false; + } + } + + true + }) + .collect() + } } } @@ -325,14 +459,16 @@ mod tests { // On the real OS filesystem, these paths don't exist, so they are treated as files. // Unsupported extensions (like .txt) will be filtered out. // So we expect only file_b and file_c, sorted alphabetically. - let filtered = gui.get_filtered_files(); + let empty_dirs = HashSet::new(); + let empty_files = HashSet::new(); + let filtered = gui.get_filtered_files(&empty_dirs, &empty_files); assert_eq!(filtered.len(), 2); assert_eq!(filtered[0], file_b); assert_eq!(filtered[1], file_c); // Test search query gui.app_state.search_query = "c_fi".to_string(); - let filtered_search = gui.get_filtered_files(); + let filtered_search = gui.get_filtered_files(&empty_dirs, &empty_files); assert_eq!(filtered_search.len(), 1); assert_eq!(filtered_search[0], file_c); @@ -345,7 +481,7 @@ mod tests { }, ); gui.app_state.search_query = String::new(); - let filtered_desc = gui.get_filtered_files(); + let filtered_desc = gui.get_filtered_files(&empty_dirs, &empty_files); assert_eq!(filtered_desc.len(), 2); assert_eq!(filtered_desc[0], file_c); assert_eq!(filtered_desc[1], file_b); diff --git a/pwsp-gui/src/gui/views/body.rs b/pwsp-gui/src/gui/views/body.rs index 0893543..524cf0e 100644 --- a/pwsp-gui/src/gui/views/body.rs +++ b/pwsp-gui/src/gui/views/body.rs @@ -10,7 +10,7 @@ use pwsp_lib::types::{ gui::{AppState, AudioPlayerState}, }; use rust_i18n::t; -use std::{cmp::Ordering, path::Path, path::PathBuf}; +use std::{cmp::Ordering, collections::HashSet, path::Path, path::PathBuf}; pub(crate) enum FileAction { Play(PathBuf, bool), @@ -195,7 +195,7 @@ impl SoundpadGui { .sort_order = order; self.config.save_to_file().ok(); self.app_state.dir_cache.remove(path); - self.open_dir(path); + self.open_dir(path, Some(ui.ctx().clone()), false); } }); }); @@ -203,7 +203,7 @@ impl SoundpadGui { self.app_state.dirs = dirs; if let Some(path) = dir_to_open { - self.open_dir(&path); + self.open_dir(&path, Some(ui.ctx().clone()), false); } ui.horizontal(|ui| { @@ -227,12 +227,41 @@ impl SoundpadGui { fn draw_files_search_field(&mut self, ui: &mut Ui) { ui.horizontal(|ui| { + let is_scanning = self + .app_state + .current_dir + .as_ref() + .is_some_and(|d| self.app_state.scanning_dirs.lock().unwrap().contains(d)); + let spinner_width = if is_scanning { 20.0 } else { 0.0 }; + + let refresh_button = Button::new(ICON_REFRESH).frame(false); + let refresh_width = 24.0; + let search_field_response = ui.add_sized( - [ui.available_width(), 22.0], + [ + f32::max( + 0.0, + ui.available_width() - spinner_width - refresh_width - 8.0, + ), + 22.0, + ], TextEdit::singleline(&mut self.app_state.search_query) .hint_text(t!("gui.search_placeholder")), ); + if is_scanning { + ui.spinner(); + } + + let refresh_response = ui + .add_sized([refresh_width, 22.0], refresh_button) + .on_hover_text("Refresh"); + if refresh_response.clicked() + && let Some(dir) = self.app_state.current_dir.clone() + { + self.open_dir(&dir, Some(ui.ctx().clone()), true); + } + if self.app_state.force_focus_search { search_field_response.request_focus(); self.app_state.force_focus_search = false; @@ -247,9 +276,54 @@ impl SoundpadGui { ui.set_min_width(area_size.x); ui.set_min_height(area_size.y); + let finished = { + let mut scans = self.app_state.finished_scans.lock().unwrap(); + std::mem::take(&mut *scans) + }; + for (top_dir, all_files, dir_updates) in finished { + self.app_state + .recursive_files_cache + .insert(top_dir, all_files); + self.app_state.dir_cache.extend(dir_updates); + } + ui.vertical(|ui| { let mut actions = Vec::new(); - let files = self.get_filtered_files(); + + let search_query = self.app_state.search_query.to_lowercase(); + let search_query = search_query.trim(); + + let mut matching_dirs = std::collections::HashSet::new(); + let mut matching_files = std::collections::HashSet::new(); + + if !search_query.is_empty() + && let Some(current_dir) = &self.app_state.current_dir + && let Some(all_files) = self.app_state.recursive_files_cache.get(current_dir) + { + for file in all_files { + if let Ok(rel_path) = file.strip_prefix(current_dir) { + let rel_path_str = + rel_path.to_string_lossy().to_lowercase().replace("\\", "/"); + if rel_path_str.contains(search_query) { + matching_files.insert(file.clone()); + let mut p = file.parent(); + while let Some(parent) = p { + if parent == current_dir { + break; + } + matching_dirs.insert(parent.to_path_buf()); + p = parent.parent(); + } + } + } + } + } + + let max_results = 300; + let is_truncated = !search_query.is_empty() && matching_files.len() > max_results; + + let files = self.get_filtered_files(&matching_dirs, &matching_files); + let mut rendered_count = 0; for entry_path in files { Self::draw_tree_node( ui, @@ -258,6 +332,22 @@ impl SoundpadGui { &mut self.app_state, &self.audio_player_state, &mut actions, + &matching_dirs, + &matching_files, + search_query, + &mut rendered_count, + max_results, + ); + } + + if is_truncated { + ui.add_space(8.0); + let text = t!( + "gui.search_truncated_warning", + count = matching_files.len() - max_results + ); + ui.label( + egui::RichText::new(text).color(egui::Color32::from_rgb(200, 150, 50)), ); } @@ -286,6 +376,7 @@ impl SoundpadGui { }); } + #[allow(clippy::too_many_arguments)] fn draw_tree_node_dir( ui: &mut Ui, path: std::path::PathBuf, @@ -293,69 +384,112 @@ impl SoundpadGui { app_state: &mut AppState, audio_player_state: &AudioPlayerState, actions: &mut Vec, + matching_dirs: &std::collections::HashSet, + matching_files: &std::collections::HashSet, + search_query: &str, + rendered_count: &mut usize, + max_results: usize, ) { + if *rendered_count >= max_results && !search_query.is_empty() { + return; + } + let dir_name = path .file_name() .unwrap_or_default() .to_string_lossy() .to_string(); - CollapsingHeader::new(dir_name) - .id_salt(&path) - .show(ui, |ui| { - let children = if let Some(cached) = app_state.dir_cache.get(&path) { - cached.clone() - } else { - let mut read = Vec::new(); - if let Ok(entries) = std::fs::read_dir(&path) { - for entry in entries.filter_map(|e| e.ok()) { - let child_path = entry.path(); - if !child_path.is_dir() - && !crate::gui::SUPPORTED_EXTENSIONS.contains( - &child_path - .extension() - .unwrap_or_default() - .to_str() - .unwrap_or_default(), - ) - { - continue; - } - read.push(child_path); - } - } - let sort_order = config.get_sort_order(&path); - read.sort_by(|a, b| { - let a_is_dir = a.is_dir(); - let b_is_dir = b.is_dir(); - if a_is_dir && !b_is_dir { - Ordering::Less - } else if !a_is_dir && b_is_dir { - Ordering::Greater - } else { - sort_order.compare(a, b) - } - }); - app_state.dir_cache.insert(path.clone(), read.clone()); - read - }; - let search_query = app_state.search_query.to_lowercase(); - let search_query = search_query.trim(); + let id_salt = if search_query.is_empty() { + format!("normal_{:?}", path) + } else { + format!("search_{:?}", path) + }; - for child in children { - if !child.is_dir() && !search_query.is_empty() { - let file_name = child - .file_name() - .unwrap_or_default() - .to_string_lossy() - .to_string(); - if !file_name.to_lowercase().contains(search_query) { + let mut header = CollapsingHeader::new(dir_name).id_salt(id_salt); + + let is_cached = app_state + .current_dir + .as_ref() + .is_some_and(|d| app_state.recursive_files_cache.contains_key(d)); + if !search_query.is_empty() && is_cached { + header = header.default_open(true); + } + + header.show(ui, |ui| { + let children = if let Some(cached) = app_state.dir_cache.get(&path) { + cached.clone() + } else { + let mut read = Vec::new(); + if let Ok(entries) = std::fs::read_dir(&path) { + for entry in entries.filter_map(|e| e.ok()) { + let child_path = entry.path(); + if !child_path.is_dir() + && !crate::gui::SUPPORTED_EXTENSIONS.contains( + &child_path + .extension() + .unwrap_or_default() + .to_str() + .unwrap_or_default(), + ) + { continue; } + read.push(child_path); } - Self::draw_tree_node(ui, child, config, app_state, audio_player_state, actions); } - }); + let sort_order = config.get_sort_order(&path); + read.sort_by(|a, b| { + let a_is_dir = a.is_dir(); + let b_is_dir = b.is_dir(); + if a_is_dir && !b_is_dir { + Ordering::Less + } else if !a_is_dir && b_is_dir { + Ordering::Greater + } else { + sort_order.compare(a, b) + } + }); + app_state.dir_cache.insert(path.clone(), read.clone()); + read + }; + + for child in children { + if *rendered_count >= max_results && !search_query.is_empty() { + break; + } + if !search_query.is_empty() && is_cached { + if child.is_dir() && !matching_dirs.contains(&child) { + continue; + } + if !child.is_dir() && !matching_files.contains(&child) { + continue; + } + } else if !search_query.is_empty() && !child.is_dir() { + let file_name = child + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_lowercase(); + if !file_name.contains(search_query) { + continue; + } + } + Self::draw_tree_node( + ui, + child, + config, + app_state, + audio_player_state, + actions, + matching_dirs, + matching_files, + search_query, + rendered_count, + max_results, + ); + } + }); } fn draw_tree_node_file( @@ -364,7 +498,10 @@ impl SoundpadGui { app_state: &mut AppState, audio_player_state: &AudioPlayerState, actions: &mut Vec, + rendered_count: &mut usize, ) { + *rendered_count += 1; + let file_name = path .file_name() .unwrap_or_default() @@ -500,6 +637,7 @@ impl SoundpadGui { }); } + #[allow(clippy::too_many_arguments)] fn draw_tree_node( ui: &mut Ui, path: std::path::PathBuf, @@ -507,11 +645,39 @@ impl SoundpadGui { app_state: &mut AppState, audio_player_state: &AudioPlayerState, actions: &mut Vec, + matching_dirs: &HashSet, + matching_files: &HashSet, + search_query: &str, + rendered_count: &mut usize, + max_results: usize, ) { + if *rendered_count >= max_results && !search_query.is_empty() { + return; + } + if path.is_dir() { - Self::draw_tree_node_dir(ui, path, config, app_state, audio_player_state, actions); + Self::draw_tree_node_dir( + ui, + path, + config, + app_state, + audio_player_state, + actions, + matching_dirs, + matching_files, + search_query, + rendered_count, + max_results, + ); } else { - Self::draw_tree_node_file(ui, path, app_state, audio_player_state, actions); + Self::draw_tree_node_file( + ui, + path, + app_state, + audio_player_state, + actions, + rendered_count, + ); } } } diff --git a/pwsp-lib/src/types/gui.rs b/pwsp-lib/src/types/gui.rs index 956874f..3bdeadb 100644 --- a/pwsp-lib/src/types/gui.rs +++ b/pwsp-lib/src/types/gui.rs @@ -8,9 +8,12 @@ use egui::Id; use std::{ collections::{HashMap, HashSet}, path::PathBuf, + sync::{Arc, Mutex}, time::Instant, }; +pub type ScanResult = (PathBuf, Vec, HashMap>); + #[derive(Default, Debug)] pub struct TrackUiState { pub position_slider_value: f32, @@ -50,6 +53,11 @@ pub struct AppState { pub listed_dirs: HashSet, pub dir_cache: HashMap>, + pub scanning_dirs: Arc>>, + pub scanned_this_session: HashSet, + pub finished_scans: Arc>>, + pub recursive_files_cache: HashMap>, + pub show_hotkeys: bool, pub hotkey_capture_active: bool,