From b535d0e9cb1d3c815d3654436f847cd822efd787 Mon Sep 17 00:00:00 2001 From: Christoffer Martinsson Date: Sat, 6 Dec 2025 14:40:53 +0100 Subject: [PATCH] Improve search and playlist management - Add folder priority boost in fuzzy search scoring - Show search results in top status bar after Enter - Change keybindings: v for mark, a for add to playlist, c for clear - Add playlist management: add_to_playlist() and clear_playlist() - Fix playlist index reset when clearing playlist - Display incremental search status in bottom bar while typing --- src/main.rs | 126 +++++++++++++----- src/state/mod.rs | 330 +++++++++++++++++++++++++++++++++++++++++++++++ src/ui/mod.rs | 215 ++++++++++++++++++++++-------- src/ui/theme.rs | 116 +++++++++++++++++ 4 files changed, 696 insertions(+), 91 deletions(-) create mode 100644 src/ui/theme.rs diff --git a/src/main.rs b/src/main.rs index a259ff6..646c1e3 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, KeyEventKind}, + event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; @@ -105,7 +105,7 @@ async fn run_app( if event::poll(std::time::Duration::from_millis(100))? { if let Event::Key(key) = event::read()? { if key.kind == KeyEventKind::Press { - handle_key_event(state, player, key.code).await?; + handle_key_event(state, player, key).await?; } } } @@ -118,51 +118,109 @@ async fn run_app( Ok(()) } -async fn handle_key_event(state: &mut AppState, player: &mut player::Player, key_code: KeyCode) -> Result<()> { - match key_code { - KeyCode::Char('q') => { +async fn handle_key_event(state: &mut AppState, player: &mut player::Player, key: KeyEvent) -> Result<()> { + // Handle search mode separately + if state.search_mode { + match key.code { + KeyCode::Char(c) => { + state.append_search_char(c); + } + KeyCode::Backspace => { + state.backspace_search(); + } + KeyCode::Tab => { + state.tab_search_next(); + } + KeyCode::BackTab => { + state.tab_search_prev(); + } + KeyCode::Enter => { + state.execute_search(); + } + KeyCode::Esc => { + state.exit_search_mode(); + } + _ => {} + } + return Ok(()); + } + + match (key.code, key.modifiers) { + (KeyCode::Char('q'), _) => { state.should_quit = true; } - KeyCode::Char('k') => { - state.move_selection_up(); + (KeyCode::Char('/'), _) => { + state.enter_search_mode(); } - KeyCode::Char('j') => { - state.move_selection_down(); + (KeyCode::Esc, _) => { + state.search_matches.clear(); } - KeyCode::Char('h') => { - state.collapse_selected(); - } - KeyCode::Char('l') => { - state.expand_selected(); - } - KeyCode::Char('t') => { - state.toggle_mark(); - } - KeyCode::Char('c') => { - state.clear_marks(); - } - KeyCode::Char('n') => { - state.play_next(); - if let Some(ref path) = state.current_file { - player.play(path)?; - tracing::info!("Next track: {:?}", path); + (KeyCode::Char('n'), _) => { + if !state.search_matches.is_empty() { + state.next_search_match(); + } else { + // If stopped, start from current index (0), otherwise go to next + if state.player_state == PlayerState::Stopped && !state.playlist.is_empty() { + state.current_file = Some(state.playlist[state.playlist_index].clone()); + state.player_state = PlayerState::Playing; + if let Some(ref path) = state.current_file { + player.play(path)?; + tracing::info!("Starting playlist: {:?}", path); + } + } else { + state.play_next(); + if let Some(ref path) = state.current_file { + player.play(path)?; + tracing::info!("Next track: {:?}", path); + } + } } } - KeyCode::Char('p') => { + (KeyCode::Char('N'), KeyModifiers::SHIFT) => { + state.prev_search_match(); + } + (KeyCode::Char('d'), KeyModifiers::CONTROL) => { + state.page_down(); + } + (KeyCode::Char('u'), KeyModifiers::CONTROL) => { + state.page_up(); + } + (KeyCode::Char('k'), _) | (KeyCode::Up, _) => { + state.move_selection_up(); + } + (KeyCode::Char('j'), _) | (KeyCode::Down, _) => { + state.move_selection_down(); + } + (KeyCode::Char('h'), _) => { + state.collapse_selected(); + } + (KeyCode::Char('l'), _) => { + state.expand_selected(); + } + (KeyCode::Char('v'), _) => { + state.toggle_mark(); + } + (KeyCode::Char('a'), _) => { + state.add_to_playlist(); + } + (KeyCode::Char('c'), _) => { + state.clear_playlist(); + } + (KeyCode::Char('p'), _) => { state.play_previous(); if let Some(ref path) = state.current_file { player.play(path)?; tracing::info!("Previous track: {:?}", path); } } - KeyCode::Enter => { + (KeyCode::Enter, _) => { state.play_selection(); if let Some(ref path) = state.current_file { player.play(path)?; tracing::info!("Playing: {:?} (playlist: {} tracks)", path, state.playlist.len()); } } - KeyCode::Char(' ') => { + (KeyCode::Char(' '), _) => { match state.player_state { PlayerState::Playing => { player.pause()?; @@ -177,31 +235,31 @@ async fn handle_key_event(state: &mut AppState, player: &mut player::Player, key PlayerState::Stopped => {} } } - KeyCode::Left => { + (KeyCode::Left, _) => { if state.player_state != PlayerState::Stopped { player.seek(-10.0)?; tracing::info!("Seek backward 10s"); } } - KeyCode::Right => { + (KeyCode::Right, _) => { if state.player_state != PlayerState::Stopped { player.seek(10.0)?; tracing::info!("Seek forward 10s"); } } - KeyCode::Char('+') | KeyCode::Char('=') => { + (KeyCode::Char('+'), _) | (KeyCode::Char('='), _) => { let new_volume = (state.volume + 5).min(100); state.volume = new_volume; player.set_volume(new_volume)?; tracing::info!("Volume: {}%", new_volume); } - KeyCode::Char('-') => { + (KeyCode::Char('-'), _) => { let new_volume = (state.volume - 5).max(0); state.volume = new_volume; player.set_volume(new_volume)?; tracing::info!("Volume: {}%", new_volume); } - KeyCode::Char('r') => { + (KeyCode::Char('r'), _) => { state.is_refreshing = true; tracing::info!("Rescanning..."); let cache_dir = cache::get_cache_dir()?; diff --git a/src/state/mod.rs b/src/state/mod.rs index 3de97f7..e57f1b4 100644 --- a/src/state/mod.rs +++ b/src/state/mod.rs @@ -27,6 +27,12 @@ pub struct AppState { pub playlist: Vec, pub 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, } #[derive(Debug, Clone)] @@ -60,12 +66,22 @@ impl AppState { playlist: Vec::new(), 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, } } 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; + } } } @@ -75,6 +91,31 @@ impl AppState { } } + pub fn update_scroll_offset(&mut self, visible_height: usize) { + // Scroll down when selection reaches bottom + if self.selected_index >= self.scroll_offset + visible_height { + self.scroll_offset = self.selected_index - visible_height + 1; + } + // Scroll up when selection reaches top + if self.selected_index < self.scroll_offset { + self.scroll_offset = self.selected_index; + } + } + + pub fn page_down(&mut self) { + // Move down by half page (vim Ctrl-D behavior) + let half_page = 10; // Default half page size + let new_index = (self.selected_index + half_page).min(self.flattened_items.len().saturating_sub(1)); + self.selected_index = new_index; + } + + pub fn page_up(&mut self) { + // Move up by half page (vim Ctrl-U behavior) + let half_page = 10; // Default half page size + let new_index = self.selected_index.saturating_sub(half_page); + self.selected_index = new_index; + } + pub fn get_selected_item(&self) -> Option<&FlattenedItem> { self.flattened_items.get(self.selected_index) } @@ -130,6 +171,43 @@ impl AppState { self.marked_files.clear(); } + pub fn clear_playlist(&mut self) { + self.playlist.clear(); + self.playlist_index = 0; + self.player_state = PlayerState::Stopped; + 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 + for path in &self.marked_files { + if !self.playlist.contains(path) { + self.playlist.push(path.clone()); + } + } + self.playlist.sort(); + } else if let Some(item) = self.get_selected_item() { + let node = item.node.clone(); + if node.is_dir { + // Add all files in directory + let files = collect_files_from_node(&node); + for path in files { + if !self.playlist.contains(&path) { + self.playlist.push(path); + } + } + self.playlist.sort(); + } else { + // Add single file + if !self.playlist.contains(&node.path) { + self.playlist.push(node.path.clone()); + } + } + } + } + pub fn play_selection(&mut self) { // Priority: marked files > directory > single file if !self.marked_files.is_empty() { @@ -189,6 +267,197 @@ impl AppState { 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(); + self.search_matches.clear(); + self.search_match_index = 0; + self.tab_search_results.clear(); + self.tab_search_index = 0; + } + + pub fn exit_search_mode(&mut self) { + self.search_mode = false; + self.tab_search_results.clear(); + self.tab_search_index = 0; + } + + 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; + 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; + } + + // Sort by score (highest first) + matching_paths_with_scores.sort_by(|a, b| b.1.cmp(&a.1)); + + // Store all matches for tab completion + self.tab_search_results = matching_paths_with_scores.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(); + } + } + + // 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) { + self.selected_index = idx; + } + } + + pub fn execute_search(&mut self) { + if self.search_query.is_empty() { + self.search_mode = false; + return; + } + + // Collect all matching paths with scores + 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; + } + + // Sort by score (highest first) + matching_paths_with_scores.sort_by(|a, b| b.1.cmp(&a.1)); + 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(); + while let Some(p) = parent { + self.expanded_dirs.insert(p.to_path_buf()); + parent = p.parent(); + } + } + + // 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]; + } + + 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(); + self.selected_index = self.search_matches[self.search_match_index]; + } + } + + 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; + } + self.selected_index = self.search_matches[self.search_match_index]; + } + } + + 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(); + + // Expand parent directories + 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; + } + } + + 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(); + + // Expand parent directories + 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; + } + } + } fn flatten_tree(nodes: &[FileTreeNode], depth: usize, expanded_dirs: &HashSet) -> Vec { @@ -224,3 +493,64 @@ fn collect_files_from_node(node: &FileTreeNode) -> Vec { files } + +fn fuzzy_match(text: &str, query: &str) -> Option { + let text_lower = text.to_lowercase(); + let query_lower = query.to_lowercase(); + + let mut text_chars = text_lower.chars(); + let mut score = 0; + let mut prev_match_idx = 0; + let mut consecutive_bonus = 0; + + for query_char in query_lower.chars() { + let mut found = false; + let mut current_idx = prev_match_idx; + + for text_char in text_chars.by_ref() { + current_idx += 1; + if text_char == query_char { + found = true; + // Bonus for consecutive matches + if current_idx == prev_match_idx + 1 { + consecutive_bonus += 10; + } else { + consecutive_bonus = 0; + } + // Bonus for matching at word start + if current_idx == 1 || text_lower.chars().nth(current_idx - 2).map_or(false, |c| !c.is_alphanumeric()) { + score += 15; + } + score += consecutive_bonus; + // Penalty for gap + score -= (current_idx - prev_match_idx - 1) as i32; + prev_match_idx = current_idx; + break; + } + } + + if !found { + return None; + } + } + + // Bonus for shorter strings (better matches) + score += 100 - text_lower.len() as i32; + + 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 += 50; + } + matches.push((node.path.clone(), score)); + } + if node.is_dir && !node.children.is_empty() { + collect_matching_paths(&node.children, query, matches); + } + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 733dbf0..98d791c 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,13 +1,22 @@ +mod theme; + use crate::state::{AppState, PlayerState}; use ratatui::{ layout::{Alignment, Constraint, Direction, Layout, Rect}, - style::{Color, Style}, + style::{Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, List, ListItem, Paragraph}, + widgets::{Block, Borders, List, ListItem, ListState, Paragraph}, Frame, }; +use theme::Theme; + +pub fn render(frame: &mut Frame, state: &mut AppState) { + // Clear background + frame.render_widget( + Block::default().style(Theme::secondary()), + frame.area(), + ); -pub fn render(frame: &mut Frame, state: &AppState) { // Three-section layout: title bar, main content, statusbar (like cm-dashboard) let main_chunks = Layout::default() .direction(Direction::Vertical) @@ -30,7 +39,11 @@ pub fn render(frame: &mut Frame, state: &AppState) { render_status_bar(frame, state, main_chunks[2]); } -fn render_file_panel(frame: &mut Frame, state: &AppState, area: Rect) { +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 items: Vec = state .flattened_items .iter() @@ -42,13 +55,13 @@ fn render_file_panel(frame: &mut Frame, state: &AppState, area: Rect) { let text = format!("{}{}{}{}", indent, mark, item.node.name, suffix); let style = if idx == state.selected_index { - Style::default().fg(Color::Black).bg(Color::Cyan) + Theme::selected() } else if item.node.is_dir { - Style::default().fg(Color::Blue) + Theme::directory() } else if state.marked_files.contains(&item.node.path) { - Style::default().fg(Color::Yellow) + Theme::marked() } else { - Style::default() + Theme::secondary() }; ListItem::new(text).style(style) @@ -60,39 +73,18 @@ fn render_file_panel(frame: &mut Frame, state: &AppState, area: Rect) { Block::default() .borders(Borders::ALL) .title("Media Files (Cached)") - .style(Style::default().fg(Color::White)), + .style(Theme::widget_border_style()) + .title_style(Theme::title_style()), ); - frame.render_widget(list, area); + let mut list_state = ListState::default(); + list_state.select(Some(state.selected_index)); + *list_state.offset_mut() = state.scroll_offset; + frame.render_stateful_widget(list, area, &mut list_state); } fn render_right_panel(frame: &mut Frame, state: &AppState, area: Rect) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Length(3), Constraint::Min(0)]) - .split(area); - - // Combined status line: Progress | Volume - let progress_text = if state.current_duration > 0.0 { - let position_mins = (state.current_position / 60.0) as u32; - let position_secs = (state.current_position % 60.0) as u32; - let duration_mins = (state.current_duration / 60.0) as u32; - let duration_secs = (state.current_duration % 60.0) as u32; - format!( - "{:02}:{:02}/{:02}:{:02}", - position_mins, position_secs, duration_mins, duration_secs - ) - } else { - "00:00/00:00".to_string() - }; - - let combined_status = format!("{} • Vol: {}%", progress_text, state.volume); - let status_widget = Paragraph::new(combined_status) - .block(Block::default().borders(Borders::ALL).title("Player")) - .style(Style::default().fg(Color::White)); - frame.render_widget(status_widget, chunks[0]); - - // Playlist panel + // Playlist panel (no longer need the player status box) let playlist_items: Vec = state .playlist .iter() @@ -104,9 +96,9 @@ 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 && state.player_state != PlayerState::Stopped { - Style::default().fg(Color::Black).bg(Color::Cyan) + Theme::selected() } else { - Style::default() + Theme::secondary() }; ListItem::new(filename).style(style) @@ -124,36 +116,145 @@ fn render_right_panel(frame: &mut Frame, state: &AppState, area: Rect) { Block::default() .borders(Borders::ALL) .title(playlist_title) - .style(Style::default().fg(Color::White)), + .style(Theme::widget_border_style()) + .title_style(Theme::title_style()), ); - frame.render_widget(playlist_widget, chunks[1]); + let mut playlist_state = ListState::default(); + if state.player_state != PlayerState::Stopped && !state.playlist.is_empty() { + playlist_state.select(Some(state.playlist_index)); + } + frame.render_stateful_widget(playlist_widget, area, &mut playlist_state); } fn render_title_bar(frame: &mut Frame, state: &AppState, area: Rect) { - let status_text = if state.is_refreshing { - "Refreshing library..." + let background_color = Theme::success(); // Green for normal operation + + // Split the title bar into left and right sections + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Length(18), Constraint::Min(0)]) + .split(area); + + // Left side: "cm-player" text with version + let title_text = format!(" cm-player v{}", env!("CARGO_PKG_VERSION")); + let left_span = Span::styled( + &title_text, + Style::default() + .fg(Theme::background()) + .bg(background_color) + .add_modifier(Modifier::BOLD) + ); + let left_title = Paragraph::new(Line::from(vec![left_span])) + .style(Style::default().bg(background_color)); + frame.render_widget(left_title, chunks[0]); + + // Right side: Status • Progress • Volume • Search (if active) + let mut right_spans = Vec::new(); + + if state.is_refreshing { + // Show only "Refreshing library..." when refreshing + right_spans.push(Span::styled( + "Refreshing library... ", + Style::default() + .fg(Theme::background()) + .bg(background_color) + .add_modifier(Modifier::BOLD) + )); } else { - match state.player_state { + // Status (bold when playing) + let status_text = match state.player_state { PlayerState::Stopped => "Stopped", PlayerState::Playing => "Playing", PlayerState::Paused => "Paused", + }; + + let status_style = if state.player_state == PlayerState::Playing { + Style::default() + .fg(Theme::background()) + .bg(background_color) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + .fg(Theme::background()) + .bg(background_color) + }; + + right_spans.push(Span::styled(status_text.to_string(), status_style)); + + // Progress + let progress_text = if state.current_duration > 0.0 { + let position_mins = (state.current_position / 60.0) as u32; + let position_secs = (state.current_position % 60.0) as u32; + let duration_mins = (state.current_duration / 60.0) as u32; + let duration_secs = (state.current_duration % 60.0) as u32; + format!( + " • {:02}:{:02}/{:02}:{:02}", + position_mins, position_secs, duration_mins, duration_secs + ) + } else { + " • 00:00/00:00".to_string() + }; + + right_spans.push(Span::styled( + progress_text, + Style::default() + .fg(Theme::background()) + .bg(background_color) + )); + + // Volume + right_spans.push(Span::styled( + 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 title_text = format!("cm-player • {}", status_text); - let title = Paragraph::new(title_text) - .style(Style::default().fg(Color::Black).bg(Color::Cyan)); - - frame.render_widget(title, area); + let right_title = Paragraph::new(Line::from(right_spans)) + .style(Style::default().bg(background_color)) + .alignment(Alignment::Right); + frame.render_widget(right_title, chunks[1]); } -fn render_status_bar(frame: &mut Frame, _state: &AppState, area: Rect) { - let shortcuts = "↑↓/jk: Navigate • h/l: Fold • t: Mark • c: Clear • Enter: Play • Space: Pause • ←→: Seek • +/-: Volume • n/p: Next/Prev • r: Rescan • q: Quit"; - - let status_bar = Paragraph::new(shortcuts) - .style(Style::default().fg(Color::White)) - .alignment(Alignment::Center); - - frame.render_widget(status_bar, area); +fn render_status_bar(frame: &mut Frame, state: &AppState, area: Rect) { + if state.search_mode { + // Show search prompt with current query and match count - LEFT aligned + let search_text = if !state.tab_search_results.is_empty() { + format!("/{}_ Search: {}/{}", state.search_query, state.tab_search_index + 1, state.tab_search_results.len()) + } else if !state.search_query.is_empty() { + format!("/{}_ [no matches]", state.search_query) + } else { + format!("/{}_", state.search_query) + }; + let status_bar = Paragraph::new(search_text) + .style(Style::default().fg(Theme::foreground()).bg(Theme::background())); + frame.render_widget(status_bar, area); + } else { + // Normal mode shortcuts (always shown when not in search mode) + let shortcuts = "/: Search • v: Mark • a: Add to Playlist • c: Clear Playlist • Enter: Play • Space: Pause • ←→: Seek • +/-: Volume • n/p: Next/Prev • r: Rescan • q: Quit"; + let status_bar = Paragraph::new(shortcuts) + .style(Style::default().fg(Theme::muted_text()).bg(Theme::background())) + .alignment(Alignment::Center); + frame.render_widget(status_bar, area); + } } diff --git a/src/ui/theme.rs b/src/ui/theme.rs new file mode 100644 index 0000000..732b557 --- /dev/null +++ b/src/ui/theme.rs @@ -0,0 +1,116 @@ +use ratatui::style::{Color, Style}; + +/// cm-dashboard color palette +pub struct Theme; + +impl Theme { + // Primary colors + pub fn foreground() -> Color { + Color::Rgb(198, 198, 198) // #c6c6c6 + } + + pub fn dim_foreground() -> Color { + Color::Rgb(112, 112, 112) // #707070 + } + + pub fn bright_foreground() -> Color { + Color::Rgb(255, 255, 255) // #ffffff + } + + pub fn background() -> Color { + Color::Rgb(38, 38, 38) // #262626 + } + + // Semantic colors + pub fn normal_blue() -> Color { + Color::Rgb(135, 175, 215) // #87afd7 + } + + pub fn normal_green() -> Color { + Color::Rgb(175, 215, 135) // #afd787 + } + + pub fn normal_yellow() -> Color { + Color::Rgb(215, 175, 95) // #d7af5f + } + + pub fn normal_red() -> Color { + Color::Rgb(215, 84, 0) // #d75400 + } + + pub fn normal_cyan() -> Color { + Color::Rgb(160, 160, 160) // #a0a0a0 + } + + // Semantic mappings + pub fn primary_text() -> Color { + Color::Rgb(238, 238, 238) // #eeeeee + } + + pub fn secondary_text() -> Color { + Self::foreground() + } + + pub fn muted_text() -> Color { + Self::dim_foreground() + } + + pub fn border() -> Color { + Self::dim_foreground() + } + + pub fn border_title() -> Color { + Self::bright_foreground() + } + + pub fn highlight() -> Color { + Self::normal_blue() + } + + pub fn success() -> Color { + Self::normal_green() + } + + pub fn warning() -> Color { + Self::normal_yellow() + } + + pub fn error() -> Color { + Self::normal_red() + } + + // Styles + pub fn widget_border_style() -> Style { + Style::default().fg(Self::border()).bg(Self::background()) + } + + pub fn title_style() -> Style { + Style::default() + .fg(Self::border_title()) + .bg(Self::background()) + } + + pub fn secondary() -> Style { + Style::default() + .fg(Self::secondary_text()) + .bg(Self::background()) + } + + pub fn selected() -> Style { + Style::default() + .fg(Self::background()) + .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()) + .bg(Self::background()) + } +}