From 7f5aa7602dfce5be902361828a1f36fb376b1bc6 Mon Sep 17 00:00:00 2001 From: Christoffer Martinsson Date: Sat, 6 Dec 2025 13:08:04 +0100 Subject: [PATCH] Reorganize UI layout with playlist panel and status bar - Merge Status, Progress, Volume into one compact top panel - Add Playlist panel showing queue with highlighted current track - Move help to bottom status bar (cm-dashboard style) - Bottom bar shows all keybindings in one line - Right panel now: Status (top) + Playlist (bottom) - Current playing track highlighted in cyan in playlist --- src/ui/mod.rs | 177 ++++++++++++++++++++++++-------------------------- 1 file changed, 84 insertions(+), 93 deletions(-) diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 1d34aea..affdb92 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -8,13 +8,19 @@ use ratatui::{ }; pub fn render(frame: &mut Frame, state: &AppState) { - let chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + let main_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(0), Constraint::Length(1)]) .split(frame.area()); - render_file_panel(frame, state, chunks[0]); - render_status_panel(frame, state, chunks[1]); + let top_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(main_chunks[0]); + + render_file_panel(frame, state, top_chunks[0]); + render_right_panel(frame, state, top_chunks[1]); + render_status_bar(frame, state, main_chunks[1]); } fn render_file_panel(frame: &mut Frame, state: &AppState, area: Rect) { @@ -53,19 +59,13 @@ fn render_file_panel(frame: &mut Frame, state: &AppState, area: Rect) { frame.render_widget(list, area); } -fn render_status_panel(frame: &mut Frame, state: &AppState, area: Rect) { +fn render_right_panel(frame: &mut Frame, state: &AppState, area: Rect) { let chunks = Layout::default() .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), - Constraint::Length(3), - Constraint::Length(3), - Constraint::Length(3), - Constraint::Min(0), - ]) + .constraints([Constraint::Length(3), Constraint::Min(0)]) .split(area); - // Player state or refreshing status + // Combined status line: State | Progress | Volume let state_text = if state.is_refreshing { "Refreshing library..." } else { @@ -75,97 +75,88 @@ fn render_status_panel(frame: &mut Frame, state: &AppState, area: Rect) { PlayerState::Paused => "Paused", } }; - let state_widget = Paragraph::new(state_text) - .block(Block::default().borders(Borders::ALL).title("Status")) - .style(Style::default().fg(Color::White)); - frame.render_widget(state_widget, chunks[0]); - // Current file with playlist position - let current_file = state - .current_file - .as_ref() - .and_then(|p| p.file_name()) - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_else(|| "None".to_string()); - - let playlist_info = if !state.playlist.is_empty() { - format!("{} [{}/{}]", current_file, state.playlist_index + 1, state.playlist.len()) - } else { - current_file - }; - - let file_widget = Paragraph::new(playlist_info) - .block(Block::default().borders(Borders::ALL).title("Current File")) - .style(Style::default().fg(Color::White)); - frame.render_widget(file_widget, chunks[1]); - - // 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}", + "{:02}:{:02}/{:02}:{:02}", position_mins, position_secs, duration_mins, duration_secs ) } else { - "00:00 / 00:00".to_string() + "00:00/00:00".to_string() }; - let progress_widget = Paragraph::new(progress_text) - .block(Block::default().borders(Borders::ALL).title("Progress")) - .style(Style::default().fg(Color::White)); - frame.render_widget(progress_widget, chunks[2]); - // Volume - let volume_text = format!("{}%", state.volume); - let volume_widget = Paragraph::new(volume_text) - .block(Block::default().borders(Borders::ALL).title("Volume")) + let combined_status = format!("{} | {} | Vol: {}%", state_text, progress_text, state.volume); + let status_widget = Paragraph::new(combined_status) + .block(Block::default().borders(Borders::ALL).title("Status")) .style(Style::default().fg(Color::White)); - frame.render_widget(volume_widget, chunks[3]); + frame.render_widget(status_widget, chunks[0]); - // Help - let help_text = vec![ - Line::from(""), - Line::from(vec![ - Span::styled("j/k", Style::default().fg(Color::Cyan)), - Span::raw(" Navigate"), - ]), - Line::from(vec![ - Span::styled("h/l", Style::default().fg(Color::Cyan)), - Span::raw(" Collapse/expand"), - ]), - Line::from(vec![ - Span::styled("t", Style::default().fg(Color::Cyan)), - Span::raw(" Mark file"), - ]), - Line::from(vec![ - Span::styled("c", Style::default().fg(Color::Cyan)), - Span::raw(" Clear marks"), - ]), - Line::from(vec![ - Span::styled("Enter", Style::default().fg(Color::Cyan)), - Span::raw(" Play"), - ]), - Line::from(vec![ - Span::styled("Space", Style::default().fg(Color::Cyan)), - Span::raw(" Pause"), - ]), - Line::from(vec![ - Span::styled("n/p", Style::default().fg(Color::Cyan)), - Span::raw(" Next/Prev"), - ]), - Line::from(vec![ - Span::styled("r", Style::default().fg(Color::Cyan)), - Span::raw(" Rescan"), - ]), - Line::from(vec![ - Span::styled("q", Style::default().fg(Color::Cyan)), - Span::raw(" Quit"), - ]), - ]; - let help_widget = Paragraph::new(help_text) - .block(Block::default().borders(Borders::ALL).title("Help")) - .style(Style::default().fg(Color::White)); - frame.render_widget(help_widget, chunks[4]); + // Playlist panel + 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 && state.player_state != PlayerState::Stopped { + Style::default().fg(Color::Black).bg(Color::Cyan) + } else { + Style::default() + }; + + 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(Style::default().fg(Color::White)), + ); + + frame.render_widget(playlist_widget, chunks[1]); +} + +fn render_status_bar(frame: &mut Frame, _state: &AppState, area: Rect) { + let help = Line::from(vec![ + Span::styled("j/k", Style::default().fg(Color::Cyan)), + Span::raw(" Nav | "), + Span::styled("h/l", Style::default().fg(Color::Cyan)), + Span::raw(" Fold | "), + Span::styled("t", Style::default().fg(Color::Cyan)), + Span::raw(" Mark | "), + Span::styled("c", Style::default().fg(Color::Cyan)), + Span::raw(" Clear | "), + Span::styled("Enter", Style::default().fg(Color::Cyan)), + Span::raw(" Play | "), + Span::styled("Space", Style::default().fg(Color::Cyan)), + Span::raw(" Pause | "), + Span::styled("n/p", Style::default().fg(Color::Cyan)), + Span::raw(" Next/Prev | "), + Span::styled("r", Style::default().fg(Color::Cyan)), + Span::raw(" Rescan | "), + Span::styled("q", Style::default().fg(Color::Cyan)), + Span::raw(" Quit"), + ]); + + let status_bar = Paragraph::new(help) + .style(Style::default().fg(Color::White).bg(Color::DarkGray)); + + frame.render_widget(status_bar, area); }