mod theme; use crate::state::{AppState, PlayerState}; use ratatui::{ layout::{Alignment, Constraint, Direction, Layout, Rect}, style::{Modifier, Style}, text::{Line, Span}, 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(), ); // Three-section layout: title bar, main content, statusbar (like cm-dashboard) let main_chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(1), // Title bar Constraint::Min(0), // Main content Constraint::Length(1), // Status bar ]) .split(frame.area()); // Main content: left (files) | right (status + playlist) let content_chunks = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) .split(main_chunks[1]); render_title_bar(frame, state, main_chunks[0]); render_file_panel(frame, state, content_chunks[0]); render_right_panel(frame, state, content_chunks[1]); 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() .enumerate() .map(|(idx, item)| { let indent = " ".repeat(item.depth); let mark = if state.marked_files.contains(&item.node.path) { "* " } else { "" }; // 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 state.marked_files.contains(&item.node.path) { Theme::marked() } else { Theme::secondary() }; 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(); let list = List::new(items) .block( Block::default() .borders(Borders::ALL) .title("Media Files (Cached)") .style(Theme::widget_border_style()) .title_style(Theme::title_style()), ); let mut list_state = ListState::default(); // 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); } fn render_right_panel(frame: &mut Frame, state: &AppState, area: Rect) { // Playlist panel (no longer need the player status box) let playlist_items: Vec = state .playlist .iter() .enumerate() .map(|(idx, path)| { let filename = path .file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or_else(|| path.to_string_lossy().to_string()); let style = if idx == state.playlist_index { // Current file: white bold Style::default() .fg(Theme::bright_foreground()) .bg(Theme::background()) .add_modifier(Modifier::BOLD) } else { Theme::secondary() }; ListItem::new(filename).style(style) }) .collect(); let playlist_title = if !state.playlist.is_empty() { format!("Playlist [{}/{}]", state.playlist_index + 1, state.playlist.len()) } else { "Playlist (empty)".to_string() }; let playlist_widget = List::new(playlist_items) .block( Block::default() .borders(Borders::ALL) .title(playlist_title) .style(Theme::widget_border_style()) .title_style(Theme::title_style()), ); let mut playlist_state = ListState::default(); // Don't set selection - use bold text instead frame.render_stateful_widget(playlist_widget, area, &mut playlist_state); } fn render_title_bar(frame: &mut Frame, state: &AppState, area: Rect) { 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() .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 { // 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) )); } 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) { 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 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()); let status_bar = Paragraph::new(visual_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 or visual mode) 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); frame.render_widget(status_bar, area); } }