use crate::tui::GroupSortMode;
use crate::tui::{App, InputMode};
use ratatui::{
Frame,
layout::{Alignment, Constraint, Direction, Layout},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, Paragraph, Wrap},
};
pub fn render(f: &mut Frame, app: &mut App) {
let main_chunks = Layout::default()
.direction(Direction::Vertical)
.margin(1)
.constraints(
[
Constraint::Length(3),
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(3),
]
.as_ref(),
)
.split(f.area());
let installed_groups = app
.groups
.iter()
.filter(|g| !g.packages.is_empty() && g.packages.iter().all(|p| p.1))
.count();
let partial_groups = app
.groups
.iter()
.filter(|g| {
let inst = g.packages.iter().filter(|p| p.1).count();
inst > 0 && inst < g.packages.len()
})
.count();
let total_pkgs_in_groups: usize = app.groups.iter().map(|g| g.packages.len()).sum();
let stats_line = Line::from(vec![
Span::styled(
format!(" groups installed : {} ", installed_groups + partial_groups),
Style::default().fg(Color::Green),
),
Span::styled(" | ", Style::default().fg(Color::DarkGray)),
Span::styled(
format!(" packages in groups : {} ", total_pkgs_in_groups),
Style::default().fg(Color::Cyan),
),
Span::styled(" | ", Style::default().fg(Color::DarkGray)),
Span::styled(
format!(" fully installed : {} ", installed_groups),
Style::default().fg(Color::Green),
),
Span::styled(" | ", Style::default().fg(Color::DarkGray)),
Span::styled(
format!(" partial : {} ", partial_groups),
Style::default().fg(Color::Yellow),
),
]);
f.render_widget(
Paragraph::new(stats_line)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray)),
)
.alignment(Alignment::Center),
main_chunks[0],
);
let (border_color, cursor) = match app.group_input_mode {
InputMode::Editing => (Color::Cyan, "█"),
InputMode::Normal => (Color::DarkGray, ""),
};
let search_display = format!(" search (/): {}{} ", app.group_search_query, cursor);
let sort_str = match app.group_sort_mode {
GroupSortMode::Alphabetical => "alphabetical",
GroupSortMode::PackageCount => "package count",
GroupSortMode::InstallCompletion => "completion",
};
f.render_widget(
Paragraph::new(search_display)
.style(Style::default().fg(border_color))
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color))
.title_bottom(format!(" [sort: {} (S)] ", sort_str))
.title_alignment(Alignment::Right),
),
main_chunks[1],
);
let split = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(35), Constraint::Percentage(65)].as_ref())
.split(main_chunks[2]);
if app.filtered_groups.is_empty() {
let empty_msg = vec![
Line::from(""),
Line::from(Span::styled(
" /\\_/\\ ",
Style::default().fg(Color::Magenta),
)),
Line::from(Span::styled(
" ( •.• )",
Style::default().fg(Color::Magenta),
)),
Line::from(Span::styled(
" > 📦 < ",
Style::default().fg(Color::Magenta),
)),
Line::from(""),
Line::from(Span::styled(
"no matching package groups.",
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled(
"press esc to clear search.",
Style::default().fg(Color::DarkGray),
)),
];
f.render_widget(
Paragraph::new(empty_msg)
.block(Block::default().borders(Borders::ALL).title(" groups "))
.alignment(Alignment::Center),
split[0],
);
f.render_widget(
Paragraph::new("").block(
Block::default()
.borders(Borders::ALL)
.title(" group information "),
),
split[1],
);
} else {
let items: Vec<ListItem> = app
.filtered_groups
.iter()
.map(|group| {
let total = group.packages.len();
let installed = group.packages.iter().filter(|p| p.1).count();
let status_color = if installed == total && total > 0 {
Color::Green
} else if installed > 0 {
Color::Yellow
} else {
Color::Red
};
let fav_icon = if group.is_favorite { "★ " } else { "📦 " };
ListItem::new(Line::from(vec![
Span::styled(
fav_icon,
Style::default().fg(if group.is_favorite {
Color::Yellow
} else {
Color::Cyan
}),
),
Span::styled(
format!("{:<15}", group.name),
Style::default()
.fg(status_color)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!("{:>5}", total),
Style::default().fg(Color::DarkGray),
),
]))
})
.collect();
let list = List::new(items)
.block(
Block::default()
.borders(Borders::ALL)
.title(format!(" groups ({}) ", app.filtered_groups.len())),
)
.highlight_style(
Style::default()
.bg(Color::Cyan)
.fg(Color::Black)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol(">> ");
f.render_stateful_widget(list, split[0], &mut app.group_state);
if let Some(idx) = app.group_state.selected() {
if let Some(group) = app.filtered_groups.get(idx) {
let total = group.packages.len();
let installed = group.packages.iter().filter(|p| p.1).count();
let pct = if total > 0 {
(installed as f64 / total as f64) * 100.0
} else {
0.0
};
let mut details = vec![
Line::from(vec![
Span::styled(
group.name.clone(),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::styled(
if group.is_favorite {
" ★ favorite"
} else {
""
},
Style::default().fg(Color::Yellow),
),
]),
Line::from(""),
Line::from(Span::styled(
"statistics",
Style::default()
.fg(Color::LightMagenta)
.add_modifier(Modifier::BOLD),
)),
Line::from(format!(" packages : {}", total)),
Line::from(format!(" installed : {} / {}", installed, total)),
Line::from(format!(" completion : {:.0}%", pct)),
Line::from(format!(" progress : {}", draw_bar(pct, 25))),
Line::from(""),
Line::from(Span::styled(
"description",
Style::default()
.fg(Color::LightMagenta)
.add_modifier(Modifier::BOLD),
)),
Line::from(format!(" {}", group.description)),
Line::from(""),
Line::from(Span::styled(
"package preview (first 20)",
Style::default()
.fg(Color::LightMagenta)
.add_modifier(Modifier::BOLD),
)),
];
for (pkg, is_inst) in group.packages.iter().take(20) {
let icon = if *is_inst { "✓ " } else { "○ " };
let color = if *is_inst {
Color::Green
} else {
Color::DarkGray
};
details.push(Line::from(vec![
Span::styled(format!(" {}", icon), Style::default().fg(color)),
Span::styled(pkg.clone(), Style::default().fg(color)),
]));
}
if group.packages.len() > 20 {
details.push(Line::from(Span::styled(
format!(" ... and {} more packages", group.packages.len() - 20),
Style::default().fg(Color::DarkGray),
)));
}
let info_block = Paragraph::new(details)
.block(
Block::default()
.borders(Borders::ALL)
.title(" group information "),
)
.wrap(Wrap { trim: true });
f.render_widget(info_block, split[1]);
}
}
}
let footer = Paragraph::new(Span::styled(
" j/k move • enter browse • / search • i install • r remove • space favorite • esc back ",
Style::default().fg(Color::DarkGray),
))
.alignment(Alignment::Center);
f.render_widget(footer, main_chunks[3]);
}
fn draw_bar(pct: f64, width: usize) -> String {
let filled = ((pct / 100.0) * width as f64).round() as usize;
let filled = filled.min(width);
let empty = width.saturating_sub(filled);
format!("{}{}", "█".repeat(filled), "░".repeat(empty))
}