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) {
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,31 +75,7 @@ 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;
@ -112,60 +88,75 @@ fn render_status_panel(frame: &mut Frame, state: &AppState, area: Rect) {
} else {
"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![
// Playlist panel
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 && 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(" Navigate"),
]),
Line::from(vec![
Span::raw(" Nav | "),
Span::styled("h/l", Style::default().fg(Color::Cyan)),
Span::raw(" Collapse/expand"),
]),
Line::from(vec![
Span::raw(" Fold | "),
Span::styled("t", Style::default().fg(Color::Cyan)),
Span::raw(" Mark file"),
]),
Line::from(vec![
Span::raw(" Mark | "),
Span::styled("c", Style::default().fg(Color::Cyan)),
Span::raw(" Clear marks"),
]),
Line::from(vec![
Span::raw(" Clear | "),
Span::styled("Enter", Style::default().fg(Color::Cyan)),
Span::raw(" Play"),
]),
Line::from(vec![
Span::raw(" Play | "),
Span::styled("Space", Style::default().fg(Color::Cyan)),
Span::raw(" Pause"),
]),
Line::from(vec![
Span::raw(" Pause | "),
Span::styled("n/p", Style::default().fg(Color::Cyan)),
Span::raw(" Next/Prev"),
]),
Line::from(vec![
Span::raw(" Next/Prev | "),
Span::styled("r", Style::default().fg(Color::Cyan)),
Span::raw(" Rescan"),
]),
Line::from(vec![
Span::raw(" Rescan | "),
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]);
]);
let status_bar = Paragraph::new(help)
.style(Style::default().fg(Color::White).bg(Color::DarkGray));
frame.render_widget(status_bar, area);
}