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::action::Keymap;
use super::command::{self, view_name, COMMANDS};
use super::{fmt_time, state_glyph, Input, Screen, Snapshot, View};
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::Selection => draw_selection(app, f, body),
View::Playlists => draw_playlists(app, f, body),
}
draw_bar(app, f, bar);
match app.input {
Input::Help => {
let rows = key_rows(app.keys);
let rows: Vec<(&str, &str)> =
rows.iter().map(|(k, c)| (k.as_str(), c.as_str())).collect();
draw_help(f, f.area(), "Keys", &rows, app.help_scroll);
}
Input::CommandHelp => {
let mut rows: Vec<(String, &str)> = Vec::new();
let mut group = None;
for c in COMMANDS {
if rows.is_empty() || c.view != group {
group = c.view;
let heading = c.view.map_or("in every view", view_name);
rows.push((heading.to_string(), ""));
}
rows.push((format!(":{} {}", c.name, c.args), c.help));
}
let rows: Vec<(&str, &str)> = rows.iter().map(|(k, h)| (k.trim_end(), *h)).collect();
draw_help(f, f.area(), "Commands", &rows, app.help_scroll);
}
_ => {}
}
}
fn key_rows(keys: &Keymap) -> Vec<(String, String)> {
let mut rows = vec![("in every view".to_string(), String::new())];
for view in [
None,
Some(View::Library),
Some(View::Selection),
Some(View::Playlists),
] {
let mut group: Vec<(String, String)> = Vec::new();
for b in keys.bindings().iter().filter(|b| b.view == view) {
let command = match &b.action {
Some(action) => format!(":{}", command::line(action, view)),
None => "nothing".to_string(),
};
match group.iter_mut().find(|(_, c)| *c == command) {
Some((k, _)) => *k = format!("{k} {}", b.key),
None => group.push((b.key.to_string(), command)),
}
}
if let Some(v) = view {
if !group.is_empty() {
rows.push((view_name(v).to_string(), String::new()));
}
}
rows.extend(group);
}
rows
}
fn draw_help(f: &mut Frame, area: Rect, name: &str, rows: &[(&str, &str)], scroll: &mut usize) {
let key_width = rows.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
let action_width = rows.iter().map(|(_, a)| a.len()).max().unwrap_or(0);
let lines: Vec<Line> = rows
.iter()
.map(|(keys, action)| match *action {
"" => Line::from(Span::styled(
format!(" {keys}"),
Style::default().fg(DIM).add_modifier(Modifier::BOLD),
)),
_ => Line::from(vec![
Span::styled(
format!(" {keys:<key_width$} "),
Style::default().fg(ACCENT),
),
Span::raw(*action),
]),
})
.collect();
let height = (rows.len() as u16 + 2).min(area.height);
let visible = height.saturating_sub(2) as usize;
*scroll = (*scroll).min(rows.len().saturating_sub(visible));
let title = if rows.len() > visible {
format!("{name}: j k scroll, any other key closes")
} else {
format!("{name}: any key closes")
};
let width = (key_width + action_width + 6).max(title.len() + 4);
let width = (width as u16).min(area.width);
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)
.scroll((*scroll as u16, 0))
.block(list_block(&title)),
popup,
);
}
fn draw_tabs(app: &Screen<'_>, f: &mut Frame, area: Rect) {
let titles = [View::Library, View::Selection, View::Playlists].map(|v| {
let n = match v {
View::Library => app.visible().len(),
View::Selection => app.selection.len(),
View::Playlists => app.playlists.len(),
};
format!(" {} {} ", v.title(), n)
});
let selected = match app.view {
View::Library => 0,
View::Selection => 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,
marked: bool,
cursor: bool,
) -> ListItem<'static> {
let (aw, alw, tw) = columns(width);
let dim = if cursor { 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 line = Line::from(vec![
Span::styled(if playing { ">" } else { " " }, Style::default().fg(ACCENT)),
Span::styled(
if marked { "+" } else { " " },
Style::default().fg(Color::Yellow),
),
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<'_> {
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(DIM));
if title.is_empty() {
return block;
}
block.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,
marked: impl Fn(&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), marked(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(_) => "Search results (esc clears)".to_string(),
None => String::new(),
},
};
let selected: std::collections::HashSet<&str> =
app.selection.iter().map(|t| t.path.as_str()).collect();
draw_tracks(
f,
area,
&title,
tracks,
app.library_state,
|_, t| current.is_some_and(|p| p.as_os_str() == t.path.as_str()),
|t| selected.contains(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_selection(app: &mut Screen<'_>, f: &mut Frame, area: Rect) {
let status = &app.snapshot.status;
let current = status.current();
draw_tracks(
f,
area,
"",
app.selection,
app.selection_state,
|_, t| current.is_some_and(|p| p.as_os_str() == t.path.as_str()),
|_| false,
);
if app.selection.is_empty() {
let inner = area.inner(ratatui::layout::Margin {
horizontal: 2,
vertical: 1,
});
f.render_widget(
Paragraph::new("Selection is empty. Press a in the library to add tracks, then s to save them as a playlist.")
.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 list = List::new(items).block(list_block("")).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 selection, 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, ticks, hints] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
])
.areas(area.inner(ratatui::layout::Margin {
horizontal: 1,
vertical: 0,
}));
let label = app
.playing
.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);
draw_marks(app, f, ticks);
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::RenamePlaylist { from, name } => Some(Line::from(vec![
Span::styled(
format!("rename \"{}\" to: ", from.name),
Style::default().fg(ACCENT),
),
Span::raw(format!("{name}_")),
])),
Input::Command(line) => Some(Line::from(vec![
Span::styled(":", Style::default().fg(ACCENT)),
Span::raw(format!("{}_", line.text)),
])),
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 playing = status.state == State::Playing;
let help_key = app
.keys
.bindings()
.iter()
.find(|b| b.view.is_none() && b.action == Some(crate::ui::action::Action::Help))
.map(|b| b.key.to_string());
let indicators = indicators(status.semitones, app.snapshot, playing, help_key);
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,
);
} else if playing {
let cells = (left.width as usize).saturating_sub(3);
if cells > 0 {
let bar = level_bar(app.snapshot.loudness, app.snapshot.peak, cells);
f.render_widget(Paragraph::new(bar), left);
}
}
f.render_widget(Paragraph::new(indicators), right);
}
fn draw_marks(app: &Screen<'_>, f: &mut Frame, area: Rect) {
let marks = &app.snapshot.marks;
let Some(total) = app.snapshot.status.duration.filter(|d| !d.is_zero()) else {
return;
};
let width = area.width as usize;
if marks.is_empty() || width == 0 {
return;
}
let mut row = vec![' '; width];
for mark in marks {
let fraction = (mark.as_secs_f64() / total.as_secs_f64()).clamp(0.0, 1.0);
row[(fraction * (width - 1) as f64).round() as usize] = '^';
}
let row: String = row.into_iter().collect();
f.render_widget(
Paragraph::new(Span::styled(row, Style::default().fg(Color::Yellow))),
area,
);
}
const METER_FLOOR_DB: f32 = -40.0;
const VOLUME_CELLS: usize = 10;
fn meter_cells(db: f32, cells: usize) -> usize {
let fraction = (db - METER_FLOOR_DB) / -METER_FLOOR_DB;
(fraction * cells as f32).round().clamp(0.0, cells as f32) as usize
}
const YELLOW_FROM_DB: f32 = -18.0;
const RED_FROM_DB: f32 = -6.0;
fn zone_colour(i: usize, cells: usize) -> Color {
let db = METER_FLOOR_DB * (1.0 - (i as f32 + 0.5) / cells as f32);
if db >= RED_FROM_DB {
Color::Red
} else if db >= YELLOW_FROM_DB {
Color::Yellow
} else {
Color::Green
}
}
fn level_bar(loudness: Option<f32>, peak: Option<f32>, cells: usize) -> Line<'static> {
let fill = loudness.map_or(0, |l| meter_cells(l, cells));
let marker = peak
.map(|p| meter_cells(p, cells))
.filter(|c| *c > 0)
.map(|c| c - 1);
let dim = Style::default().fg(DIM);
let mut spans = vec![Span::styled("[", dim)];
spans.extend((0..cells).map(|i| {
let zone = Style::default().fg(zone_colour(i, cells));
match i {
_ if Some(i) == marker => Span::styled("|", zone),
_ if i < fill => Span::styled("#", zone),
_ => Span::styled("-", dim),
}
}));
spans.push(Span::styled("]", dim));
Line::from(spans)
}
fn level_readout(loudness: Option<f32>, peak: Option<f32>) -> Vec<Span<'static>> {
let lufs = loudness.map_or(" --".into(), |l| format!("{l:>5.1}"));
let pk = peak.map_or(" --".into(), |p| format!("{p:>5.1}"));
let clipping = peak.is_some_and(|p| p >= -0.1);
vec![
Span::styled(format!("{lufs} LUFS "), Style::default().fg(DIM)),
Span::styled(
format!("pk {pk} "),
Style::default().fg(if clipping { Color::Red } else { DIM }),
),
]
}
fn indicators(
semitones: i32,
snapshot: &Snapshot,
playing: bool,
help_key: Option<String>,
) -> Line<'static> {
let volume = snapshot.volume;
let mut spans = Vec::new();
if playing {
spans.extend(level_readout(snapshot.loudness, snapshot.peak));
}
let mode = snapshot.status.mode;
if mode != crate::audio::Mode::Normal {
spans.push(Span::styled(
format!("{} ", mode.name()),
Style::default().fg(Color::Yellow),
));
}
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) * VOLUME_CELLS as f32).round() as usize;
spans.push(Span::styled(
format!(
"vol [{}{}] {:>3.0}%",
"#".repeat(filled),
"-".repeat(VOLUME_CELLS - filled),
volume * 100.0
),
Style::default().fg(DIM),
));
if let Some(key) = help_key {
spans.push(Span::styled(
format!(" {key} help"),
Style::default().fg(ACCENT),
));
}
Line::from(spans)
}