use crate::cache::{Cache, FileTreeNode}; use crate::config::Config; use std::collections::HashSet; use std::path::PathBuf; use std::time::Instant; // Fuzzy match scoring bonuses const FUZZY_CONSECUTIVE_BONUS: i32 = 10; const FUZZY_WORD_START_BONUS: i32 = 15; const FUZZY_FOLDER_BONUS: i32 = 50; // Helper to calculate effective height accounting for "X more below" indicator fn calculate_effective_height(scroll_offset: usize, visible_height: usize, total_items: usize) -> usize { let visible_end = scroll_offset + visible_height; let items_below = if visible_end < total_items { total_items - visible_end } else { 0 }; if items_below > 0 { visible_height.saturating_sub(1) } else { visible_height } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PlayerState { Stopped, Playing, Paused, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PlayMode { Normal, // Play through once Loop, // Repeat playlist } #[derive(Debug, Clone, PartialEq, Eq)] pub struct ContextMenu { pub menu_type: ContextMenuType, pub x: u16, pub y: u16, pub selected_index: usize, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ContextMenuType { FilePanel, // Shows "Play" and "Add" Playlist, // Shows "Remove" and "Randomise" TitleBar, // Shows "Stop" and "Loop" } pub struct AppState { pub cache: Cache, pub config: Config, pub selected_index: usize, pub scroll_offset: usize, pub file_panel_visible_height: usize, pub playlist_visible_height: usize, pub current_file: Option, pub current_position: f64, pub current_duration: f64, pub volume: i64, pub should_quit: bool, pub flattened_items: Vec, pub expanded_dirs: HashSet, pub marked_files: HashSet, pub playlist: Vec, pub playlist_index: usize, pub playlist_scroll_offset: usize, pub selected_playlist_index: usize, pub is_refreshing: bool, pub search_mode: bool, pub search_query: String, pub search_matches: Vec, pub search_match_index: usize, pub tab_search_results: Vec, pub tab_search_index: usize, pub playlist_search_matches: Vec, pub playlist_search_match_index: usize, pub playlist_tab_search_results: Vec, pub playlist_tab_search_index: usize, pub visual_mode: bool, pub visual_anchor: usize, pub saved_expanded_dirs: HashSet, pub show_refresh_confirm: bool, pub focus_playlist: bool, pub last_click_time: Option, pub last_click_index: Option, pub last_click_is_playlist: bool, pub context_menu: Option, pub play_mode: PlayMode, } #[derive(Debug, Clone)] pub struct FlattenedItem { pub node: FileTreeNode, pub depth: usize, } impl AppState { pub fn new(cache: Cache, config: Config) -> Self { // Start with all directories collapsed (vifm-style) let expanded_dirs = HashSet::new(); let flattened_items = flatten_tree(&cache.file_tree, 0, &expanded_dirs); Self { cache, config, selected_index: 0, scroll_offset: 0, file_panel_visible_height: 20, playlist_visible_height: 20, current_file: None, current_position: 0.0, current_duration: 0.0, volume: 100, should_quit: false, flattened_items, expanded_dirs, marked_files: HashSet::new(), playlist: Vec::new(), playlist_index: 0, playlist_scroll_offset: 0, selected_playlist_index: 0, is_refreshing: false, search_mode: false, search_query: String::new(), search_matches: Vec::new(), search_match_index: 0, tab_search_results: Vec::new(), tab_search_index: 0, playlist_search_matches: Vec::new(), playlist_search_match_index: 0, playlist_tab_search_results: Vec::new(), playlist_tab_search_index: 0, visual_mode: false, visual_anchor: 0, saved_expanded_dirs: HashSet::new(), show_refresh_confirm: false, focus_playlist: false, last_click_time: None, last_click_index: None, last_click_is_playlist: false, context_menu: None, play_mode: PlayMode::Normal, } } pub fn move_selection_up(&mut self) { if self.selected_index > 0 { self.selected_index -= 1; // Scroll up when selection reaches top if self.selected_index < self.scroll_offset { self.scroll_offset = self.selected_index; } // Update visual selection if in visual mode if self.visual_mode { self.update_visual_selection(); } } } pub fn move_selection_down(&mut self) { if self.selected_index < self.flattened_items.len().saturating_sub(1) { self.selected_index += 1; let effective_height = calculate_effective_height( self.scroll_offset, self.file_panel_visible_height, self.flattened_items.len() ); // Scroll down when selection reaches bottom if self.selected_index >= self.scroll_offset + effective_height { self.scroll_offset = self.selected_index - effective_height + 1; } // Update visual selection if in visual mode if self.visual_mode { self.update_visual_selection(); } } } pub fn scroll_view_up(&mut self) { // Scroll view up without changing selection if self.scroll_offset > 0 { self.scroll_offset -= 1; } } pub fn scroll_view_down(&mut self, visible_height: usize) { // Scroll view down without changing selection let max_scroll = self.flattened_items.len().saturating_sub(visible_height); if self.scroll_offset < max_scroll { self.scroll_offset += 1; } } pub fn scroll_playlist_up(&mut self) { // Scroll playlist view up if self.playlist_scroll_offset > 0 { self.playlist_scroll_offset -= 1; } } pub fn scroll_playlist_down(&mut self, visible_height: usize) { // Scroll playlist view down let max_scroll = self.playlist.len().saturating_sub(visible_height); if self.playlist_scroll_offset < max_scroll { self.playlist_scroll_offset += 1; } } pub fn update_playlist_scroll(&mut self, visible_height: usize) { // Auto-scroll playlist to keep current track visible if self.playlist_index >= self.playlist_scroll_offset + visible_height { // Track is below visible area, scroll down self.playlist_scroll_offset = self.playlist_index - visible_height + 1; } else if self.playlist_index < self.playlist_scroll_offset { // Track is above visible area, scroll up self.playlist_scroll_offset = self.playlist_index; } } pub fn move_playlist_selection_up(&mut self) { if self.selected_playlist_index > 0 { self.selected_playlist_index -= 1; // Scroll up when selection reaches top if self.selected_playlist_index < self.playlist_scroll_offset { self.playlist_scroll_offset = self.selected_playlist_index; } } } pub fn move_playlist_selection_down(&mut self, visible_height: usize) { if self.selected_playlist_index < self.playlist.len().saturating_sub(1) { self.selected_playlist_index += 1; let effective_height = calculate_effective_height( self.playlist_scroll_offset, visible_height, self.playlist.len() ); // Scroll down when selection reaches bottom if self.selected_playlist_index >= self.playlist_scroll_offset + effective_height { self.playlist_scroll_offset = self.selected_playlist_index - effective_height + 1; } } } pub fn remove_selected_playlist_item(&mut self) { if self.selected_playlist_index < self.playlist.len() { self.playlist.remove(self.selected_playlist_index); // Adjust playlist_index if necessary if self.playlist_index > self.selected_playlist_index { self.playlist_index -= 1; } else if self.playlist_index == self.selected_playlist_index { // Keep same index (which is now the next track) if self.playlist_index >= self.playlist.len() && !self.playlist.is_empty() { self.playlist_index = self.playlist.len() - 1; } } // Adjust selected_playlist_index if at end if self.selected_playlist_index >= self.playlist.len() && !self.playlist.is_empty() { self.selected_playlist_index = self.playlist.len() - 1; } } } pub fn playlist_page_down(&mut self) { // Move down by half page (vim Ctrl-D behavior) let half_page = self.playlist_visible_height / 2; let new_index = (self.selected_playlist_index + half_page).min(self.playlist.len().saturating_sub(1)); self.selected_playlist_index = new_index; let effective_height = calculate_effective_height( self.playlist_scroll_offset, self.playlist_visible_height, self.playlist.len() ); // Adjust scroll if needed if self.selected_playlist_index >= self.playlist_scroll_offset + effective_height { self.playlist_scroll_offset = self.selected_playlist_index - effective_height + 1; } } pub fn playlist_page_up(&mut self) { // Move up by half page (vim Ctrl-U behavior) let half_page = self.playlist_visible_height / 2; let new_index = self.selected_playlist_index.saturating_sub(half_page); self.selected_playlist_index = new_index; // Adjust scroll if needed if self.selected_playlist_index < self.playlist_scroll_offset { self.playlist_scroll_offset = self.selected_playlist_index; } } pub fn page_down(&mut self) { // Move down by half page (vim Ctrl-D behavior) let half_page = self.file_panel_visible_height / 2; let new_index = (self.selected_index + half_page).min(self.flattened_items.len().saturating_sub(1)); self.selected_index = new_index; let effective_height = calculate_effective_height( self.scroll_offset, self.file_panel_visible_height, self.flattened_items.len() ); // Adjust scroll if needed if self.selected_index >= self.scroll_offset + effective_height { self.scroll_offset = self.selected_index - effective_height + 1; } } pub fn page_up(&mut self) { // Move up by half page (vim Ctrl-U behavior) let half_page = self.file_panel_visible_height / 2; let new_index = self.selected_index.saturating_sub(half_page); self.selected_index = new_index; // Adjust scroll if needed if self.selected_index < self.scroll_offset { self.scroll_offset = self.selected_index; } } pub fn get_selected_item(&self) -> Option<&FlattenedItem> { self.flattened_items.get(self.selected_index) } pub fn collapse_selected(&mut self) { let item = self.get_selected_item().cloned(); if let Some(item) = item { if item.node.is_dir { let path = item.node.path.clone(); let was_expanded = self.expanded_dirs.contains(&path); if was_expanded { // Close the expanded folder and keep selection on it self.expanded_dirs.remove(&path); self.rebuild_flattened_items(); // Find the collapsed folder and select it if let Some(idx) = self.flattened_items.iter().position(|i| i.node.path == path) { self.selected_index = idx; } } else { // Folder is collapsed, close parent instead and jump to it if let Some(parent) = path.parent() { let parent_buf = parent.to_path_buf(); self.expanded_dirs.remove(&parent_buf); self.rebuild_flattened_items(); // Jump to parent folder if let Some(parent_idx) = self.flattened_items.iter().position(|i| i.node.path == parent_buf) { self.selected_index = parent_idx; } } } } else { // Close parent folder when on a file and jump to it if let Some(parent) = item.node.path.parent() { let parent_buf = parent.to_path_buf(); self.expanded_dirs.remove(&parent_buf); self.rebuild_flattened_items(); // Jump to parent folder if let Some(parent_idx) = self.flattened_items.iter().position(|i| i.node.path == parent_buf) { self.selected_index = parent_idx; } } } } } pub fn expand_selected(&mut self) { if let Some(item) = self.get_selected_item() { if item.node.is_dir { let path = item.node.path.clone(); self.expanded_dirs.insert(path); self.rebuild_flattened_items(); } } } pub fn toggle_mark(&mut self) { if self.visual_mode { // Exit visual mode and mark all files in range self.update_visual_selection(); self.visual_mode = false; } else { // Enter visual mode self.visual_mode = true; self.visual_anchor = self.selected_index; // Clear previous marks when entering visual mode self.marked_files.clear(); // Mark current file if let Some(item) = self.get_selected_item() { if !item.node.is_dir { self.marked_files.insert(item.node.path.clone()); } } } } fn update_visual_selection(&mut self) { // Clear marks self.marked_files.clear(); // Mark all files between anchor and current position let start = self.visual_anchor.min(self.selected_index); let end = self.visual_anchor.max(self.selected_index); for i in start..=end { if let Some(item) = self.flattened_items.get(i) { if !item.node.is_dir { self.marked_files.insert(item.node.path.clone()); } } } } pub fn clear_playlist(&mut self) { self.playlist.clear(); self.playlist_index = 0; self.current_file = None; } pub fn add_to_playlist(&mut self) { // Add marked files or current selection to playlist if !self.marked_files.is_empty() { // Add marked files (allow duplicates) let mut files: Vec = self.marked_files.iter().cloned().collect(); files.sort(); self.playlist.extend(files); } else if let Some(item) = self.get_selected_item() { let node = item.node.clone(); if node.is_dir { // Add all files in directory (allow duplicates) let mut files = collect_files_from_node(&node); files.sort(); self.playlist.extend(files); } else { // Add single file (allow duplicates) self.playlist.push(node.path.clone()); } } } pub fn play_selection(&mut self) { // Priority: marked files > directory > single file if !self.marked_files.is_empty() { // Play marked files self.playlist = self.marked_files.iter().cloned().collect(); self.playlist.sort(); self.playlist_index = 0; self.playlist_scroll_offset = 0; self.selected_playlist_index = 0; if let Some(first) = self.playlist.first() { self.current_file = Some(first.clone()); } else { // Empty playlist self.current_file = None; } } else if let Some(item) = self.get_selected_item() { let node = item.node.clone(); if node.is_dir { // Play all files in directory self.playlist = collect_files_from_node(&node); self.playlist_index = 0; self.playlist_scroll_offset = 0; self.selected_playlist_index = 0; if let Some(first) = self.playlist.first() { self.current_file = Some(first.clone()); } else { // Empty directory self.current_file = None; } } else { // Play single file let path = node.path.clone(); self.playlist = vec![path.clone()]; self.playlist_index = 0; self.playlist_scroll_offset = 0; self.selected_playlist_index = 0; self.current_file = Some(path); } } } pub fn play_next(&mut self) -> bool { if self.playlist.is_empty() { return false; } match self.play_mode { PlayMode::Normal => { // Play through once, stop at end if self.playlist_index + 1 < self.playlist.len() { self.playlist_index += 1; if self.playlist_index < self.playlist.len() { self.current_file = Some(self.playlist[self.playlist_index].clone()); return true; // Should continue playing } } // Reached end, should stop false } PlayMode::Loop => { // Loop back to beginning when reaching end self.playlist_index = (self.playlist_index + 1) % self.playlist.len(); self.current_file = Some(self.playlist[self.playlist_index].clone()); true // Should continue playing } } } pub fn cycle_play_mode(&mut self) { self.play_mode = match self.play_mode { PlayMode::Normal => PlayMode::Loop, PlayMode::Loop => PlayMode::Normal, }; } pub fn shuffle_playlist(&mut self) { if self.playlist.is_empty() { return; } use rand::seq::SliceRandom; let mut rng = rand::thread_rng(); // Remember the currently playing track let current_track = if self.playlist_index < self.playlist.len() { Some(self.playlist[self.playlist_index].clone()) } else { None }; // Shuffle the playlist self.playlist.shuffle(&mut rng); // Find the new position of the currently playing track if let Some(track) = current_track { if let Some(new_index) = self.playlist.iter().position(|p| p == &track) { self.playlist_index = new_index; self.selected_playlist_index = new_index; } else { self.playlist_index = 0; self.selected_playlist_index = 0; } } else { self.playlist_index = 0; self.selected_playlist_index = 0; } } pub fn refresh_flattened_items(&mut self) { // Keep current expanded state after rescan self.rebuild_flattened_items(); } pub fn rebuild_flattened_items(&mut self) { self.flattened_items = flatten_tree(&self.cache.file_tree, 0, &self.expanded_dirs); if self.selected_index >= self.flattened_items.len() { self.selected_index = self.flattened_items.len().saturating_sub(1); } } pub fn enter_search_mode(&mut self) { self.search_mode = true; self.search_query.clear(); if self.focus_playlist { // Clear playlist search state self.playlist_search_matches.clear(); self.playlist_search_match_index = 0; self.playlist_tab_search_results.clear(); self.playlist_tab_search_index = 0; } else { // Clear file search state self.search_matches.clear(); self.search_match_index = 0; self.tab_search_results.clear(); self.tab_search_index = 0; // Save current folder state self.saved_expanded_dirs = self.expanded_dirs.clone(); } } pub fn exit_search_mode(&mut self) { self.search_mode = false; if self.focus_playlist { // Clear playlist search state self.playlist_tab_search_results.clear(); self.playlist_tab_search_index = 0; } else { // Clear file search state self.tab_search_results.clear(); self.tab_search_index = 0; // Restore folder state from before search self.expanded_dirs = self.saved_expanded_dirs.clone(); self.rebuild_flattened_items(); } } pub fn append_search_char(&mut self, c: char) { self.search_query.push(c); self.perform_incremental_search(); } pub fn backspace_search(&mut self) { self.search_query.pop(); self.perform_incremental_search(); } fn perform_incremental_search(&mut self) { if self.search_query.is_empty() { self.tab_search_results.clear(); self.tab_search_index = 0; // Don't rebuild tree on every keystroke - only when exiting search return; } // Collect all matching paths with scores from the entire tree let mut matching_paths_with_scores = Vec::new(); collect_matching_paths(&self.cache.file_tree, &self.search_query, &mut matching_paths_with_scores); if matching_paths_with_scores.is_empty() { self.tab_search_results.clear(); self.tab_search_index = 0; return; } // Add index to preserve original tree order when scores are equal let mut indexed_matches: Vec<(PathBuf, i32, usize)> = matching_paths_with_scores .into_iter() .enumerate() .map(|(idx, (path, score))| (path, score, idx)) .collect(); // Sort by score (highest first), then by original index to prefer first occurrence indexed_matches.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.2.cmp(&b.2))); // Store all matches for tab completion self.tab_search_results = indexed_matches.iter().map(|(path, _, _)| path.clone()).collect(); self.tab_search_index = 0; // Only expand and rebuild if this is a new best match let best_match = self.tab_search_results[0].clone(); // Check if we need to expand folders for this match let needs_expand = best_match.ancestors() .skip(1) // Skip the file itself .any(|p| !self.expanded_dirs.contains(p)); if needs_expand { // Close all folders and expand only for the best match self.expanded_dirs.clear(); let mut parent = best_match.parent(); while let Some(p) = parent { self.expanded_dirs.insert(p.to_path_buf()); parent = p.parent(); } // Rebuild flattened items self.rebuild_flattened_items(); } // Find the best match in the flattened list and jump to it if let Some(idx) = self.flattened_items.iter().position(|item| item.node.path == best_match) { self.selected_index = idx; // Scroll to show the match let effective_height = calculate_effective_height( self.scroll_offset, self.file_panel_visible_height, self.flattened_items.len() ); if self.selected_index < self.scroll_offset { self.scroll_offset = self.selected_index; } else if self.selected_index >= self.scroll_offset + effective_height { self.scroll_offset = self.selected_index - effective_height + 1; } } } pub fn execute_search(&mut self) { if self.search_query.is_empty() { self.search_mode = false; return; } // Collect all matching paths with scores and preserve order let mut matching_paths_with_scores = Vec::new(); collect_matching_paths(&self.cache.file_tree, &self.search_query, &mut matching_paths_with_scores); if matching_paths_with_scores.is_empty() { self.search_mode = false; return; } // Add index to preserve original tree order when scores are equal let mut indexed_matches: Vec<(PathBuf, i32, usize)> = matching_paths_with_scores .into_iter() .enumerate() .map(|(idx, (path, score))| (path, score, idx)) .collect(); // Sort by score (highest first), then by original index to prefer first occurrence indexed_matches.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.2.cmp(&b.2))); let matching_paths_with_scores: Vec<(PathBuf, i32)> = indexed_matches .into_iter() .map(|(path, score, _)| (path, score)) .collect(); let matching_paths: Vec = matching_paths_with_scores.iter().map(|(path, _)| path.clone()).collect(); // Store matching paths (not indices, as they change when folders collapse) self.search_matches = matching_paths; if !self.search_matches.is_empty() { self.search_match_index = 0; // Close all folders and expand only for first match self.expanded_dirs.clear(); let first_match = self.search_matches[0].clone(); let mut parent = first_match.parent(); while let Some(p) = parent { self.expanded_dirs.insert(p.to_path_buf()); parent = p.parent(); } // Rebuild flattened items self.rebuild_flattened_items(); // Find first match in flattened list if let Some(idx) = self.flattened_items.iter().position(|item| item.node.path == first_match) { self.selected_index = idx; } } self.search_mode = false; } pub fn next_search_match(&mut self) { if !self.search_matches.is_empty() { self.search_match_index = (self.search_match_index + 1) % self.search_matches.len(); let target_path = self.search_matches[self.search_match_index].clone(); // Close all folders and expand only for this match self.expanded_dirs.clear(); let mut parent = target_path.parent(); while let Some(p) = parent { self.expanded_dirs.insert(p.to_path_buf()); parent = p.parent(); } // Rebuild flattened items self.rebuild_flattened_items(); // Find the path in current flattened items if let Some(idx) = self.flattened_items.iter().position(|item| item.node.path == target_path) { self.selected_index = idx; // Scroll to show the match // Account for "... X more below" indicator which takes one line let visible_end = self.scroll_offset + self.file_panel_visible_height; let items_below = if visible_end < self.flattened_items.len() { self.flattened_items.len() - visible_end } else { 0 }; let effective_height = if items_below > 0 { self.file_panel_visible_height.saturating_sub(1) } else { self.file_panel_visible_height }; if self.selected_index < self.scroll_offset { self.scroll_offset = self.selected_index; } else if self.selected_index >= self.scroll_offset + effective_height { self.scroll_offset = self.selected_index - effective_height + 1; } } } } pub fn prev_search_match(&mut self) { if !self.search_matches.is_empty() { if self.search_match_index == 0 { self.search_match_index = self.search_matches.len() - 1; } else { self.search_match_index -= 1; } let target_path = self.search_matches[self.search_match_index].clone(); // Close all folders and expand only for this match self.expanded_dirs.clear(); let mut parent = target_path.parent(); while let Some(p) = parent { self.expanded_dirs.insert(p.to_path_buf()); parent = p.parent(); } // Rebuild flattened items self.rebuild_flattened_items(); // Find the path in current flattened items if let Some(idx) = self.flattened_items.iter().position(|item| item.node.path == target_path) { self.selected_index = idx; // Scroll to show the match // Account for "... X more below" indicator which takes one line let visible_end = self.scroll_offset + self.file_panel_visible_height; let items_below = if visible_end < self.flattened_items.len() { self.flattened_items.len() - visible_end } else { 0 }; let effective_height = if items_below > 0 { self.file_panel_visible_height.saturating_sub(1) } else { self.file_panel_visible_height }; if self.selected_index < self.scroll_offset { self.scroll_offset = self.selected_index; } else if self.selected_index >= self.scroll_offset + effective_height { self.scroll_offset = self.selected_index - effective_height + 1; } } } } pub fn tab_search_next(&mut self) { if self.tab_search_results.is_empty() { return; } // Cycle to next match self.tab_search_index = (self.tab_search_index + 1) % self.tab_search_results.len(); let next_match = self.tab_search_results[self.tab_search_index].clone(); // Close all folders and expand only for this match self.expanded_dirs.clear(); let mut parent = next_match.parent(); while let Some(p) = parent { self.expanded_dirs.insert(p.to_path_buf()); parent = p.parent(); } // Rebuild flattened items self.rebuild_flattened_items(); // Find and select the match if let Some(idx) = self.flattened_items.iter().position(|item| item.node.path == next_match) { self.selected_index = idx; // Scroll to show the match let effective_height = calculate_effective_height( self.scroll_offset, self.file_panel_visible_height, self.flattened_items.len() ); if self.selected_index < self.scroll_offset { self.scroll_offset = self.selected_index; } else if self.selected_index >= self.scroll_offset + effective_height { self.scroll_offset = self.selected_index - effective_height + 1; } } } pub fn tab_search_prev(&mut self) { if self.tab_search_results.is_empty() { return; } // Cycle to previous match if self.tab_search_index == 0 { self.tab_search_index = self.tab_search_results.len() - 1; } else { self.tab_search_index -= 1; } let prev_match = self.tab_search_results[self.tab_search_index].clone(); // Close all folders and expand only for this match self.expanded_dirs.clear(); let mut parent = prev_match.parent(); while let Some(p) = parent { self.expanded_dirs.insert(p.to_path_buf()); parent = p.parent(); } // Rebuild flattened items self.rebuild_flattened_items(); // Find and select the match if let Some(idx) = self.flattened_items.iter().position(|item| item.node.path == prev_match) { self.selected_index = idx; // Scroll to show the match let effective_height = calculate_effective_height( self.scroll_offset, self.file_panel_visible_height, self.flattened_items.len() ); if self.selected_index < self.scroll_offset { self.scroll_offset = self.selected_index; } else if self.selected_index >= self.scroll_offset + effective_height { self.scroll_offset = self.selected_index - effective_height + 1; } } } pub fn append_playlist_search_char(&mut self, c: char) { self.search_query.push(c); self.perform_playlist_incremental_search(); } pub fn backspace_playlist_search(&mut self) { self.search_query.pop(); self.perform_playlist_incremental_search(); } fn perform_playlist_incremental_search(&mut self) { if self.search_query.is_empty() { self.playlist_tab_search_results.clear(); self.playlist_tab_search_index = 0; return; } // Collect all matching indices with scores let mut matching_indices_with_scores: Vec<(usize, i32)> = self.playlist .iter() .enumerate() .filter_map(|(idx, path)| { let filename = path.file_name() .and_then(|n| n.to_str()) .unwrap_or(""); fuzzy_match(filename, &self.search_query).map(|score| (idx, score)) }) .collect(); if matching_indices_with_scores.is_empty() { self.playlist_tab_search_results.clear(); self.playlist_tab_search_index = 0; return; } // Sort by score (highest first) matching_indices_with_scores.sort_by(|a, b| b.1.cmp(&a.1)); // Store all matches for tab completion self.playlist_tab_search_results = matching_indices_with_scores.iter().map(|(idx, _)| *idx).collect(); self.playlist_tab_search_index = 0; // Jump to best match let best_match_idx = self.playlist_tab_search_results[0]; self.selected_playlist_index = best_match_idx; // Scroll to show the match let effective_height = calculate_effective_height( self.playlist_scroll_offset, self.playlist_visible_height, self.playlist.len() ); if self.selected_playlist_index < self.playlist_scroll_offset { self.playlist_scroll_offset = self.selected_playlist_index; } else if self.selected_playlist_index >= self.playlist_scroll_offset + effective_height { self.playlist_scroll_offset = self.selected_playlist_index - effective_height + 1; } } pub fn playlist_tab_search_next(&mut self) { if self.playlist_tab_search_results.is_empty() { return; } // Cycle to next match self.playlist_tab_search_index = (self.playlist_tab_search_index + 1) % self.playlist_tab_search_results.len(); let next_match_idx = self.playlist_tab_search_results[self.playlist_tab_search_index]; self.selected_playlist_index = next_match_idx; // Scroll to show the match let effective_height = calculate_effective_height( self.playlist_scroll_offset, self.playlist_visible_height, self.playlist.len() ); if self.selected_playlist_index < self.playlist_scroll_offset { self.playlist_scroll_offset = self.selected_playlist_index; } else if self.selected_playlist_index >= self.playlist_scroll_offset + effective_height { self.playlist_scroll_offset = self.selected_playlist_index - effective_height + 1; } } pub fn playlist_tab_search_prev(&mut self) { if self.playlist_tab_search_results.is_empty() { return; } // Cycle to previous match if self.playlist_tab_search_index == 0 { self.playlist_tab_search_index = self.playlist_tab_search_results.len() - 1; } else { self.playlist_tab_search_index -= 1; } let prev_match_idx = self.playlist_tab_search_results[self.playlist_tab_search_index]; self.selected_playlist_index = prev_match_idx; // Scroll to show the match let effective_height = calculate_effective_height( self.playlist_scroll_offset, self.playlist_visible_height, self.playlist.len() ); if self.selected_playlist_index < self.playlist_scroll_offset { self.playlist_scroll_offset = self.selected_playlist_index; } else if self.selected_playlist_index >= self.playlist_scroll_offset + effective_height { self.playlist_scroll_offset = self.selected_playlist_index - effective_height + 1; } } pub fn execute_playlist_search(&mut self) { if self.search_query.is_empty() { self.search_mode = false; return; } // Collect all matching indices with scores let mut matching_indices_with_scores: Vec<(usize, i32)> = self.playlist .iter() .enumerate() .filter_map(|(idx, path)| { let filename = path.file_name() .and_then(|n| n.to_str()) .unwrap_or(""); fuzzy_match(filename, &self.search_query).map(|score| (idx, score)) }) .collect(); if matching_indices_with_scores.is_empty() { self.search_mode = false; return; } // Sort by score (highest first) matching_indices_with_scores.sort_by(|a, b| b.1.cmp(&a.1)); // Store matching indices self.playlist_search_matches = matching_indices_with_scores.iter().map(|(idx, _)| *idx).collect(); if !self.playlist_search_matches.is_empty() { self.playlist_search_match_index = 0; let first_match_idx = self.playlist_search_matches[0]; self.selected_playlist_index = first_match_idx; // Scroll to show the match // Account for "... X more below" indicator which takes one line let visible_end = self.playlist_scroll_offset + self.playlist_visible_height; let items_below = if visible_end < self.playlist.len() { self.playlist.len() - visible_end } else { 0 }; let effective_height = if items_below > 0 { self.playlist_visible_height.saturating_sub(1) } else { self.playlist_visible_height }; if self.selected_playlist_index < self.playlist_scroll_offset { self.playlist_scroll_offset = self.selected_playlist_index; } else if self.selected_playlist_index >= self.playlist_scroll_offset + effective_height { self.playlist_scroll_offset = self.selected_playlist_index - effective_height + 1; } } self.search_mode = false; } pub fn next_playlist_search_match(&mut self) { if !self.playlist_search_matches.is_empty() { self.playlist_search_match_index = (self.playlist_search_match_index + 1) % self.playlist_search_matches.len(); let match_idx = self.playlist_search_matches[self.playlist_search_match_index]; self.selected_playlist_index = match_idx; // Scroll to show the match // Account for "... X more below" indicator which takes one line let visible_end = self.playlist_scroll_offset + self.playlist_visible_height; let items_below = if visible_end < self.playlist.len() { self.playlist.len() - visible_end } else { 0 }; let effective_height = if items_below > 0 { self.playlist_visible_height.saturating_sub(1) } else { self.playlist_visible_height }; if self.selected_playlist_index < self.playlist_scroll_offset { self.playlist_scroll_offset = self.selected_playlist_index; } else if self.selected_playlist_index >= self.playlist_scroll_offset + effective_height { self.playlist_scroll_offset = self.selected_playlist_index - effective_height + 1; } } } pub fn prev_playlist_search_match(&mut self) { if !self.playlist_search_matches.is_empty() { if self.playlist_search_match_index == 0 { self.playlist_search_match_index = self.playlist_search_matches.len() - 1; } else { self.playlist_search_match_index -= 1; } let match_idx = self.playlist_search_matches[self.playlist_search_match_index]; self.selected_playlist_index = match_idx; // Scroll to show the match // Account for "... X more below" indicator which takes one line let visible_end = self.playlist_scroll_offset + self.playlist_visible_height; let items_below = if visible_end < self.playlist.len() { self.playlist.len() - visible_end } else { 0 }; let effective_height = if items_below > 0 { self.playlist_visible_height.saturating_sub(1) } else { self.playlist_visible_height }; if self.selected_playlist_index < self.playlist_scroll_offset { self.playlist_scroll_offset = self.selected_playlist_index; } else if self.selected_playlist_index >= self.playlist_scroll_offset + effective_height { self.playlist_scroll_offset = self.selected_playlist_index - effective_height + 1; } } } } fn flatten_tree(nodes: &[FileTreeNode], depth: usize, expanded_dirs: &HashSet) -> Vec { let mut result = Vec::new(); for node in nodes { let is_expanded = expanded_dirs.contains(&node.path); result.push(FlattenedItem { node: node.clone(), depth, }); if node.is_dir && !node.children.is_empty() && is_expanded { result.extend(flatten_tree(&node.children, depth + 1, expanded_dirs)); } } result } fn collect_files_from_node(node: &FileTreeNode) -> Vec { let mut files = Vec::new(); if node.is_dir { for child in &node.children { files.extend(collect_files_from_node(child)); } } else { files.push(node.path.clone()); } files } fn fuzzy_match(text: &str, query: &str) -> Option { // Avoid allocations by comparing chars directly with case-insensitive logic let mut text_chars = text.chars(); let mut score = 0; let mut prev_match_idx = 0; let mut consecutive_bonus = 0; let mut prev_char = '\0'; for query_char in query.chars() { // Lowercase query char inline let query_char_lower = query_char.to_lowercase().next().unwrap_or(query_char); let mut found = false; let mut current_idx = prev_match_idx; for text_char in text_chars.by_ref() { current_idx += 1; // Lowercase text char inline for comparison let text_char_lower = text_char.to_lowercase().next().unwrap_or(text_char); if text_char_lower == query_char_lower { found = true; // Bonus for consecutive matches if current_idx == prev_match_idx + 1 { consecutive_bonus += FUZZY_CONSECUTIVE_BONUS; } else { consecutive_bonus = 0; } // Bonus for matching at word start if current_idx == 1 || !prev_char.is_alphanumeric() { score += FUZZY_WORD_START_BONUS; } score += consecutive_bonus; // Penalty for gap score -= (current_idx - prev_match_idx - 1) as i32; prev_match_idx = current_idx; prev_char = text_char; break; } prev_char = text_char; } if !found { return None; } } Some(score) } fn collect_matching_paths(nodes: &[FileTreeNode], query: &str, matches: &mut Vec<(PathBuf, i32)>) { for node in nodes { if let Some(mut score) = fuzzy_match(&node.name, query) { // Give folders a significant boost so they appear before files if node.is_dir { score += FUZZY_FOLDER_BONUS; } matches.push((node.path.clone(), score)); } if node.is_dir && !node.children.is_empty() { collect_matching_paths(&node.children, query, matches); } } }