All checks were successful
Build and Release / build-and-release (push) Successful in 50s
- Remove tokio async runtime dependency (~2MB reduction) - Optimize fuzzy search to avoid string allocations - Optimize incremental search to only rebuild tree when needed - Extract duplicate scrolling logic to helper function - Replace magic numbers with named constants - Fix terminal cleanup to run even on error - Fix context menu item count mismatch - Remove unused metadata fields (duration, codec, hash)
1252 lines
46 KiB
Rust
1252 lines
46 KiB
Rust
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<PathBuf>,
|
|
pub current_position: f64,
|
|
pub current_duration: f64,
|
|
pub volume: i64,
|
|
pub should_quit: bool,
|
|
pub flattened_items: Vec<FlattenedItem>,
|
|
pub expanded_dirs: HashSet<PathBuf>,
|
|
pub marked_files: HashSet<PathBuf>,
|
|
pub playlist: Vec<PathBuf>,
|
|
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<PathBuf>,
|
|
pub search_match_index: usize,
|
|
pub tab_search_results: Vec<PathBuf>,
|
|
pub tab_search_index: usize,
|
|
pub playlist_search_matches: Vec<usize>,
|
|
pub playlist_search_match_index: usize,
|
|
pub playlist_tab_search_results: Vec<usize>,
|
|
pub playlist_tab_search_index: usize,
|
|
pub visual_mode: bool,
|
|
pub visual_anchor: usize,
|
|
pub saved_expanded_dirs: HashSet<PathBuf>,
|
|
pub show_refresh_confirm: bool,
|
|
pub focus_playlist: bool,
|
|
pub last_click_time: Option<Instant>,
|
|
pub last_click_index: Option<usize>,
|
|
pub last_click_is_playlist: bool,
|
|
pub context_menu: Option<ContextMenu>,
|
|
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<PathBuf> = 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<PathBuf> = 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<PathBuf>) -> Vec<FlattenedItem> {
|
|
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<PathBuf> {
|
|
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<i32> {
|
|
// 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);
|
|
}
|
|
}
|
|
}
|