use crossterm::event::KeyCode;
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Cell, Clear, Paragraph, Row, Table, TableState, Wrap},
Frame,
};
use crate::apt::parser::{ChangeKind, PackageChange};
use crate::apt::query::PackageInfo;
pub struct PackageTable {
pub packages: Vec<PackageInfo>,
pub changes: Vec<PackageChange>,
pub state: TableState,
pub mode: PackageTableMode,
pub detail_pkg: Option<PackageInfo>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum PackageTableMode {
Browse,
UpgradeDiff,
}
impl PackageTable {
pub fn new(packages: Vec<PackageInfo>, changes: Vec<PackageChange>) -> Self {
let mode = if changes.is_empty() {
PackageTableMode::Browse
} else {
PackageTableMode::UpgradeDiff
};
let mut state = TableState::default();
if !packages.is_empty() || !changes.is_empty() {
state.select(Some(0));
}
Self { packages, changes, state, mode, detail_pkg: None }
}
pub fn handle_key(&mut self, code: KeyCode) {
match code {
KeyCode::Char('j') | KeyCode::Down => self.next(1),
KeyCode::Char('k') | KeyCode::Up => self.prev(1),
KeyCode::PageDown => self.next(10),
KeyCode::PageUp => self.prev(10),
_ => {}
}
}
fn next(&mut self, step: usize) {
let len = match self.mode {
PackageTableMode::Browse => self.packages.len(),
PackageTableMode::UpgradeDiff => self.changes.len(),
};
if len == 0 { return; }
let i = self.state.selected().map_or(0, |i| (i + step).min(len - 1));
self.state.select(Some(i));
}
fn prev(&mut self, step: usize) {
let i = self.state.selected().map_or(0, |i| i.saturating_sub(step));
self.state.select(Some(i));
}
pub fn render(&self, f: &mut Frame, area: Rect) {
match self.mode {
PackageTableMode::Browse => self.render_browse(f, area),
PackageTableMode::UpgradeDiff => self.render_diff(f, area),
}
if self.detail_pkg.is_some() {
self.render_detail(f, area);
}
}
fn render_browse(&self, f: &mut Frame, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(0), Constraint::Length(3)])
.split(area);
let rows: Vec<Row> = self
.packages
.iter()
.map(|p| {
let installed = p.installed_version.as_deref().unwrap_or("—");
let candidate = p.candidate_version.as_deref().unwrap_or("—");
let style = if p.installed_version.is_some() && p.candidate_version.is_some() {
Style::default().fg(Color::Yellow)
} else if p.installed_version.is_some() {
Style::default().fg(Color::Green)
} else {
Style::default()
};
Row::new(vec![
Cell::from(p.name.clone()),
Cell::from(installed),
Cell::from(candidate),
Cell::from(p.description.as_deref().unwrap_or("").chars().take(60).collect::<String>()),
])
.style(style)
})
.collect();
let widths = [
Constraint::Length(30),
Constraint::Length(20),
Constraint::Length(20),
Constraint::Min(20),
];
let table = Table::new(rows, widths)
.header(
Row::new(vec!["Package", "Installed", "Available", "Description"])
.style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
)
.block(Block::default().borders(Borders::ALL).title(" Packages "))
.row_highlight_style(Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD))
.highlight_symbol("â–¶ ");
let mut state = self.state.clone();
f.render_stateful_widget(table, chunks[0], &mut state);
let actions = Paragraph::new(" [Enter] details [i] install [r] remove [?] help")
.block(Block::default().borders(Borders::ALL).title(" Actions "));
f.render_widget(actions, chunks[1]);
}
fn render_diff(&self, f: &mut Frame, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(5),
Constraint::Length(3),
Constraint::Length(3),
])
.split(area);
let rows: Vec<Row> = self
.changes
.iter()
.filter(|c| c.kind != ChangeKind::Configure)
.map(|c| {
let (action, style) = match c.kind {
ChangeKind::Install => ("install", Style::default().fg(Color::Green)),
ChangeKind::Remove => ("remove", Style::default().fg(Color::Red)),
ChangeKind::Upgrade => ("upgrade", Style::default().fg(Color::Yellow)),
ChangeKind::Configure => ("configure", Style::default().fg(Color::DarkGray)),
};
let old = c.old_version.as_deref().unwrap_or("(new)");
let new = c.new_version.as_deref().unwrap_or("—");
Row::new(vec![
Cell::from(action),
Cell::from(c.name.clone()),
Cell::from(old),
Cell::from(new),
])
.style(style)
})
.collect();
let widths = [
Constraint::Length(10),
Constraint::Length(30),
Constraint::Length(20),
Constraint::Length(20),
];
let table = Table::new(rows, widths)
.header(
Row::new(vec!["Action", "Package", "Old Version", "New Version"])
.style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
)
.block(Block::default().borders(Borders::ALL).title(" Pending Changes "))
.row_highlight_style(Style::default().bg(Color::DarkGray));
let mut state = self.state.clone();
f.render_stateful_widget(table, chunks[0], &mut state);
let installs = self.changes.iter().filter(|c| c.kind == ChangeKind::Install).count();
let removes = self.changes.iter().filter(|c| c.kind == ChangeKind::Remove).count();
let upgrades = self.changes.iter().filter(|c| c.kind == ChangeKind::Upgrade).count();
let summary = format!(
" {} to install {} to remove {} to upgrade",
installs, removes, upgrades
);
let summary_widget = Paragraph::new(summary)
.block(Block::default().borders(Borders::ALL).title(" Summary "));
f.render_widget(summary_widget, chunks[1]);
let actions = Paragraph::new(" [Y] Proceed [n] Abort [?] Help")
.block(Block::default().borders(Borders::ALL).title(" Actions "));
f.render_widget(actions, chunks[2]);
}
fn render_detail(&self, f: &mut Frame, area: Rect) {
let pkg = match &self.detail_pkg {
Some(p) => p,
None => return,
};
let popup = centered_popup(area, 72, 75);
f.render_widget(Clear, popup);
let cyan = Style::default().fg(Color::Cyan);
let bold = Style::default().add_modifier(Modifier::BOLD);
let mut lines: Vec<Line> = vec![
Line::from(vec![
Span::styled("Package: ", cyan),
Span::styled(pkg.name.clone(), bold),
]),
Line::from(vec![
Span::styled("Installed: ", cyan),
Span::raw(pkg.installed_version.as_deref().unwrap_or("(not installed)")),
]),
Line::from(vec![
Span::styled("Available: ", cyan),
Span::raw(pkg.candidate_version.as_deref().unwrap_or("(not available)")),
]),
];
if let Some(section) = &pkg.section {
lines.push(Line::from(vec![
Span::styled("Section: ", cyan),
Span::raw(section.clone()),
]));
}
if let Some(kb) = pkg.installed_size_kb {
let display = if kb >= 1024 {
format!("{:.1} MB installed", kb as f64 / 1024.0)
} else {
format!("{} kB installed", kb)
};
lines.push(Line::from(vec![Span::styled("Size: ", cyan), Span::raw(display)]));
}
if let Some(homepage) = &pkg.homepage {
lines.push(Line::from(vec![
Span::styled("Homepage: ", cyan),
Span::raw(homepage.clone()),
]));
}
if !pkg.depends.is_empty() {
let shown: Vec<&str> = pkg.depends.iter().take(4).map(|s| s.as_str()).collect();
let dep_str = if pkg.depends.len() > 4 {
format!("{} (+{})", shown.join(", "), pkg.depends.len() - 4)
} else {
shown.join(", ")
};
lines.push(Line::from(vec![Span::styled("Depends: ", cyan), Span::raw(dep_str)]));
}
if let Some(desc) = &pkg.description {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled("Description:", cyan)));
for dl in desc.lines() {
if dl.is_empty() {
lines.push(Line::from(""));
} else {
lines.push(Line::from(format!(" {}", dl)));
}
}
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" Enter or Esc to close",
Style::default().fg(Color::DarkGray),
)));
let block = Block::default()
.borders(Borders::ALL)
.title(format!(" {} ", pkg.name))
.style(Style::default().bg(Color::Black));
let para = Paragraph::new(lines)
.block(block)
.wrap(Wrap { trim: false });
f.render_widget(para, popup);
}
}
fn centered_popup(area: Rect, pct_x: u16, pct_y: u16) -> Rect {
let w = area.width * pct_x / 100;
let h = area.height * pct_y / 100;
Rect {
x: area.x + (area.width - w) / 2,
y: area.y + (area.height - h) / 2,
width: w,
height: h,
}
}