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::palette::Palette;
use super::sampler::{self as glyphs, Fill};
use super::{
confirm_prompt, state_glyph, view_title, Drawn, Input, Screen, Scroll, Snapshot, View,
};
use playr_app::action::Action;
use playr_app::command::{self, view_name};
use playr_app::message::fmt_time;
use playr_app::sampler::{self, Display};
use playr_app::{meter, model};
use playr_core::audio::State;
use playr_core::db::Track;
pub fn draw(app: &Screen<'_>, f: &mut Frame) -> Drawn {
let [tabs_area, body, bar] = Layout::vertical([
Constraint::Length(1),
Constraint::Min(3),
Constraint::Length(4),
])
.areas(f.area());
let mut drawn = Drawn {
lists: app.lists,
help_scroll: app.help_scroll,
zoom: app.sampler.zoom,
scale: None,
};
draw_tabs(app, f, tabs_area);
match app.view {
View::Library => drawn.lists.library = draw_library(app, f, body),
View::Selection => drawn.lists.selection = draw_selection(app, f, body),
View::Playlists => drawn.lists.playlists = draw_playlists(app, f, body),
View::Sampler => (drawn.zoom, drawn.scale) = draw_sampler(app, f, body),
}
draw_bar(app, f, bar);
drawn.help_scroll = match app.input {
Input::Help => {
let rows = command::key_rows(app.keys, app.view);
let rows: Vec<(&str, &str)> =
rows.iter().map(|(k, c)| (k.as_str(), c.as_str())).collect();
let name = format!("Keys in the {} view", view_name(app.view));
draw_help(app.palette(), f, f.area(), &name, &rows, app.help_scroll)
}
Input::CommandHelp => {
let rows = command::command_rows();
let rows: Vec<(&str, &str)> =
rows.iter().map(|(k, h)| (k.as_str(), h.as_str())).collect();
draw_help(
app.palette(),
f,
f.area(),
"Commands",
&rows,
app.help_scroll,
)
}
_ => app.help_scroll,
};
if !app.colour {
for cell in f.buffer_mut().content.iter_mut() {
cell.set_fg(Color::Reset).set_bg(Color::Reset);
}
}
drawn
}
fn cursor_style(app: &Screen<'_>) -> Style {
let p = app.palette();
let style = Style::default().add_modifier(Modifier::BOLD);
if app.colour {
style.bg(p.selected_bg)
} else {
style.add_modifier(Modifier::REVERSED)
}
}
fn draw_help(
p: &Palette,
f: &mut Frame,
area: Rect,
name: &str,
rows: &[(&str, &str)],
scroll: usize,
) -> 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(p.dim).add_modifier(Modifier::BOLD),
)),
_ => Line::from(vec![
Span::styled(
format!(" {keys:<key_width$} "),
Style::default().fg(p.accent),
),
Span::raw(*action),
]),
})
.collect();
let height = (rows.len() as u16 + 2).min(area.height);
let visible = height.saturating_sub(2) as usize;
let 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(p, &title)),
popup,
);
scroll
}
fn draw_sampler(app: &Screen<'_>, f: &mut Frame, area: Rect) -> (u32, Option<sampler::Scale>) {
let p = app.palette();
let current = app.snapshot.status.current();
let name = current
.and_then(|p| p.file_name())
.map(|n| n.to_string_lossy().into_owned());
let peaks = match sampler::peaks_of(app.sampler, current) {
Ok(peaks) => peaks,
Err(hint) => {
let block = list_block(p, name.as_deref().unwrap_or(""));
let inner = block.inner(area).inner(ratatui::layout::Margin {
horizontal: 1,
vertical: 0,
});
f.render_widget(block, area);
f.render_widget(
Paragraph::new(hint).style(Style::default().fg(p.dim)),
inner,
);
return (app.sampler.zoom, None);
}
};
let inner_width = area.width.saturating_sub(2).max(1) as u64;
let layout = sampler::Layout::new(
peaks,
inner_width,
app.sampler.zoom,
app.snapshot.position,
&app.snapshot.marks,
1,
)
.with_range(app.sampler.range_ends(current))
.with_detail(app.sampler.detail(current));
let zoom = layout.zoom;
let scale = Some(layout.columns());
let title = format!(
"{} {} 1 col = {}{}{} {}",
app.sampler.display.name(),
layout.shown(),
layout.scale(),
if app.sampler.snap { " snap" } else { "" },
if app.snapshot.status.looping.is_some() {
" loop"
} else {
""
},
name.as_deref().unwrap_or(""),
);
let block = list_block(p, &title);
let inner = block.inner(area);
f.render_widget(block, area);
if inner.height == 0 {
return (zoom, scale);
}
let width = inner.width as usize;
let rows = inner.height.saturating_sub(2) as usize;
let playhead = layout.playhead();
let colours = |c: usize| {
if Some(c) == playhead {
(p.mark, p.mark_peak)
} else if layout.in_region(c) {
(p.accent, p.accent_peak)
} else {
(p.outside, p.outside_peak)
}
};
let mut lines: Vec<Line> = match app.sampler.display {
Display::Envelope | Display::Decibels => {
let columns: Vec<(f32, f32)> = (0..width)
.map(|c| layout.heights(app.sampler.display, c))
.collect();
glyphs::envelope_rows(&columns, rows)
.iter()
.map(|row| {
runs(row.iter().enumerate().map(|(c, cell)| {
let (rms, peak) = colours(c);
let colour = |fill| match fill {
Fill::Rms => Some(rms),
Fill::Peak => Some(peak),
Fill::Empty => None,
};
let mut style = Style::default();
if let Some(fg) = colour(cell.fg) {
style = style.fg(fg);
}
if let Some(bg) = colour(cell.behind) {
style = style.bg(bg);
}
(cell.glyph, style)
}))
})
.collect()
}
Display::Braille => {
let extents: Vec<(f32, f32)> = (0..width)
.flat_map(|c| {
let (a, b) = layout.span_of(c);
if b - a < 2 {
return [(a, b), (a, b)];
}
let mid = a + (b - a).div_ceil(2);
[(a, mid), (mid, b)]
})
.map(|(a, b)| layout.extent(a, b))
.collect();
glyphs::braille_rows(&extents, rows)
.iter()
.map(|row| {
runs(
row.chars()
.enumerate()
.map(|(c, g)| (g, Style::default().fg(colours(c).0))),
)
})
.collect()
}
};
let mut axis: Vec<(char, Style)> = vec![(' ', Style::default()); width];
if let Some(pending) = &app.sampler.pending {
for c in sampler::edges(pending).filter_map(|e| layout.column_of(e)) {
axis[c] = ('+', Style::default().fg(p.edge));
}
}
let (start, end) = layout.range;
for (frame, glyph, edge) in [
(start, '[', sampler::Edge::Start),
(end, ']', sampler::Edge::End),
] {
if let Some(c) = frame.and_then(|e| layout.column_of(e)) {
let style = Style::default().fg(p.accent);
axis[c] = match edge == app.sampler.edge {
true => (glyph, style.add_modifier(Modifier::REVERSED)),
false => (glyph, style),
};
}
}
for c in layout.marks.iter().filter_map(|&m| layout.column_of(m)) {
axis[c] = ('|', Style::default().fg(p.mark));
}
if let Some(c) = playhead {
axis[c] = (
'^',
Style::default().fg(p.mark).add_modifier(Modifier::BOLD),
);
}
lines.push(runs(axis.into_iter()));
let mut plan = sampler::plan_text(app.sampler);
if !plan.is_empty() {
plan.push_str(" ");
}
lines.push(Line::from(vec![
Span::styled(plan, Style::default().fg(p.edge)),
Span::styled(layout.region_text(), Style::default().fg(p.dim)),
]));
let skip = lines.len().saturating_sub(inner.height as usize);
f.render_widget(
Paragraph::new(lines.split_off(skip.min(lines.len()))),
inner,
);
(zoom, scale)
}
fn runs(cells: impl Iterator<Item = (char, Style)>) -> Line<'static> {
let mut spans: Vec<Span<'static>> = Vec::new();
let mut text = String::new();
let mut style = None;
for (ch, s) in cells {
if style.is_some_and(|current| current != s) {
spans.push(Span::styled(
std::mem::take(&mut text),
style.expect("checked"),
));
}
style = Some(s);
text.push(ch);
}
if let Some(s) = style {
spans.push(Span::styled(text, s));
}
Line::from(spans)
}
fn draw_tabs(app: &Screen<'_>, f: &mut Frame, area: Rect) {
let p = app.palette();
let views = [
View::Library,
View::Selection,
View::Playlists,
View::Sampler,
];
let titles = views.map(|v| {
let n = match v {
View::Library => app.visible().len(),
View::Selection => app.selection.len(),
View::Playlists => app.playlists.len(),
View::Sampler => return format!(" {} ", view_title(v)),
};
format!(" {} {} ", view_title(v), n)
});
let selected = views.iter().position(|v| *v == app.view).unwrap_or(0);
let tabs = Tabs::new(titles.to_vec())
.select(selected)
.style(Style::default().fg(p.dim))
.highlight_style(Style::default().fg(p.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(
p: &Palette,
t: &Track,
width: u16,
playing: bool,
marked: bool,
cursor: bool,
) -> ListItem<'static> {
let (aw, alw, tw) = columns(width);
let dim = if cursor { p.dim_selected } else { p.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(p.accent),
),
Span::styled(if marked { "+" } else { " " }, Style::default().fg(p.mark)),
Span::styled(
format!("{} ", fit(t.display_artist(), aw)),
Style::default().fg(p.name),
),
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<'a>(p: &Palette, title: &'a str) -> Block<'a> {
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(p.dim));
if title.is_empty() {
return block;
}
block.title(Span::styled(
format!(" {title} "),
Style::default().fg(p.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(
app: &Screen<'_>,
f: &mut Frame,
area: Rect,
title: &str,
tracks: &[Track],
scroll: Scroll,
marked: impl Fn(&Track) -> bool,
) -> Scroll {
let p = app.palette();
let current = app.snapshot.status.current();
let playing = |t: &Track| current.is_some_and(|p| p.as_os_str() == t.path.as_str());
let len = tracks.len();
let selected = scroll.row.map(|s| s.min(len.saturating_sub(1)));
let rows = area.height.saturating_sub(2) as usize;
let offset = scroll_offset(scroll.offset, selected, rows, len);
let end = (offset + rows).min(len);
let items: Vec<ListItem> = tracks[offset..end]
.iter()
.enumerate()
.map(|(i, t)| {
let i = offset + i;
track_line(p, t, area.width, playing(t), marked(t), selected == Some(i))
})
.collect();
let list = List::new(items)
.block(list_block(p, title))
.highlight_style(cursor_style(app));
let mut window = ListState::default().with_selected(selected.map(|s| s - offset));
f.render_stateful_widget(list, area, &mut window);
Scroll {
row: if len == 0 { None } else { selected },
offset,
}
}
fn draw_library(app: &Screen<'_>, f: &mut Frame, area: Rect) -> Scroll {
let p = app.palette();
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();
let scroll = draw_tracks(app, f, area, &title, tracks, app.lists.library, |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(p.dim)),
inner,
);
}
scroll
}
fn draw_selection(app: &Screen<'_>, f: &mut Frame, area: Rect) -> Scroll {
let p = app.palette();
let scroll = draw_tracks(app, f, area, "", app.selection, app.lists.selection, |_| {
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(p.dim)),
inner,
);
}
scroll
}
fn draw_playlists(app: &Screen<'_>, f: &mut Frame, area: Rect) -> Scroll {
let p = app.palette();
let selected = app.lists.playlists.row;
let items: Vec<ListItem> = app
.playlists
.iter()
.enumerate()
.map(|(i, list)| {
let dim = if selected == Some(i) {
p.dim_selected
} else {
p.dim
};
ListItem::new(Line::from(vec![
Span::raw(" "),
Span::styled(fit(&list.name, 40), Style::default().fg(p.name)),
Span::styled(format!("{:>4} tracks", list.len), Style::default().fg(dim)),
]))
})
.collect();
let empty = items.is_empty();
let list = List::new(items)
.block(list_block(p, ""))
.highlight_style(cursor_style(app));
let mut state = ListState::default()
.with_selected(selected)
.with_offset(app.lists.playlists.offset);
f.render_stateful_widget(list, area, &mut 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(p.dim)),
inner,
);
}
Scroll {
row: state.selected(),
offset: state.offset(),
}
}
fn draw_bar(app: &Screen<'_>, f: &mut Frame, area: Rect) {
let p = app.palette();
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 = model::now_playing(app.playing, status).unwrap_or_else(|| "nothing playing".into());
let mut spans = vec![
Span::styled(
format!("{} ", state_glyph(status.state)),
Style::default().fg(p.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(p.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(p.progress))
.ratio(ratio)
.label(Span::styled(
format!("{} / {}", fmt_time(pos), fmt_time(total)),
Style::default().fg(Color::Reset),
));
f.render_widget(gauge, progress);
let buf = f.buffer_mut();
for at in progress.positions() {
if buf[at].bg == p.progress {
buf[at].set_fg(p.progress_text);
}
}
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(p.accent)),
Span::raw(format!("{name}_")),
])),
Input::RenamePlaylist { from, name } => Some(Line::from(vec![
Span::styled(
format!("rename \"{}\" to: ", from.name),
Style::default().fg(p.accent),
),
Span::raw(format!("{name}_")),
])),
Input::Command(line) => Some(Line::from(vec![
Span::styled(":", Style::default().fg(p.accent)),
Span::raw(format!("{}_", line.text)),
])),
Input::Confirm(c) => Some(Line::from(Span::styled(
confirm_prompt(c),
Style::default().fg(p.notice),
))),
_ => 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()
.filter(|b| b.action == Some(Action::Help))
.map(|b| b.key)
.find(|&key| app.keys.lookup(key, app.view) == Some(&Action::Help))
.map(|key| key.to_string());
let indicators = indicators(p, 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(p.notice))),
left,
);
} else if playing {
let cells = (left.width as usize).saturating_sub(3);
if cells > 0 {
let bar = level_bar(p, 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 p = app.palette();
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(p.mark))),
area,
);
}
const VOLUME_CELLS: usize = 10;
fn meter_cells(db: f32, cells: usize) -> usize {
(meter::fraction(db) * cells as f32).round() as usize
}
fn zone_colour(p: &Palette, i: usize, cells: usize) -> Color {
match meter::zone(meter::level_at((i as f32 + 0.5) / cells as f32)) {
meter::Zone::Red => p.red,
meter::Zone::Yellow => p.yellow,
meter::Zone::Green => p.green,
}
}
fn level_bar(p: &Palette, 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(p.dim);
let mut spans = vec![Span::styled("[", dim)];
spans.extend((0..cells).map(|i| {
let zone = Style::default().fg(zone_colour(p, 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(p: &Palette, 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(meter::clipping);
vec![
Span::styled(format!("{lufs} LUFS "), Style::default().fg(p.dim)),
Span::styled(
format!("pk {pk} "),
Style::default().fg(if clipping { p.clip } else { p.dim }),
),
]
}
fn indicators(
p: &Palette,
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(p, snapshot.loudness, snapshot.peak));
}
let mode = snapshot.status.mode;
if mode != playr_core::audio::Mode::Normal {
spans.push(Span::styled(
format!("{} ", mode.name()),
Style::default().fg(p.notice),
));
}
if semitones != 0 {
let speed = playr_core::audio::speed_for(semitones);
spans.push(Span::styled(
format!("{speed:.2}x ({semitones:+} st) "),
Style::default().fg(p.notice),
));
}
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(p.dim),
));
if let Some(key) = help_key {
spans.push(Span::styled(
format!(" {key} help"),
Style::default().fg(p.accent),
));
}
Line::from(spans)
}