diff --git a/Cargo.toml b/Cargo.toml index 1ee6cf2..daf6db1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cm-player" -version = "0.1.8" +version = "0.1.9" edition = "2021" [dependencies] diff --git a/src/main.rs b/src/main.rs index 0688304..7f7a622 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,7 +7,7 @@ mod ui; use anyhow::{Context, Result}; use crossterm::{ - event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}, + event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; @@ -56,7 +56,7 @@ async fn main() -> Result<()> { // Setup terminal enable_raw_mode()?; let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + execute!(stdout, EnterAlternateScreen)?; let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; @@ -67,8 +67,7 @@ async fn main() -> Result<()> { disable_raw_mode()?; execute!( terminal.backend_mut(), - LeaveAlternateScreen, - DisableMouseCapture + LeaveAlternateScreen )?; terminal.show_cursor()?; @@ -95,8 +94,8 @@ async fn run_app( state.current_position = player.get_position().unwrap_or(0.0); state.current_duration = player.get_duration().unwrap_or(0.0); - // Check if track ended and play next - if player.is_idle() && state.player_state == PlayerState::Playing { + // Check if track ended and play next (but only if track was actually loaded) + if player.is_idle() && state.player_state == PlayerState::Playing && state.current_duration > 0.0 { if state.playlist_index + 1 < state.playlist.len() { state.play_next(); if let Some(ref path) = state.current_file { @@ -157,10 +156,17 @@ async fn handle_key_event(terminal: &mut Terminal< state.should_quit = true; } (KeyCode::Char('/'), _) => { - state.enter_search_mode(); + if state.search_mode && state.search_query.is_empty() { + // Exit search mode only if search query is blank + state.exit_search_mode(); + } else if !state.search_mode { + state.enter_search_mode(); + } } (KeyCode::Esc, _) => { - state.search_matches.clear(); + if !state.search_matches.is_empty() { + state.search_matches.clear(); + } if state.visual_mode { state.visual_mode = false; state.marked_files.clear(); @@ -169,38 +175,70 @@ async fn handle_key_event(terminal: &mut Terminal< (KeyCode::Char('n'), _) => { if !state.search_matches.is_empty() { state.next_search_match(); - } else if !state.playlist.is_empty() { - // Advance to next track - if state.playlist_index + 1 < state.playlist.len() { - state.playlist_index += 1; - state.current_file = Some(state.playlist[state.playlist_index].clone()); + } + } + (KeyCode::Char('N'), KeyModifiers::SHIFT) => { + if !state.search_matches.is_empty() { + state.prev_search_match(); + } + } + (KeyCode::Char('J'), KeyModifiers::SHIFT) => { + // Next track + if !state.playlist.is_empty() && state.playlist_index + 1 < state.playlist.len() { + state.playlist_index += 1; + state.current_file = Some(state.playlist[state.playlist_index].clone()); - match state.player_state { - PlayerState::Playing => { - // Keep playing - if let Some(ref path) = state.current_file { - player.play(path)?; - tracing::info!("Next track: {:?}", path); - } + match state.player_state { + PlayerState::Playing => { + // Keep playing + if let Some(ref path) = state.current_file { + player.play(path)?; + tracing::info!("Next track: {:?}", path); } - PlayerState::Paused => { - // Load but stay paused - if let Some(ref path) = state.current_file { - player.play(path)?; - player.pause()?; - tracing::info!("Next track (paused): {:?}", path); - } - } - PlayerState::Stopped => { - // Just update current file, stay stopped - tracing::info!("Next track selected (stopped): {:?}", state.current_file); + } + PlayerState::Paused => { + // Load but stay paused + if let Some(ref path) = state.current_file { + player.play(path)?; + player.pause()?; + tracing::info!("Next track (paused): {:?}", path); } } + PlayerState::Stopped => { + // Just update current file, stay stopped + tracing::info!("Next track selected (stopped): {:?}", state.current_file); + } } } } - (KeyCode::Char('N'), KeyModifiers::SHIFT) => { - state.prev_search_match(); + (KeyCode::Char('K'), KeyModifiers::SHIFT) => { + // Previous track + if !state.playlist.is_empty() && state.playlist_index > 0 { + state.playlist_index -= 1; + state.current_file = Some(state.playlist[state.playlist_index].clone()); + + match state.player_state { + PlayerState::Playing => { + // Keep playing + if let Some(ref path) = state.current_file { + player.play(path)?; + tracing::info!("Previous track: {:?}", path); + } + } + PlayerState::Paused => { + // Load but stay paused + if let Some(ref path) = state.current_file { + player.play(path)?; + player.pause()?; + tracing::info!("Previous track (paused): {:?}", path); + } + } + PlayerState::Stopped => { + // Just update current file, stay stopped + tracing::info!("Previous track selected (stopped): {:?}", state.current_file); + } + } + } } (KeyCode::Char('d'), KeyModifiers::CONTROL) => { state.page_down(); @@ -233,34 +271,6 @@ async fn handle_key_event(terminal: &mut Terminal< (KeyCode::Char('c'), _) => { state.clear_playlist(); } - (KeyCode::Char('p'), _) => { - if !state.playlist.is_empty() && state.playlist_index > 0 { - state.playlist_index -= 1; - state.current_file = Some(state.playlist[state.playlist_index].clone()); - - match state.player_state { - PlayerState::Playing => { - // Keep playing - if let Some(ref path) = state.current_file { - player.play(path)?; - tracing::info!("Previous track: {:?}", path); - } - } - PlayerState::Paused => { - // Load but stay paused - if let Some(ref path) = state.current_file { - player.play(path)?; - player.pause()?; - tracing::info!("Previous track (paused): {:?}", path); - } - } - PlayerState::Stopped => { - // Just update current file, stay stopped - tracing::info!("Previous track selected (stopped): {:?}", state.current_file); - } - } - } - } (KeyCode::Enter, _) => { state.play_selection(); if let Some(ref path) = state.current_file { @@ -305,13 +315,13 @@ async fn handle_key_event(terminal: &mut Terminal< } } } - (KeyCode::Left, _) => { + (KeyCode::Char('H'), KeyModifiers::SHIFT) => { if state.player_state != PlayerState::Stopped { player.seek(-10.0)?; tracing::info!("Seek backward 10s"); } } - (KeyCode::Right, _) => { + (KeyCode::Char('L'), KeyModifiers::SHIFT) => { if state.player_state != PlayerState::Stopped { player.seek(10.0)?; tracing::info!("Seek forward 10s"); diff --git a/src/state/mod.rs b/src/state/mod.rs index 77fb462..ba3c497 100644 --- a/src/state/mod.rs +++ b/src/state/mod.rs @@ -29,12 +29,13 @@ pub struct AppState { pub is_refreshing: bool, pub search_mode: bool, pub search_query: String, - pub search_matches: Vec, + pub search_matches: Vec, pub search_match_index: usize, pub tab_search_results: Vec, pub tab_search_index: usize, pub visual_mode: bool, pub visual_anchor: usize, + pub saved_expanded_dirs: HashSet, } #[derive(Debug, Clone)] @@ -75,6 +76,7 @@ impl AppState { tab_search_index: 0, visual_mode: false, visual_anchor: 0, + saved_expanded_dirs: HashSet::new(), } } @@ -139,9 +141,13 @@ impl AppState { let was_expanded = self.expanded_dirs.contains(&path); if was_expanded { - // Close the expanded folder + // 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() { @@ -298,7 +304,7 @@ impl AppState { self.rebuild_flattened_items(); } - fn rebuild_flattened_items(&mut self) { + 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); @@ -312,12 +318,17 @@ impl AppState { 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; 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) { @@ -347,29 +358,34 @@ impl AppState { return; } - // Sort by score (highest first) - matching_paths_with_scores.sort_by(|a, b| b.1.cmp(&a.1)); + // 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 = matching_paths_with_scores.iter().map(|(path, _)| path.clone()).collect(); + self.tab_search_results = indexed_matches.iter().map(|(path, _, _)| path.clone()).collect(); self.tab_search_index = 0; - // Expand parent directories of ALL matches (not just best match) - // This ensures folders deep in the tree become visible - for (path, _) in &matching_paths_with_scores { - let mut parent = path.parent(); - while let Some(p) = parent { - self.expanded_dirs.insert(p.to_path_buf()); - parent = p.parent(); - } + // Close all folders and expand only for the best match + self.expanded_dirs.clear(); + let best_match = self.tab_search_results[0].clone(); + 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 - let best_match = &self.tab_search_results[0]; - if let Some(idx) = self.flattened_items.iter().position(|item| &item.node.path == best_match) { + if let Some(idx) = self.flattened_items.iter().position(|item| item.node.path == best_match) { self.selected_index = idx; } } @@ -380,7 +396,7 @@ impl AppState { return; } - // Collect all matching paths with scores + // 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); @@ -389,35 +405,43 @@ impl AppState { return; } - // Sort by score (highest first) - matching_paths_with_scores.sort_by(|a, b| b.1.cmp(&a.1)); + // 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(); - // Expand all parent directories - for path in &matching_paths { - let mut parent = path.parent(); + // 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(); + // Rebuild flattened items + self.rebuild_flattened_items(); - // Find indices of matches in the flattened list - self.search_matches = matching_paths - .iter() - .filter_map(|path| { - self.flattened_items - .iter() - .position(|item| &item.node.path == path) - }) - .collect(); - - if !self.search_matches.is_empty() { - self.search_match_index = 0; - self.selected_index = self.search_matches[0]; + // 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; @@ -426,7 +450,23 @@ impl AppState { 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(); - self.selected_index = self.search_matches[self.search_match_index]; + 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; + } } } @@ -437,7 +477,23 @@ impl AppState { } else { self.search_match_index -= 1; } - self.selected_index = self.search_matches[self.search_match_index]; + 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; + } } } @@ -450,7 +506,8 @@ impl AppState { 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(); - // Expand parent directories + // 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()); @@ -479,7 +536,8 @@ impl AppState { } let prev_match = self.tab_search_results[self.tab_search_index].clone(); - // Expand parent directories + // 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()); @@ -570,9 +628,6 @@ fn fuzzy_match(text: &str, query: &str) -> Option { } } - // Bonus for shorter strings (better matches) - score += 100 - text_lower.len() as i32; - Some(score) } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 886e51b..1c9b32f 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -39,11 +39,77 @@ pub fn render(frame: &mut Frame, state: &mut AppState) { render_status_bar(frame, state, main_chunks[2]); } +fn highlight_search_matches<'a>(text: &str, query: &str, search_typing: bool, is_selected: bool) -> Vec> { + let query_lower = query.to_lowercase(); + + let mut spans = Vec::new(); + let mut query_chars = query_lower.chars(); + let mut current_query_char = query_chars.next(); + let mut current_segment = String::new(); + + for ch in text.chars() { + let ch_lower = ch.to_lowercase().next().unwrap(); + + if let Some(query_ch) = current_query_char { + if ch_lower == query_ch { + // Found a match - flush current segment + if !current_segment.is_empty() { + spans.push(Span::raw(current_segment.clone())); + current_segment.clear(); + } + // Add matched character with styling based on mode + if search_typing && is_selected { + // While typing on selected row: green bg with black fg (inverted) + spans.push(Span::styled( + ch.to_string(), + Style::default() + .fg(Theme::background()) + .bg(Theme::success()), + )); + } else if !search_typing && is_selected { + // After Enter on selected row: bold black text on blue selection bar + spans.push(Span::styled( + ch.to_string(), + Style::default() + .fg(Theme::background()) + .add_modifier(Modifier::BOLD), + )); + } else { + // Other rows: just green text + spans.push(Span::styled( + ch.to_string(), + Style::default().fg(Theme::success()), + )); + } + // Move to next query character + current_query_char = query_chars.next(); + } else { + // No match - add to current segment + current_segment.push(ch); + } + } else { + // No more query characters to match + current_segment.push(ch); + } + } + + // Flush remaining segment + if !current_segment.is_empty() { + spans.push(Span::raw(current_segment)); + } + + spans +} + fn render_file_panel(frame: &mut Frame, state: &mut AppState, area: Rect) { // Calculate visible height (subtract 2 for borders) let visible_height = area.height.saturating_sub(2) as usize; state.update_scroll_offset(visible_height); + let in_search = state.search_mode || !state.search_matches.is_empty(); + let search_typing = state.search_mode; // True when actively typing search + let search_query = if in_search { state.search_query.to_lowercase() } else { String::new() }; + let items: Vec = state .flattened_items .iter() @@ -51,20 +117,56 @@ fn render_file_panel(frame: &mut Frame, state: &mut AppState, area: Rect) { .map(|(idx, item)| { let indent = " ".repeat(item.depth); let mark = if state.marked_files.contains(&item.node.path) { "* " } else { "" }; - let suffix = if item.node.is_dir { "/" } else { "" }; - let text = format!("{}{}{}{}", indent, mark, item.node.name, suffix); - let style = if idx == state.selected_index { + // Build name with search highlighting + let is_selected = idx == state.selected_index; + + // Add folder icon for directories + let icon = if item.node.is_dir { + // Bold black icon on selection bar, blue otherwise + if !search_typing && is_selected { + Span::styled("▸ ", Style::default().fg(Theme::background()).add_modifier(Modifier::BOLD)) + } else { + Span::styled("▸ ", Style::default().fg(Theme::highlight())) + } + } else { + Span::raw(" ") + }; + let name_spans = if in_search && !search_query.is_empty() { + highlight_search_matches(&item.node.name, &search_query, search_typing, is_selected) + } else { + vec![Span::raw(&item.node.name)] + }; + + let suffix = if item.node.is_dir { "/" } else { "" }; + + let base_style = if search_typing { + // While typing search: no selection bar, just normal colors + if state.marked_files.contains(&item.node.path) { + Theme::marked() + } else { + Theme::secondary() + } + } else if is_selected { + // After pressing Enter or normal mode: normal blue selection bar Theme::selected() - } else if item.node.is_dir { - Theme::directory() } else if state.marked_files.contains(&item.node.path) { Theme::marked() } else { Theme::secondary() }; - ListItem::new(text).style(style) + let mut line_spans = vec![ + Span::raw(indent), + Span::raw(mark), + icon, + ]; + line_spans.extend(name_spans); + line_spans.push(Span::raw(suffix)); + + let line = Line::from(line_spans); + + ListItem::new(line).style(base_style) }) .collect(); @@ -78,7 +180,11 @@ fn render_file_panel(frame: &mut Frame, state: &mut AppState, area: Rect) { ); let mut list_state = ListState::default(); - list_state.select(Some(state.selected_index)); + // Don't show selection bar widget while typing search - we use inverted colors instead + // Show it in normal mode and after executing search (Enter) + if !search_typing { + list_state.select(Some(state.selected_index)); + } *list_state.offset_mut() = state.scroll_offset; frame.render_stateful_widget(list, area, &mut list_state); } @@ -96,21 +202,11 @@ fn render_right_panel(frame: &mut Frame, state: &AppState, area: Rect) { .unwrap_or_else(|| path.to_string_lossy().to_string()); let style = if idx == state.playlist_index { - // Color based on player state with bold text - match state.player_state { - PlayerState::Playing => Style::default() - .fg(Theme::success()) // Green text - .bg(Theme::background()) - .add_modifier(Modifier::BOLD), - PlayerState::Paused => Style::default() - .fg(Theme::highlight()) // Blue text - .bg(Theme::background()) - .add_modifier(Modifier::BOLD), - PlayerState::Stopped => Style::default() - .fg(Theme::warning()) // Yellow/orange text - .bg(Theme::background()) - .add_modifier(Modifier::BOLD), - } + // Current file: white bold + Style::default() + .fg(Theme::bright_foreground()) + .bg(Theme::background()) + .add_modifier(Modifier::BOLD) } else { Theme::secondary() }; @@ -140,7 +236,11 @@ fn render_right_panel(frame: &mut Frame, state: &AppState, area: Rect) { } fn render_title_bar(frame: &mut Frame, state: &AppState, area: Rect) { - let background_color = Theme::success(); // Green for normal operation + let background_color = match state.player_state { + PlayerState::Playing => Theme::success(), // Green for playing + PlayerState::Paused => Theme::highlight(), // Blue for paused + PlayerState::Stopped => Theme::dim_foreground(), // Gray for stopped + }; // Split the title bar into left and right sections let chunks = Layout::default() @@ -217,29 +317,11 @@ fn render_title_bar(frame: &mut Frame, state: &AppState, area: Rect) { // Volume right_spans.push(Span::styled( - format!(" • Vol: {}%", state.volume), + format!(" • Vol: {}% ", state.volume), Style::default() .fg(Theme::background()) .bg(background_color) )); - - // Add search info if active - if !state.search_matches.is_empty() { - right_spans.push(Span::styled( - format!(" • Search: {}/{} ", state.search_match_index + 1, state.search_matches.len()), - Style::default() - .fg(Theme::background()) - .bg(background_color) - .add_modifier(Modifier::BOLD) - )); - } else { - right_spans.push(Span::styled( - " ", - Style::default() - .fg(Theme::background()) - .bg(background_color) - )); - } } let right_title = Paragraph::new(Line::from(right_spans)) @@ -261,6 +343,12 @@ fn render_status_bar(frame: &mut Frame, state: &AppState, area: Rect) { let status_bar = Paragraph::new(search_text) .style(Style::default().fg(Theme::foreground()).bg(Theme::background())); frame.render_widget(status_bar, area); + } else if !state.search_matches.is_empty() { + // Show search navigation when search results are active + let search_text = format!("Search: {}/{} • n: Next • N: Prev • Esc: Clear", state.search_match_index + 1, state.search_matches.len()); + let status_bar = Paragraph::new(search_text) + .style(Style::default().fg(Theme::foreground()).bg(Theme::background())); + frame.render_widget(status_bar, area); } else if state.visual_mode { // Show visual mode indicator let visual_text = format!("-- VISUAL -- {} files marked", state.marked_files.len()); @@ -269,7 +357,7 @@ fn render_status_bar(frame: &mut Frame, state: &AppState, area: Rect) { frame.render_widget(status_bar, area); } else { // Normal mode shortcuts (always shown when not in search or visual mode) - let shortcuts = "/: Search • v: Visual • a: Add • c: Clear • Enter: Play • Space: Pause • s: Stop • ←→: Seek • +/-: Vol • n/p: Next/Prev • r: Rescan • q: Quit"; + let shortcuts = "a: Add • c: Clear • Enter: Play • Space: Pause • s: Stop • +/-: Vol • r: Rescan"; let status_bar = Paragraph::new(shortcuts) .style(Style::default().fg(Theme::muted_text()).bg(Theme::background())) .alignment(Alignment::Center); diff --git a/src/ui/theme.rs b/src/ui/theme.rs index dc7862f..3813f75 100644 --- a/src/ui/theme.rs +++ b/src/ui/theme.rs @@ -86,12 +86,6 @@ impl Theme { .bg(Self::highlight()) } - pub fn directory() -> Style { - Style::default() - .fg(Self::normal_blue()) - .bg(Self::background()) - } - pub fn marked() -> Style { Style::default() .fg(Self::warning())