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
This commit is contained in:
Christoffer Martinsson 2025-12-06 13:08:04 +01:00
parent 71b43d644c
commit 7f5aa7602d

View File

@ -8,13 +8,19 @@ use ratatui::{
}; };
pub fn render(frame: &mut Frame, state: &AppState) { pub fn render(frame: &mut Frame, state: &AppState) {
let chunks = Layout::default() let main_chunks = Layout::default()
.direction(Direction::Horizontal) .direction(Direction::Vertical)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) .constraints([Constraint::Min(0), Constraint::Length(1)])
.split(frame.area()); .split(frame.area());
render_file_panel(frame, state, chunks[0]); let top_chunks = Layout::default()
render_status_panel(frame, state, chunks[1]); .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) { 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); 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() let chunks = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints([ .constraints([Constraint::Length(3), Constraint::Min(0)])
Constraint::Length(3),
Constraint::Length(3),
Constraint::Length(3),
Constraint::Length(3),
Constraint::Min(0),
])
.split(area); .split(area);
// Player state or refreshing status // Combined status line: State | Progress | Volume
let state_text = if state.is_refreshing { let state_text = if state.is_refreshing {
"Refreshing library..." "Refreshing library..."
} else { } else {
@ -75,97 +75,88 @@ fn render_status_panel(frame: &mut Frame, state: &AppState, area: Rect) {
PlayerState::Paused => "Paused", 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 progress_text = if state.current_duration > 0.0 {
let position_mins = (state.current_position / 60.0) as u32; let position_mins = (state.current_position / 60.0) as u32;
let position_secs = (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_mins = (state.current_duration / 60.0) as u32;
let duration_secs = (state.current_duration % 60.0) as u32; let duration_secs = (state.current_duration % 60.0) as u32;
format!( format!(
"{:02}:{:02} / {:02}:{:02}", "{:02}:{:02}/{:02}:{:02}",
position_mins, position_secs, duration_mins, duration_secs position_mins, position_secs, duration_mins, duration_secs
) )
} else { } 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 combined_status = format!("{} | {} | Vol: {}%", state_text, progress_text, state.volume);
let volume_text = format!("{}%", state.volume); let status_widget = Paragraph::new(combined_status)
let volume_widget = Paragraph::new(volume_text) .block(Block::default().borders(Borders::ALL).title("Status"))
.block(Block::default().borders(Borders::ALL).title("Volume"))
.style(Style::default().fg(Color::White)); .style(Style::default().fg(Color::White));
frame.render_widget(volume_widget, chunks[3]); frame.render_widget(status_widget, chunks[0]);
// Help // Playlist panel
let help_text = vec![ let playlist_items: Vec<ListItem> = state
Line::from(""), .playlist
Line::from(vec![ .iter()
Span::styled("j/k", Style::default().fg(Color::Cyan)), .enumerate()
Span::raw(" Navigate"), .map(|(idx, path)| {
]), let filename = path
Line::from(vec![ .file_name()
Span::styled("h/l", Style::default().fg(Color::Cyan)), .map(|n| n.to_string_lossy().to_string())
Span::raw(" Collapse/expand"), .unwrap_or_else(|| path.to_string_lossy().to_string());
]),
Line::from(vec![ let style = if idx == state.playlist_index && state.player_state != PlayerState::Stopped {
Span::styled("t", Style::default().fg(Color::Cyan)), Style::default().fg(Color::Black).bg(Color::Cyan)
Span::raw(" Mark file"), } else {
]), Style::default()
Line::from(vec![ };
Span::styled("c", Style::default().fg(Color::Cyan)),
Span::raw(" Clear marks"), ListItem::new(filename).style(style)
]), })
Line::from(vec![ .collect();
Span::styled("Enter", Style::default().fg(Color::Cyan)),
Span::raw(" Play"), let playlist_title = if !state.playlist.is_empty() {
]), format!("Playlist [{}/{}]", state.playlist_index + 1, state.playlist.len())
Line::from(vec![ } else {
Span::styled("Space", Style::default().fg(Color::Cyan)), "Playlist (empty)".to_string()
Span::raw(" Pause"), };
]),
Line::from(vec![ let playlist_widget = List::new(playlist_items)
Span::styled("n/p", Style::default().fg(Color::Cyan)), .block(
Span::raw(" Next/Prev"), Block::default()
]), .borders(Borders::ALL)
Line::from(vec![ .title(playlist_title)
Span::styled("r", Style::default().fg(Color::Cyan)), .style(Style::default().fg(Color::White)),
Span::raw(" Rescan"), );
]),
Line::from(vec![ frame.render_widget(playlist_widget, chunks[1]);
Span::styled("q", Style::default().fg(Color::Cyan)), }
Span::raw(" Quit"),
]), fn render_status_bar(frame: &mut Frame, _state: &AppState, area: Rect) {
]; let help = Line::from(vec![
let help_widget = Paragraph::new(help_text) Span::styled("j/k", Style::default().fg(Color::Cyan)),
.block(Block::default().borders(Borders::ALL).title("Help")) Span::raw(" Nav | "),
.style(Style::default().fg(Color::White)); Span::styled("h/l", Style::default().fg(Color::Cyan)),
frame.render_widget(help_widget, chunks[4]); 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);
} }