use crate::{
app::{App, Mode},
theme,
};
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, List, ListItem, Padding, Paragraph, Wrap},
Frame,
};
pub fn draw(f: &mut Frame, app: &App) {
let area = f.area();
f.render_widget(
Block::default().style(Style::default().bg(theme::BG)),
area,
);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Length(3), Constraint::Min(8), Constraint::Length(3), ])
.split(area);
draw_header(f, chunks[0], app);
draw_tabs(f, chunks[1], app);
draw_body(f, chunks[2], app);
draw_footer(f, chunks[3], app);
match app.mode {
Mode::Prompt => draw_prompt_modal(f, area, app),
Mode::Confirm => draw_confirm_modal(f, area, app),
Mode::Help => draw_help_modal(f, area),
Mode::Output | Mode::Menu => {}
}
}
fn draw_header(f: &mut Frame, area: Rect, app: &App) {
let title = Line::from(vec![
Span::styled(" ๐ฆ ", Style::default().fg(theme::WARN)),
Span::styled(
"fp-dotfiles",
Style::default()
.fg(theme::TITLE)
.add_modifier(Modifier::BOLD),
),
Span::styled(" ยท ", theme::muted()),
Span::styled("manager tui", theme::accent()),
Span::styled(" ", theme::muted()),
Span::styled(
format!("via {}", app.cli),
theme::muted(),
),
]);
let block = Block::default()
.borders(Borders::BOTTOM)
.border_style(theme::border())
.style(Style::default().bg(theme::SURFACE));
let p = Paragraph::new(title)
.alignment(Alignment::Left)
.block(block);
f.render_widget(p, area);
}
fn draw_tabs(f: &mut Frame, area: Rect, app: &App) {
let mut spans: Vec<Span> = vec![Span::raw(" ")];
for (i, cat) in app.categories().iter().enumerate() {
let active = i == app.category_idx;
let label = format!(" {} {} ", cat.icon(), cat.label());
if active {
spans.push(Span::styled(
label,
theme::category_active().bg(theme::HIGHLIGHT_BG),
));
} else {
spans.push(Span::styled(label, theme::category_idle()));
}
if i + 1 < app.categories().len() {
spans.push(Span::styled(" โ ", theme::muted()));
}
}
let block = Block::default()
.borders(Borders::BOTTOM)
.border_style(theme::border())
.style(Style::default().bg(theme::BG));
f.render_widget(
Paragraph::new(Line::from(spans)).block(block),
area,
);
}
fn draw_body(f: &mut Frame, area: Rect, app: &App) {
if app.mode == Mode::Output {
draw_output(f, area, app);
return;
}
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(48), Constraint::Percentage(52)])
.split(area);
draw_command_list(f, cols[0], app);
draw_detail(f, cols[1], app);
}
fn draw_command_list(f: &mut Frame, area: Rect, app: &App) {
let items = app.current_items();
let list_items: Vec<ListItem> = items
.iter()
.enumerate()
.map(|(i, cmd)| {
let selected = i == app.selected;
let marker = if selected { "โธ " } else { " " };
let idx = format!("{}. ", i + 1);
let line = if selected {
Line::from(vec![
Span::styled(marker, theme::selected_marker()),
Span::styled(idx, theme::selected_marker()),
Span::styled(cmd.name, theme::selected()),
Span::styled(" ", theme::selected()),
Span::styled(cmd.summary, theme::muted().bg(theme::HIGHLIGHT_BG)),
])
} else {
Line::from(vec![
Span::styled(marker, theme::muted()),
Span::styled(idx, theme::muted()),
Span::styled(cmd.name, theme::normal()),
])
};
ListItem::new(line)
})
.collect();
let block = Block::default()
.title(Span::styled(" Commands ", theme::title()))
.borders(Borders::ALL)
.border_style(theme::border_focus())
.style(Style::default().bg(theme::SURFACE))
.padding(Padding::new(1, 1, 0, 0));
let list = List::new(list_items).block(block);
f.render_widget(list, area);
}
fn draw_detail(f: &mut Frame, area: Rect, app: &App) {
let (title, body, meta) = match app.selected_cmd() {
Some(cmd) => {
let mut flags = Vec::new();
if cmd.needs_input {
flags.push("input");
}
if cmd.needs_confirm {
flags.push("confirm");
}
if cmd.interactive {
flags.push("interactive");
}
let meta = if flags.is_empty() {
format!("$ {} {}", app.cli, cmd.args.join(" "))
} else {
format!(
"$ {} {} ยท [{}]",
app.cli,
cmd.args.join(" "),
flags.join(", ")
)
};
(cmd.name, cmd.detail, meta)
}
None => ("", "No command selected.", String::new()),
};
let block = Block::default()
.title(Span::styled(" Detail ", theme::title()))
.borders(Borders::ALL)
.border_style(theme::border())
.style(Style::default().bg(theme::SURFACE))
.padding(Padding::new(1, 1, 1, 1));
let inner = block.inner(area);
f.render_widget(block, area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(2),
Constraint::Min(3),
Constraint::Length(2),
])
.split(inner);
f.render_widget(
Paragraph::new(Line::from(Span::styled(
title,
Style::default()
.fg(theme::ACCENT)
.add_modifier(Modifier::BOLD),
))),
chunks[0],
);
f.render_widget(
Paragraph::new(body)
.style(theme::normal())
.wrap(Wrap { trim: true }),
chunks[1],
);
f.render_widget(
Paragraph::new(meta).style(theme::muted()),
chunks[2],
);
}
fn draw_output(f: &mut Frame, area: Rect, app: &App) {
let lines: Vec<Line> = app
.output
.lines()
.skip(app.output_scroll)
.map(|l| {
let style = if l.starts_with("error") || l.contains("Error") || l.contains("failed") {
theme::error()
} else if l.contains("โ") || l.contains("success") {
theme::success()
} else {
theme::normal()
};
Line::from(Span::styled(l.to_string(), style))
})
.collect();
let block = Block::default()
.title(Span::styled(
" Output ยท โโ scroll ยท Enter/Esc back ",
theme::title(),
))
.borders(Borders::ALL)
.border_style(theme::border_focus())
.style(Style::default().bg(theme::SURFACE))
.padding(Padding::new(1, 1, 0, 0));
f.render_widget(
Paragraph::new(lines)
.block(block)
.wrap(Wrap { trim: false }),
area,
);
}
fn draw_footer(f: &mut Frame, area: Rect, app: &App) {
let status_style = if app.status.starts_with('โ') {
theme::error()
} else if app.status.starts_with('โ') {
theme::success()
} else {
theme::muted()
};
let help = Line::from(vec![
Span::styled(" โโ", theme::keybind()),
Span::styled(" move ", theme::muted()),
Span::styled("โโ", theme::keybind()),
Span::styled(" tab ", theme::muted()),
Span::styled("โ", theme::keybind()),
Span::styled(" run ", theme::muted()),
Span::styled("?", theme::keybind()),
Span::styled(" help ", theme::muted()),
Span::styled("q", theme::keybind()),
Span::styled(" quit", theme::muted()),
Span::styled(" โ ", theme::border()),
Span::styled(app.status.as_str(), status_style),
]);
let block = Block::default()
.borders(Borders::TOP)
.border_style(theme::border())
.style(Style::default().bg(theme::SURFACE));
f.render_widget(Paragraph::new(help).block(block), area);
}
fn centered(area: Rect, w: u16, h: u16) -> Rect {
let x = area.x + area.width.saturating_sub(w) / 2;
let y = area.y + area.height.saturating_sub(h) / 2;
Rect::new(x, y, w.min(area.width), h.min(area.height))
}
fn draw_prompt_modal(f: &mut Frame, area: Rect, app: &App) {
let popup = centered(area, area.width.saturating_mul(70) / 100, 7);
f.render_widget(Clear, popup);
let block = Block::default()
.title(Span::styled(" Arguments ", theme::title()))
.borders(Borders::ALL)
.border_style(theme::border_focus())
.style(Style::default().bg(theme::SURFACE))
.padding(Padding::new(1, 1, 0, 0));
let inner = block.inner(popup);
f.render_widget(block, popup);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Length(1), Constraint::Length(1)])
.split(inner);
f.render_widget(
Paragraph::new(app.prompt_hint.as_str()).style(theme::muted()),
chunks[0],
);
let input_line = format!("โฏ {}_", app.input);
f.render_widget(
Paragraph::new(input_line).style(
Style::default()
.fg(theme::TEXT)
.add_modifier(Modifier::BOLD),
),
chunks[1],
);
f.render_widget(
Paragraph::new("Enter confirm ยท Esc cancel").style(theme::muted()),
chunks[2],
);
}
fn draw_confirm_modal(f: &mut Frame, area: Rect, app: &App) {
let popup = centered(area, 56, 7);
f.render_widget(Clear, popup);
let name = app
.selected_cmd()
.map(|c| c.name)
.unwrap_or("command");
let block = Block::default()
.title(Span::styled(" Confirm ", theme::warn()))
.borders(Borders::ALL)
.border_style(Style::default().fg(theme::WARN))
.style(Style::default().bg(theme::SURFACE))
.padding(Padding::new(1, 1, 1, 0));
let text = vec![
Line::from(Span::styled(
format!("Run destructive action `{name}`?"),
theme::normal(),
)),
Line::from(""),
Line::from(vec![
Span::styled(" y ", theme::keybind()),
Span::styled("yes ", theme::muted()),
Span::styled(" n ", theme::keybind()),
Span::styled("no / Esc", theme::muted()),
]),
];
f.render_widget(Paragraph::new(text).block(block), popup);
}
fn draw_help_modal(f: &mut Frame, area: Rect) {
let popup = centered(area, 64, 16);
f.render_widget(Clear, popup);
let block = Block::default()
.title(Span::styled(" Help ", theme::title()))
.borders(Borders::ALL)
.border_style(theme::border_focus())
.style(Style::default().bg(theme::SURFACE))
.padding(Padding::new(2, 2, 1, 1));
let lines = vec![
Line::from(Span::styled(
"Keyboard",
Style::default()
.fg(theme::ACCENT)
.add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(vec![
Span::styled(" โ / k ", theme::keybind()),
Span::styled("previous command", theme::normal()),
]),
Line::from(vec![
Span::styled(" โ / j ", theme::keybind()),
Span::styled("next command", theme::normal()),
]),
Line::from(vec![
Span::styled(" โโ / hl ", theme::keybind()),
Span::styled("switch category", theme::normal()),
]),
Line::from(vec![
Span::styled(" Enter ", theme::keybind()),
Span::styled("run selected command", theme::normal()),
]),
Line::from(vec![
Span::styled(" 1โ9 ", theme::keybind()),
Span::styled("jump to item", theme::normal()),
]),
Line::from(vec![
Span::styled(" ? ", theme::keybind()),
Span::styled("toggle this help", theme::normal()),
]),
Line::from(vec![
Span::styled(" q / Esc ", theme::keybind()),
Span::styled("quit (menu) or close panel", theme::normal()),
]),
Line::from(""),
Line::from(Span::styled(
"Set FP_DOTFILES_MANAGER to override the CLI path.",
theme::muted(),
)),
];
f.render_widget(Paragraph::new(lines).block(block), popup);
}