use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, List, ListItem, ListState, Paragraph};
use ratatui::Frame;
use crate::tui::app::{App, FileBrowserState, WizardStep};
use crate::tui::theme::Theme;
const TOTAL_STEPS: usize = 4;
pub fn render(f: &mut Frame, app: &App) {
let theme = &app.theme;
let area = f.area();
let Some(ref wiz) = app.wizard else { return };
let step_label = if wiz.step == WizardStep::Confirm {
"confirm".to_owned()
} else {
format!(
"step {}/{TOTAL_STEPS}: {}",
wiz.step.number(),
wiz.step.label()
)
};
let outer = Block::bordered()
.title(
Line::from(" dapctl — new profile ")
.style(Style::default().fg(theme.fg).add_modifier(Modifier::BOLD)),
)
.title(
Line::from(format!(" {step_label} "))
.style(Style::default().fg(theme.muted))
.alignment(ratatui::layout::Alignment::Right),
)
.border_style(Style::default().fg(theme.muted))
.style(Style::default().bg(theme.bg));
let inner = outer.inner(area);
f.render_widget(outer, area);
let [dots_area, content_area, error_area, footer_area] = Layout::vertical([
Constraint::Length(1),
Constraint::Fill(1),
Constraint::Length(1),
Constraint::Length(1),
])
.areas(inner);
let step_n = wiz.step.number();
let mut dot_spans: Vec<Span> = vec![Span::raw(" ")];
for i in 1usize..=5 {
if i > 1 {
dot_spans.push(Span::raw(" "));
}
let (ch, style) = if i < step_n {
("●", Style::default().fg(theme.muted))
} else if i == step_n {
(
"●",
Style::default().fg(theme.fg).add_modifier(Modifier::BOLD),
)
} else {
("○", Style::default().fg(theme.muted))
};
dot_spans.push(Span::styled(ch, style));
}
f.render_widget(Paragraph::new(Line::from(dot_spans)), dots_area);
if let Some(ref err) = wiz.error {
f.render_widget(
Paragraph::new(Line::from(Span::styled(
format!(" ! {err}"),
Style::default().fg(theme.err),
))),
error_area,
);
}
match wiz.step {
WizardStep::Name => render_name(f, app, content_area, footer_area),
WizardStep::Source => render_browser_step(
f,
app,
content_area,
footer_area,
"Source — your music library",
&wiz.source_browser,
),
WizardStep::Destination => render_destination(f, app, content_area, footer_area),
WizardStep::Mode => render_mode(f, app, content_area, footer_area),
WizardStep::Confirm => render_confirm(f, app, content_area, footer_area),
}
}
fn render_name(f: &mut Frame, app: &App, content: Rect, footer: Rect) {
let theme = &app.theme;
let wiz = app.wizard.as_ref().unwrap();
let [_, heading_area, _, input_area, _, hint_area, _] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(3),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Fill(1),
])
.areas(content);
f.render_widget(
Paragraph::new(Line::from(Span::styled(
" Profile name",
Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
))),
heading_area,
);
let input_width = input_area.width.saturating_sub(2) as usize;
let scroll = wiz.name.visual_scroll(input_width);
f.render_widget(
Paragraph::new(Line::from(Span::styled(
wiz.name.value(),
Style::default().fg(theme.fg).bg(theme.bg),
)))
.scroll((0, scroll as u16))
.block(
Block::bordered()
.border_style(Style::default().fg(theme.warn))
.style(Style::default().bg(theme.bg)),
),
input_area,
);
let visual = wiz.name.visual_cursor().min(input_width.saturating_sub(1));
f.set_cursor_position((input_area.x + 1 + visual as u16, input_area.y + 1));
let hint_text = if let Some(ref src) = wiz.cloned_from {
format!(" cloning from '{src}' — change the name then press enter")
} else {
" A short identifier (e.g. hiby-r4-flac). Used as the filename.".to_owned()
};
f.render_widget(
Paragraph::new(Line::from(Span::styled(
hint_text,
Style::default().fg(theme.muted),
))),
hint_area,
);
render_text_footer(f, app, footer);
}
fn render_browser_step(
f: &mut Frame,
app: &App,
content: Rect,
footer: Rect,
heading: &str,
browser: &FileBrowserState,
) {
let theme = &app.theme;
let [_, heading_area, path_area, _, list_area] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Fill(1),
])
.areas(content);
f.render_widget(
Paragraph::new(Line::from(Span::styled(
format!(" {heading}"),
Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
))),
heading_area,
);
let (path_str, path_style) = if browser.at_drives_root {
(" [ select a drive ]", Style::default().fg(theme.muted))
} else {
(
browser.current.as_str(),
Style::default().fg(theme.warn).add_modifier(Modifier::BOLD),
)
};
f.render_widget(
Paragraph::new(Line::from(Span::styled(
if browser.at_drives_root {
path_str.to_owned()
} else {
format!(" {path_str}")
},
path_style,
))),
path_area,
);
render_browser_list(f, theme, browser, list_area);
render_browser_footer(f, app, footer, !browser.at_drives_root);
}
fn render_destination(f: &mut Frame, app: &App, content: Rect, footer: Rect) {
let theme = &app.theme;
let wiz = app.wizard.as_ref().unwrap();
let manual_idx = app.scan.identified.len();
if wiz.dest_choice == manual_idx {
if let Some(ref browser) = wiz.dest_browser {
render_browser_step(
f,
app,
content,
footer,
"Destination — browse to the folder on your DAP",
browser,
);
return;
}
}
let [_, heading_area, _, list_area] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Fill(1),
])
.areas(content);
f.render_widget(
Paragraph::new(Line::from(Span::styled(
" Destination",
Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
))),
heading_area,
);
let mut items: Vec<ListItem> = app
.scan
.identified
.iter()
.map(|id| {
ListItem::new(Line::from(vec![
Span::styled(
format!(" {:<22}", id.dap_id),
Style::default().fg(theme.fg),
),
Span::styled(
format!("auto:{:<16} {}", id.dap_id, id.mount.mount_point),
Style::default().fg(theme.muted),
),
]))
})
.collect();
items.push(ListItem::new(Line::from(Span::styled(
" Browse filesystem…",
Style::default()
.fg(theme.muted)
.add_modifier(Modifier::ITALIC),
))));
let list = List::new(items)
.style(Style::default().bg(theme.bg))
.highlight_style(
Style::default()
.fg(theme.sel_fg)
.bg(theme.sel_bg)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("▶");
let mut state = ListState::default().with_selected(Some(wiz.dest_choice));
f.render_stateful_widget(list, list_area, &mut state);
render_list_footer(f, app, footer);
}
fn render_mode(f: &mut Frame, app: &App, content: Rect, footer: Rect) {
let theme = &app.theme;
let wiz = app.wizard.as_ref().unwrap();
let [_, heading_area, _, list_area] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Fill(1),
])
.areas(content);
f.render_widget(
Paragraph::new(Line::from(Span::styled(
" Sync mode",
Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
))),
heading_area,
);
let modes = [
(
"additive",
"copy new + modified, never delete from destination",
),
(
"mirror",
"copy new + modified, DELETE orphans from destination",
),
(
"selective",
"mark albums to include; remembers your selection",
),
];
let items: Vec<ListItem> = modes
.iter()
.map(|(name, desc)| {
ListItem::new(Line::from(vec![
Span::styled(
format!(" {name:<12}"),
Style::default().fg(theme.fg).add_modifier(Modifier::BOLD),
),
Span::styled(format!(" {desc}"), Style::default().fg(theme.muted)),
]))
})
.collect();
let list = List::new(items)
.style(Style::default().bg(theme.bg))
.highlight_style(
Style::default()
.fg(theme.sel_fg)
.bg(theme.sel_bg)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("▶");
let mut state = ListState::default().with_selected(Some(wiz.mode_choice));
f.render_stateful_widget(list, list_area, &mut state);
render_list_footer(f, app, footer);
}
fn render_confirm(f: &mut Frame, app: &App, content: Rect, footer: Rect) {
let theme = &app.theme;
let wiz = app.wizard.as_ref().unwrap();
let source = wiz.source();
let dest = wiz.destination(&app.scan);
let dir = crate::config::profiles_dir()
.map(|p| p.display().to_string())
.unwrap_or_else(|_| "~/.config/dapctl/profiles/".to_owned());
let filename = sanitize_name(wiz.name.value());
let lines = vec![
Line::raw(""),
Line::from(Span::styled(
" Profile ready to write",
Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
)),
Line::raw(""),
summary_row(" name ", wiz.name.value().trim(), theme),
summary_row(" source ", &source, theme),
summary_row(" destination ", &dest, theme),
summary_row(" DAP profile ", wiz.selected_dap(), theme),
summary_row(" mode ", wiz.selected_mode(), theme),
Line::raw(""),
Line::from(vec![
Span::styled(" will write ", Style::default().fg(theme.muted)),
Span::styled(
format!("{dir}/{filename}.toml"),
Style::default().fg(theme.warn),
),
]),
];
f.render_widget(
Paragraph::new(lines).style(Style::default().bg(theme.bg)),
content,
);
f.render_widget(
Paragraph::new(Line::from(vec![
kb("enter"),
Span::raw(" confirm "),
kb("esc"),
Span::raw(" back "),
kb("q"),
Span::raw(" cancel"),
]))
.style(Style::default().fg(theme.muted)),
footer,
);
}
fn render_browser_list(f: &mut Frame, theme: &Theme, browser: &FileBrowserState, area: Rect) {
let visible = area.height as usize;
let scroll = browser.cursor.saturating_sub(visible.saturating_sub(1));
let items: Vec<ListItem> = if browser.at_drives_root {
browser
.entries
.iter()
.map(|drive| {
ListItem::new(Line::from(vec![
Span::raw(" "),
Span::styled(
drive.as_str(),
Style::default().fg(theme.fg).add_modifier(Modifier::BOLD),
),
]))
})
.collect()
} else {
let select = ListItem::new(Line::from(Span::styled(
" [ ✓ select this directory ]",
Style::default().fg(theme.warn).add_modifier(Modifier::BOLD),
)));
let mut v = vec![select];
v.extend(browser.entries.iter().map(|name| {
ListItem::new(Line::from(vec![
Span::raw(" "),
Span::styled(name.as_str(), Style::default().fg(theme.fg)),
Span::styled("/", Style::default().fg(theme.muted)),
]))
}));
v
};
if items.is_empty() {
f.render_widget(
Paragraph::new(Line::from(Span::styled(
" (empty directory)",
Style::default().fg(theme.muted),
))),
area,
);
return;
}
let list = List::new(items)
.style(Style::default().bg(theme.bg))
.highlight_style(
Style::default()
.fg(theme.sel_fg)
.bg(theme.sel_bg)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("▶");
let mut state = ListState::default()
.with_selected(Some(browser.cursor))
.with_offset(scroll);
f.render_stateful_widget(list, area, &mut state);
}
fn render_text_footer(f: &mut Frame, app: &App, area: Rect) {
f.render_widget(
Paragraph::new(Line::from(vec![
kb("enter"),
Span::raw(" next "),
kb("esc"),
Span::raw(" back "),
kb("ctrl+c"),
Span::raw(" quit"),
]))
.style(Style::default().fg(app.theme.muted)),
area,
);
}
fn render_browser_footer(f: &mut Frame, app: &App, area: Rect, can_go_up: bool) {
let mut spans = vec![
kb("j/k"),
Span::raw(" navigate "),
kb("enter/l/→"),
Span::raw(" open/select "),
];
if can_go_up {
spans.push(kb("h/←"));
spans.push(Span::raw(" parent "));
}
spans.push(kb("esc"));
spans.push(Span::raw(" back"));
f.render_widget(
Paragraph::new(Line::from(spans)).style(Style::default().fg(app.theme.muted)),
area,
);
}
fn render_list_footer(f: &mut Frame, app: &App, area: Rect) {
f.render_widget(
Paragraph::new(Line::from(vec![
kb("j/k"),
Span::raw(" move "),
kb("enter"),
Span::raw(" select "),
kb("esc"),
Span::raw(" back"),
]))
.style(Style::default().fg(app.theme.muted)),
area,
);
}
fn summary_row<'a>(label: &'a str, value: &'a str, theme: &'a Theme) -> Line<'a> {
Line::from(vec![
Span::styled(label, Style::default().fg(theme.muted)),
Span::styled(
value,
Style::default().fg(theme.fg).add_modifier(Modifier::BOLD),
),
])
}
fn kb(key: &str) -> Span<'static> {
Span::styled(
key.to_owned(),
Style::default().add_modifier(Modifier::BOLD),
)
}
pub fn sanitize_name(s: &str) -> String {
s.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' || c == '_' {
c
} else {
'-'
}
})
.collect::<String>()
.trim_matches('-')
.to_lowercase()
}