use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style, Stylize};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Gauge, List, ListItem, ListState, Paragraph, Tabs};
use ratatui::Frame;
use unicode_width::UnicodeWidthChar;
use super::{fmt_time, state_glyph, Input, Screen, View, KEYS};
use crate::audio::State;
use crate::db::Track;
const ACCENT: Color = Color::Cyan;
const DIM: Color = Color::DarkGray;
const SELECTED_BG: Color = Color::DarkGray;
const DIM_SELECTED: Color = Color::Gray;
pub fn draw(app: &mut Screen<'_>, f: &mut Frame) {
let [tabs_area, body, bar] = Layout::vertical([
Constraint::Length(1),
Constraint::Min(3),
Constraint::Length(4),
])
.areas(f.area());
draw_tabs(app, f, tabs_area);
match app.view {
View::Library => draw_library(app, f, body),
View::Queue => draw_queue(app, f, body),
View::Playlists => draw_playlists(app, f, body),
}
draw_bar(app, f, bar);
if matches!(app.input, Input::Help) {
draw_help(f, f.area());
}
}
fn draw_help(f: &mut Frame, area: Rect) {
let key_width = KEYS.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
let action_width = KEYS.iter().map(|(_, a)| a.len()).max().unwrap_or(0);
let lines: Vec<Line> = KEYS
.iter()
.map(|(keys, action)| {
Line::from(vec![
Span::styled(
format!(" {keys:<key_width$} "),
Style::default().fg(ACCENT),
),
Span::raw(*action),
])
})
.collect();
let width = ((key_width + action_width + 6) as u16).min(area.width);
let height = (KEYS.len() as u16 + 2).min(area.height);
let popup = Rect {
x: area.x + (area.width - width) / 2,
y: area.y + (area.height - height) / 2,
width,
height,
};
f.render_widget(Clear, popup);
f.render_widget(
Paragraph::new(lines).block(list_block("Keys: any key closes")),
popup,
);
}
fn draw_tabs(app: &Screen<'_>, f: &mut Frame, area: Rect) {
let titles = [View::Library, View::Queue, View::Playlists].map(|v| {
let n = match v {
View::Library => app.visible().len(),
View::Queue => app.queue.len(),
View::Playlists => app.playlists.len(),
};
format!(" {} {} ", v.title(), n)
});
let selected = match app.view {
View::Library => 0,
View::Queue => 1,
View::Playlists => 2,
};
let tabs = Tabs::new(titles.to_vec())
.select(selected)
.style(Style::default().fg(DIM))
.highlight_style(Style::default().fg(ACCENT).add_modifier(Modifier::BOLD))
.divider("");
f.render_widget(tabs, area);
}
const ROW_OVERHEAD: usize = 13;
fn columns(width: u16) -> (usize, usize, usize) {
let usable = (width as usize).saturating_sub(ROW_OVERHEAD);
let artist = (usable / 4).clamp(6, 28).min(usable);
let album = (usable.saturating_sub(artist) / 3)
.clamp(6, 28)
.min(usable - artist);
let title = usable.saturating_sub(artist + album);
(artist, album, title)
}
fn fit(s: &str, width: usize) -> String {
let mut out = String::with_capacity(width);
let mut used = 0;
for c in s.chars() {
let w = c.width().unwrap_or(0);
if used + w > width {
break;
}
out.push(c);
used += w;
}
out.extend(std::iter::repeat_n(' ', width - used));
out
}
fn track_line(t: &Track, width: u16, playing: bool, selected: bool) -> ListItem<'static> {
let (aw, alw, tw) = columns(width);
let dim = if selected { DIM_SELECTED } else { DIM };
let dur = t
.duration_ms
.map(|ms| fmt_time(std::time::Duration::from_millis(ms.max(0) as u64)))
.unwrap_or_else(|| "-".into());
let marker = if playing { ">" } else { " " };
let line = Line::from(vec![
Span::styled(format!("{marker} "), Style::default().fg(ACCENT)),
Span::styled(
format!("{} ", fit(t.display_artist(), aw)),
Style::default().fg(Color::Green),
),
Span::styled(
format!("{} ", fit(t.display_album(), alw)),
Style::default().fg(dim),
),
Span::raw(format!("{} ", fit(&t.display_title(), tw))),
Span::styled(format!("{dur:>6}"), Style::default().fg(dim)),
]);
ListItem::new(line)
}
fn list_block(title: &str) -> Block<'_> {
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(DIM))
.title(Span::styled(
format!(" {title} "),
Style::default().fg(ACCENT),
))
}
pub fn scroll_offset(offset: usize, selected: Option<usize>, rows: usize, len: usize) -> usize {
if rows == 0 || len == 0 {
return 0;
}
let mut offset = offset.min(len.saturating_sub(rows));
if let Some(s) = selected {
if s < offset {
offset = s;
} else if s >= offset + rows {
offset = s + 1 - rows;
}
}
offset
}
fn draw_tracks(
f: &mut Frame,
area: Rect,
title: &str,
tracks: &[Track],
state: &mut ListState,
playing: impl Fn(usize, &Track) -> bool,
) {
let len = tracks.len();
let selected = state.selected().map(|s| s.min(len.saturating_sub(1)));
let rows = area.height.saturating_sub(2) as usize;
let offset = scroll_offset(state.offset(), selected, rows, len);
state.select(if len == 0 { None } else { selected });
*state.offset_mut() = offset;
let end = (offset + rows).min(len);
let items: Vec<ListItem> = tracks[offset..end]
.iter()
.enumerate()
.map(|(i, t)| {
let i = offset + i;
track_line(t, area.width, playing(i, t), selected == Some(i))
})
.collect();
let list = List::new(items).block(list_block(title)).highlight_style(
Style::default()
.bg(SELECTED_BG)
.add_modifier(Modifier::BOLD),
);
let mut window = ListState::default().with_selected(selected.map(|s| s - offset));
f.render_stateful_widget(list, area, &mut window);
}
fn draw_library(app: &mut Screen<'_>, f: &mut Frame, area: Rect) {
let current = app.snapshot.status.current();
let tracks = app.results.unwrap_or(app.all);
let title = match app.input {
Input::Search(q) => format!("Search: {q}_"),
_ => match app.results {
Some(r) => format!("Results ({})", r.len()),
None => format!("Library ({})", app.all.len()),
},
};
draw_tracks(f, area, &title, tracks, app.library_state, |_, t| {
current.is_some_and(|p| p.as_os_str() == t.path.as_str())
});
if tracks.is_empty() {
let hint = if app.results.is_some() {
"No matches. Esc clears the search."
} else {
"Library is empty. Run: playr scan <directory>"
};
let inner = area.inner(ratatui::layout::Margin {
horizontal: 2,
vertical: 1,
});
f.render_widget(Paragraph::new(hint).style(Style::default().fg(DIM)), inner);
}
}
fn draw_queue(app: &mut Screen<'_>, f: &mut Frame, area: Rect) {
let status = &app.snapshot.status;
let title = format!("Queue ({})", app.queue.len());
draw_tracks(f, area, &title, app.queue, app.queue_state, |i, _| {
i == status.index && status.state != State::Stopped
});
if app.queue.is_empty() {
let inner = area.inner(ratatui::layout::Margin {
horizontal: 2,
vertical: 1,
});
f.render_widget(
Paragraph::new("Queue is empty. Press Enter in the library to play from there.")
.style(Style::default().fg(DIM)),
inner,
);
}
}
fn draw_playlists(app: &mut Screen<'_>, f: &mut Frame, area: Rect) {
let selected = app.playlist_state.selected();
let items: Vec<ListItem> = app
.playlists
.iter()
.enumerate()
.map(|(i, p)| {
let dim = if selected == Some(i) {
DIM_SELECTED
} else {
DIM
};
ListItem::new(Line::from(vec![
Span::raw(" "),
Span::styled(fit(&p.name, 40), Style::default().fg(Color::Green)),
Span::styled(format!("{:>4} tracks", p.len), Style::default().fg(dim)),
]))
})
.collect();
let empty = items.is_empty();
let title = format!("Playlists ({})", app.playlists.len());
let list = List::new(items).block(list_block(&title)).highlight_style(
Style::default()
.bg(SELECTED_BG)
.add_modifier(Modifier::BOLD),
);
f.render_stateful_widget(list, area, app.playlist_state);
if empty {
let inner = area.inner(ratatui::layout::Margin {
horizontal: 2,
vertical: 1,
});
f.render_widget(
Paragraph::new("No playlists. Build a queue, then press s to save it.")
.style(Style::default().fg(DIM)),
inner,
);
}
}
fn draw_bar(app: &Screen<'_>, f: &mut Frame, area: Rect) {
let status = &app.snapshot.status;
let pos = app.snapshot.position;
let [now, progress, hints] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
])
.areas(area.inner(ratatui::layout::Margin {
horizontal: 1,
vertical: 0,
}));
let label = app
.queue
.get(status.index)
.map(|t| format!("{} - {}", t.display_title(), t.display_artist()))
.or_else(|| {
status.current().map(|p| {
p.file_name()
.unwrap_or_default()
.to_string_lossy()
.into_owned()
})
})
.unwrap_or_else(|| "nothing playing".into());
let mut spans = vec![
Span::styled(
format!("{} ", state_glyph(status.state)),
Style::default().fg(ACCENT),
),
Span::raw(label).bold(),
];
if let Some(src) = status.source {
let khz = src.rate as f64 / 1000.0;
let mut fmt = format!(" {khz:.1}kHz {}ch", src.channels);
if status.output_rate != 0 && status.output_rate != src.rate {
fmt.push_str(&format!(" -> {:.1}kHz", status.output_rate as f64 / 1000.0));
}
spans.push(Span::styled(fmt, Style::default().fg(DIM)));
}
f.render_widget(Paragraph::new(Line::from(spans)), now);
let total = status.duration.unwrap_or_default();
let ratio = if total.is_zero() {
0.0
} else {
(pos.as_secs_f64() / total.as_secs_f64()).clamp(0.0, 1.0)
};
let gauge = Gauge::default()
.gauge_style(Style::default().fg(ACCENT))
.ratio(ratio)
.label(format!("{} / {}", fmt_time(pos), fmt_time(total)));
f.render_widget(gauge, progress);
let prompt = match app.input {
Input::SavePlaylist(name) => Some(Line::from(vec![
Span::styled("save playlist as: ", Style::default().fg(ACCENT)),
Span::raw(format!("{name}_")),
])),
Input::Confirm(c) => Some(Line::from(Span::styled(
c.prompt(),
Style::default().fg(Color::Yellow),
))),
_ => None,
};
if let Some(prompt) = prompt {
f.render_widget(Paragraph::new(prompt), hints);
return;
}
let indicators = indicators(status.semitones, app.snapshot.volume);
let [left, right] = Layout::horizontal([
Constraint::Min(0),
Constraint::Length(indicators.width() as u16),
])
.areas(hints);
if let Some(msg) = app.message {
f.render_widget(
Paragraph::new(Span::styled(msg, Style::default().fg(Color::Yellow))),
left,
);
}
f.render_widget(Paragraph::new(indicators), right);
}
fn indicators(semitones: i32, volume: f32) -> Line<'static> {
let mut spans = Vec::new();
if semitones != 0 {
let speed = crate::audio::speed_for(semitones);
spans.push(Span::styled(
format!("{speed:.2}x ({semitones:+} st) "),
Style::default().fg(Color::Yellow),
));
}
let filled = (volume.clamp(0.0, 1.0) * 10.0).round() as usize;
spans.push(Span::styled(
format!(
"vol [{}{}] {:>3.0}%",
"#".repeat(filled),
"-".repeat(10 - filled),
volume * 100.0
),
Style::default().fg(DIM),
));
spans.push(Span::styled(" ? help", Style::default().fg(ACCENT)));
Line::from(spans)
}