All checks were successful
Build and Release / build-and-release (push) Successful in 51s
Search mode improvements: - Search results persist until explicitly cleared - Bold black highlighted chars on selection bar - Fix fuzzy match scoring to select first occurrence - Search info moved to bottom status bar Keybinding changes: - J/K for next/prev track (was n/p) - H/L for seeking (was arrow keys) - Simplified status bar shortcuts UI improvements: - Dynamic title bar color (green=playing, blue=paused, gray=stopped) - White bold text for current playlist item - Removed mouse capture for terminal text selection Bug fixes: - Fix auto-advance triggering multiple times when restarting from stopped state
367 lines
14 KiB
Rust
367 lines
14 KiB
Rust
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<Span<'a>> {
|
|
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<ListItem> = 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<ListItem> = 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);
|
|
}
|
|
}
|