cm-player/src/ui/mod.rs
Christoffer Martinsson ae80e9a5db
All checks were successful
Build and Release / build-and-release (push) Successful in 51s
Improve search mode UX and fix playback bugs
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
2025-12-06 22:14:57 +01:00

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);
}
}