use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Cell, Clear, List, ListItem, Paragraph, Row, Table, Wrap};
use super::actions;
use super::app::{ListMode, ListState as TuiListState, PickerState};
use prj_core::project::Project;
pub fn render_picker(f: &mut Frame, state: &PickerState, projects: &[Project]) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Min(1), Constraint::Length(1), ])
.split(f.area());
let input = Paragraph::new(Line::from(vec![
Span::styled("> ", Style::default().fg(Color::Cyan)),
Span::raw(&state.query),
]))
.block(Block::default().borders(Borders::ALL).title("Search"));
f.render_widget(input, chunks[0]);
let cursor_x = chunks[0].x + 2 + state.query.len() as u16;
let cursor_y = chunks[0].y + 1;
f.set_cursor_position((cursor_x, cursor_y));
let items: Vec<ListItem> = state
.filtered
.iter()
.enumerate()
.map(|(i, fm)| {
let project = &projects[fm.index];
let style = if i == state.selected {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let prefix = if i == state.selected { "> " } else { " " };
let mut spans = vec![
Span::styled(prefix, style),
Span::styled(&project.name, style),
Span::styled(
format!(" {}", project.path.display()),
Style::default().fg(Color::DarkGray),
),
];
if !project.tags.is_empty() {
spans.push(Span::styled(
format!(" [{}]", project.tags.join(", ")),
Style::default().fg(Color::Magenta),
));
}
ListItem::new(Line::from(spans))
})
.collect();
let list = List::new(items).block(Block::default().borders(Borders::ALL).title("Projects"));
f.render_widget(list, chunks[1]);
let status = Paragraph::new(Line::from(vec![
Span::styled(
format!(" {}/{} ", state.filtered.len(), projects.len()),
Style::default().fg(Color::Cyan),
),
Span::styled(
" | ESC: cancel | Enter: select",
Style::default().fg(Color::DarkGray),
),
]));
f.render_widget(status, chunks[2]);
}
pub fn render_list(f: &mut Frame, state: &TuiListState, projects: &[Project]) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(1), Constraint::Length(1), ])
.split(f.area());
let rows: Vec<Row> = projects
.iter()
.enumerate()
.map(|(i, p)| {
let vcs = p
.vcs
.iter()
.map(|v| v.to_string())
.collect::<Vec<_>>()
.join(",");
let bs = p
.build_systems
.iter()
.map(|b| b.to_string())
.collect::<Vec<_>>()
.join(",");
let tags = if p.tags.is_empty() {
"-".to_string()
} else {
p.tags.join(", ")
};
let status_str =
if let Some(git_status) = &state.git_statuses.get(i).and_then(|s| s.as_ref()) {
if git_status.is_dirty {
"dirty"
} else {
"clean"
}
} else if p.vcs.is_empty() {
"-"
} else {
"..."
};
let style = if i == state.selected {
Style::default().add_modifier(Modifier::REVERSED)
} else {
Style::default()
};
Row::new(vec![
Cell::from(p.name.clone()),
Cell::from(format!("{}", p.path.display())),
Cell::from(if vcs.is_empty() { "-".to_string() } else { vcs }),
Cell::from(if bs.is_empty() { "-".to_string() } else { bs }),
Cell::from(tags),
Cell::from(status_str.to_string()),
])
.style(style)
})
.collect();
let table = Table::new(
rows,
[
Constraint::Percentage(15),
Constraint::Percentage(30),
Constraint::Percentage(8),
Constraint::Percentage(12),
Constraint::Percentage(15),
Constraint::Percentage(10),
],
)
.header(
Row::new(vec!["Name", "Path", "VCS", "Build", "Tags", "Status"]).style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
)
.block(Block::default().borders(Borders::ALL).title("Projects"));
let is_overlay = !matches!(state.mode, ListMode::Browsing);
if is_overlay {
let dimmed_table = table.style(Style::default().fg(Color::DarkGray));
f.render_widget(dimmed_table, chunks[0]);
} else {
f.render_widget(table, chunks[0]);
}
let status_text = if let Some(msg) = &state.message {
msg.clone()
} else {
String::new()
};
let status = Paragraph::new(Line::from(vec![
Span::styled(
format!(" {} projects ", projects.len()),
Style::default().fg(Color::Cyan),
),
Span::styled(
" | q: quit | j/k: navigate | Enter: actions",
Style::default().fg(Color::DarkGray),
),
if !status_text.is_empty() {
Span::styled(
format!(" {status_text}"),
Style::default().fg(Color::Yellow),
)
} else {
Span::raw("")
},
]));
f.render_widget(status, chunks[1]);
match &state.mode {
ListMode::Browsing => {}
ListMode::ActionMenu { menu_selected } => {
if let Some(project) = projects.get(state.selected) {
render_action_menu(f, project, *menu_selected);
}
}
ListMode::ViewingStats { stats } => {
render_stats_view(f, stats);
}
ListMode::Confirming { action, .. } => {
render_confirm_dialog(f, action);
}
ListMode::CleanResult { message } => {
render_message_popup(f, "Result", message);
}
}
}
fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
let x = area.x + area.width.saturating_sub(width) / 2;
let y = area.y + area.height.saturating_sub(height) / 2;
Rect::new(x, y, width.min(area.width), height.min(area.height))
}
fn render_action_menu(f: &mut Frame, project: &Project, menu_selected: usize) {
let items = actions::menu_items(project);
let height = items.len() as u16 + 4; let width = 35;
let area = centered_rect(width, height, f.area());
f.render_widget(Clear, area);
let title = format!(" Actions: {} ", project.name);
let block = Block::default().borders(Borders::ALL).title(title);
let inner = block.inner(area);
f.render_widget(block, area);
let menu_items: Vec<ListItem> = items
.iter()
.enumerate()
.map(|(i, item)| {
let style = if i == menu_selected {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let prefix = if i == menu_selected { "> " } else { " " };
ListItem::new(Span::styled(format!("{prefix}{}", item.label), style))
})
.collect();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(1), Constraint::Length(1)])
.split(inner);
let list = List::new(menu_items);
f.render_widget(list, chunks[0]);
let footer = Paragraph::new(Span::styled(
" Enter: select Esc: cancel",
Style::default().fg(Color::DarkGray),
));
f.render_widget(footer, chunks[1]);
}
fn render_stats_view(f: &mut Frame, stats: &prj_core::stats::ProjectStats) {
let width = 60u16.min(f.area().width.saturating_sub(4));
let height = f.area().height.saturating_sub(4);
let area = centered_rect(width, height, f.area());
f.render_widget(Clear, area);
let title = format!(" Stats: {} ", stats.name);
let block = Block::default().borders(Borders::ALL).title(title);
let inner = block.inner(area);
f.render_widget(block, area);
let mut lines = Vec::new();
if let Some(git) = &stats.git {
let branch = git.branch.as_deref().unwrap_or("(detached)");
let status = if git.is_dirty { "dirty" } else { "clean" };
lines.push(Line::from(vec![
Span::styled("Git: ", Style::default().fg(Color::Cyan)),
Span::raw(format!("{branch} ({status})")),
]));
if git.is_dirty {
lines.push(Line::from(format!(
" changed: {}, staged: {}, untracked: {}",
git.changed, git.staged, git.untracked
)));
}
if git.ahead > 0 || git.behind > 0 {
lines.push(Line::from(format!(
" ahead: {}, behind: {}",
git.ahead, git.behind
)));
}
lines.push(Line::from(""));
}
lines.push(Line::from(vec![
Span::styled("Lines of Code: ", Style::default().fg(Color::Cyan)),
Span::raw(format!("{}", stats.loc.total_code)),
]));
for (lang, ls) in &stats.loc.languages {
lines.push(Line::from(format!(
" {lang}: {} code, {} comments, {} blanks ({} files)",
ls.code, ls.comments, ls.blanks, ls.files
)));
}
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled("Disk: ", Style::default().fg(Color::Cyan)),
Span::raw(format!(
"{} total, {} artifacts",
stats.disk.total_display(),
stats.disk.artifact_display()
)),
]));
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"Press Esc/Enter/q to close",
Style::default().fg(Color::DarkGray),
)));
let paragraph = Paragraph::new(lines).wrap(Wrap { trim: false });
f.render_widget(paragraph, inner);
}
fn render_confirm_dialog(f: &mut Frame, action: &str) {
let width = 40;
let height = 5;
let area = centered_rect(width, height, f.area());
f.render_widget(Clear, area);
let block = Block::default()
.borders(Borders::ALL)
.title(format!(" {action} "));
let inner = block.inner(area);
f.render_widget(block, area);
let text = Paragraph::new(vec![
Line::from("Are you sure?"),
Line::from(""),
Line::from(Span::styled(
"y: yes n/Esc: cancel",
Style::default().fg(Color::DarkGray),
)),
]);
f.render_widget(text, inner);
}
fn render_message_popup(f: &mut Frame, title: &str, message: &str) {
let width = 50u16.min(f.area().width.saturating_sub(4));
let height = 5;
let area = centered_rect(width, height, f.area());
f.render_widget(Clear, area);
let block = Block::default()
.borders(Borders::ALL)
.title(format!(" {title} "));
let inner = block.inner(area);
f.render_widget(block, area);
let text = Paragraph::new(vec![
Line::from(message.to_string()),
Line::from(""),
Line::from(Span::styled(
"Press Enter/Esc to close",
Style::default().fg(Color::DarkGray),
)),
]);
f.render_widget(text, inner);
}