use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap};
use crate::format::{file_type_name, format_bpm, format_length};
use super::app::{App, Focus, InputMode, StatusLevel};
use super::data::TrackRow;
use super::diff::render_pair;
pub fn draw(f: &mut Frame, app: &App) {
let outer = Layout::vertical([
Constraint::Length(1), Constraint::Min(0), Constraint::Length(2), ])
.split(f.area());
draw_top_bar(f, outer[0], app);
let body = Layout::vertical([
Constraint::Min(0), Constraint::Length(5), ])
.split(outer[1]);
let cols = Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(body[0]);
draw_column(f, cols[0], app, Focus::Src);
draw_column(f, cols[1], app, Focus::Dst);
draw_preview(f, body[1], app);
draw_status(f, outer[2], app);
match app.mode {
InputMode::Confirm => draw_confirm(f, app),
InputMode::Help => draw_help(f),
_ => {}
}
}
fn draw_top_bar(f: &mut Frame, area: Rect, app: &App) {
let title = Span::styled("rekord-ripper TUI", Style::new().bold().cyan());
let opts = format!(
" replace={} lock={}",
if app.copy_opts.replace { "ON" } else { "off" },
if app.copy_opts.lock { "ON" } else { "off" },
);
let rb = if app.rb_running {
Span::styled(" rekordbox: RUNNING ", Style::new().fg(Color::Red).bold())
} else {
Span::styled(" rekordbox: closed", Style::new().fg(Color::DarkGray))
};
let line = Line::from(vec![title, Span::raw(opts), rb]);
f.render_widget(Paragraph::new(line), area);
}
fn draw_column(f: &mut Frame, area: Rect, app: &App, which: Focus) {
let (state, label, extras) = match which {
Focus::Src => (&app.src, "SOURCES", String::new()),
Focus::Dst => {
let mut tags = Vec::new();
if app.dst_filters.auto {
tags.push("auto");
}
if app.dst_filters.fuzzy_from_src {
tags.push("fuzzy");
}
let t = if tags.is_empty() {
String::new()
} else {
format!(" [{}]", tags.join("+"))
};
(&app.dst, "DESTINATIONS", t)
}
};
let focused = which == app.focus;
let border_style = if focused {
Style::new().fg(Color::Cyan)
} else {
Style::new().fg(Color::DarkGray)
};
let title = format!(" {label} ({}){} ", state.visible.len(), extras);
let block = Block::default()
.borders(Borders::ALL)
.border_style(border_style)
.title(title);
let inner = block.inner(area);
f.render_widget(block, area);
let inner_layout = Layout::vertical([Constraint::Length(2), Constraint::Min(0)]).split(inner);
let search_active = matches!(app.mode, InputMode::Search(f0) if f0 == which);
let search_caret = if search_active { "_" } else { "" };
let search = format!(" / {}{}", state.query, search_caret);
let style = if search_active {
Style::new().bold()
} else {
Style::new().dim()
};
f.render_widget(Paragraph::new(search).style(style), inner_layout[0]);
let dim = Style::new().add_modifier(Modifier::DIM);
let mut items: Vec<ListItem> = Vec::with_capacity(state.visible.len() * 2);
for (visible_pos, &row_idx) in state.visible.iter().enumerate() {
let row = &app.rows[row_idx];
let marked = match which {
Focus::Src => visible_pos == state.cursor,
Focus::Dst => state.selected.contains(&row.id),
};
let active = match which {
Focus::Src => visible_pos == state.cursor,
Focus::Dst => {
state.selected.contains(&row.id)
|| (state.selected.is_empty() && visible_pos == state.cursor)
}
};
let row_style = if !focused && !active { dim } else { Style::new() };
items.push(track_item_line1(row, marked).style(row_style));
items.push(track_item_line2(row).style(row_style));
}
let mut list_state = ListState::default();
if !state.visible.is_empty() {
list_state.select(Some(state.cursor * 2));
}
let highlight_style = if focused {
Style::new().add_modifier(Modifier::REVERSED | Modifier::BOLD)
} else {
Style::new()
};
let list = List::new(items).highlight_style(highlight_style);
f.render_stateful_widget(list, inner_layout[1], &mut list_state);
}
fn track_item_line1(row: &TrackRow, marked: bool) -> ListItem<'static> {
let title = if row.title.is_empty() {
"(untitled)"
} else {
&row.title
};
let artist = if row.artist.is_empty() {
"—"
} else {
&row.artist
};
let mark_span = if marked {
Span::styled("✓ ", Style::new().fg(Color::Green).bold())
} else {
Span::raw(" ")
};
let line = Line::from(vec![
mark_span,
Span::styled(title.to_string(), Style::new().bold()),
Span::styled(format!(" — {}", artist), Style::new().fg(Color::Gray)),
Span::styled(
format!(" — {}", file_type_name(row.file_type)),
Style::new().fg(Color::Gray),
),
]);
ListItem::new(line)
}
fn track_item_line2(row: &TrackRow) -> ListItem<'static> {
let lock = if row.locked {
Span::styled(" 🔒", Style::new().fg(Color::Yellow))
} else {
Span::raw(" ")
};
let line = Line::from(vec![
Span::raw(" "),
Span::styled(format_bpm(row.bpm), Style::new().fg(Color::Magenta)),
Span::raw(" BPM "),
Span::raw(format_length(row.length)),
Span::raw(" "),
Span::styled(
format!("{} cues", row.cue_count),
Style::new().fg(Color::Green),
),
lock,
]);
ListItem::new(line)
}
fn draw_preview(f: &mut Frame, area: Rect, app: &App) {
let src = app.current_src();
let dst = app.current_dst();
let lines: Vec<Line<'static>> = render_pair(src, dst, app.copy_opts)
.into_iter()
.map(Line::from)
.collect();
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::new().fg(Color::DarkGray))
.title(" PREVIEW ");
f.render_widget(Paragraph::new(lines).block(block), area);
}
fn draw_status(f: &mut Frame, area: Rect, app: &App) {
let parts = Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).split(area);
let hints = "tab focus / search space select a auto f fuzzy r replace l lock enter apply ? help q quit";
f.render_widget(
Paragraph::new(hints).style(Style::new().fg(Color::DarkGray)),
parts[0],
);
let style = match app.status.level {
StatusLevel::Info => Style::new().fg(Color::Gray),
StatusLevel::Ok => Style::new().fg(Color::Green),
StatusLevel::Warn => Style::new().fg(Color::Yellow),
StatusLevel::Err => Style::new().fg(Color::Red).bold(),
};
f.render_widget(Paragraph::new(app.status.text.as_str()).style(style), parts[1]);
}
fn draw_confirm(f: &mut Frame, app: &App) {
let Some(batch) = app.pending.as_ref() else {
return;
};
let area = popup_area(f.area(), 80, 70);
f.render_widget(Clear, area);
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::new().fg(Color::Yellow))
.title(" CONFIRM APPLY ");
let mut lines: Vec<Line> = Vec::new();
if let Some(p) = batch.plans.first() {
let src_title = p.src.title.as_deref().unwrap_or("?");
let src_artist = p.src.artist.as_deref().unwrap_or("?");
lines.push(Line::from(format!(
"src: \"{src_title}\" — {src_artist} ({})",
p.src.id
)));
lines.push(Line::from(""));
}
for p in &batch.plans {
let dst_title = p.dst.title.as_deref().unwrap_or("?");
let bpm_pair = match (p.set_bpm, p.dst.bpm) {
(Some(s), Some(d)) if s != d => format!(
"BPM {:.2} → {:.2}",
d as f64 / 100.0,
s as f64 / 100.0
),
_ => "BPM ≈".into(),
};
let cue_delta = format!("cues {} → {}", p.dst.cue_count, p.src.cue_count);
let len = if p.set_length.is_some() {
"len ≈"
} else {
"len skipped"
};
let dst_artist = p.dst.artist.as_deref().unwrap_or("?");
lines.push(Line::from(vec![
Span::raw(" → "),
Span::styled(
format!("\"{dst_title}\" — {dst_artist}"),
Style::new().bold(),
),
Span::styled(format!(" ({})", p.dst.id), Style::new().fg(Color::DarkGray)),
]));
lines.push(Line::from(format!(" {bpm_pair} {cue_delta} {len}")));
}
if !batch.failures.is_empty() {
lines.push(Line::from(""));
lines.push(Line::styled(
"SKIPPED:",
Style::new().fg(Color::Yellow).bold(),
));
for (dst_id, err) in &batch.failures {
lines.push(Line::from(format!(" {dst_id}: {err}")));
}
}
lines.push(Line::from(""));
lines.push(Line::styled(
format!("[y/enter] apply {} [n/esc] cancel", batch.plans.len()),
Style::new().fg(Color::Cyan).bold(),
));
let para = Paragraph::new(lines).wrap(Wrap { trim: false }).block(block);
f.render_widget(para, area);
}
fn draw_help(f: &mut Frame) {
let area = popup_area(f.area(), 70, 70);
f.render_widget(Clear, area);
let body = "\
Tab / Shift-Tab Switch focus between SOURCES and DESTINATIONS
↑ ↓ / k j Move cursor
PgUp / PgDn Page
g / G Jump top / bottom
/ Search the focused column (Esc/Enter to leave)
Ctrl-U Clear search query (in search mode)
Space Toggle destination selection (multi-select)
c Clear destination selection
a Toggle dest auto-mode (unlocked + cueless + audio)
f Toggle dest fuzzy-match-from-source filter
r Toggle --replace
l Toggle --lock (set lock on dst after copy)
R Force-reload tracks from master.db
Enter Build plans and open confirm modal
y / Enter (Confirm) Apply the batch
n / Esc / q (Confirm) Cancel
? This help
q / Esc Quit
";
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::new().fg(Color::Cyan))
.title(" HELP ");
f.render_widget(Paragraph::new(body).block(block), area);
}
fn popup_area(area: Rect, percent_x: u16, percent_y: u16) -> Rect {
let vertical = Layout::vertical([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
])
.split(area);
Layout::horizontal([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(vertical[1])[1]
}