use ratatui::{
Frame,
prelude::Rect,
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Clear, Paragraph, Wrap},
};
use crate::state::modal::PreflightHeaderChips;
use crate::state::{PackageItem, PreflightAction, PreflightTab};
use crate::theme::theme;
use crate::ui::helpers::{format_bytes, format_signed_bytes};
#[allow(clippy::many_single_char_names)]
fn calculate_modal_layout(area: Rect, f: &mut Frame) -> (Rect, Rect, Vec<Rect>) {
let w = area.width.saturating_sub(4).min(110);
let h = area.height.saturating_sub(4).min(area.height);
let x = area.x + (area.width.saturating_sub(w)) / 2;
let y = area.y + (area.height.saturating_sub(h)) / 2;
let rect = Rect {
x,
y,
width: w,
height: h,
};
f.render_widget(Clear, rect);
let inner = Rect {
x: rect.x + 1,
y: rect.y + 1,
width: rect.width.saturating_sub(2),
height: rect.height.saturating_sub(2),
};
let cols = ratatui::layout::Layout::default()
.direction(ratatui::layout::Direction::Horizontal)
.constraints([
ratatui::layout::Constraint::Percentage(30),
ratatui::layout::Constraint::Percentage(70),
])
.split(inner);
(rect, inner, cols.to_vec())
}
fn render_tab_header(tab: PreflightTab) -> Line<'static> {
let th = theme();
let tab_labels = ["Summary", "Deps", "Files", "Services", "Sandbox"];
let mut header = String::new();
for (i, lbl) in tab_labels.iter().enumerate() {
let is_active = matches!(
(i, tab),
(0, PreflightTab::Summary)
| (1, PreflightTab::Deps)
| (2, PreflightTab::Files)
| (3, PreflightTab::Services)
| (4, PreflightTab::Sandbox)
);
if i > 0 {
header.push_str(" ");
}
if is_active {
header.push('[');
header.push_str(lbl);
header.push(']');
} else {
header.push_str(lbl);
}
}
Line::from(Span::styled(
header,
Style::default()
.fg(th.overlay1)
.add_modifier(Modifier::BOLD),
))
}
#[allow(dead_code)] fn format_log_footer(verbose: bool, abortable: bool) -> String {
format!(
"l: verbose={} • x: abort{} • q/Esc/Enter: close",
if verbose { "ON" } else { "OFF" },
if abortable { " (available)" } else { "" }
)
}
fn render_sidebar(
items: &[PackageItem],
tab: PreflightTab,
header_chips: &PreflightHeaderChips,
border_color: ratatui::style::Color,
bg_color: ratatui::style::Color,
) -> Paragraph<'static> {
let th = theme();
let mut s_lines = vec![
render_header_chips(header_chips),
Line::from(""),
Line::from(Span::styled(
"─────────────────────────",
Style::default().fg(th.overlay1),
)),
Line::from(""),
render_tab_header(tab),
Line::from(""),
];
if items.is_empty() {
s_lines.push(Line::from(Span::styled(
"No packages",
Style::default().fg(th.subtext1),
)));
} else {
s_lines.push(Line::from(Span::styled(
"Packages:",
Style::default()
.fg(th.subtext1)
.add_modifier(Modifier::BOLD),
)));
for p in items.iter().take(10) {
let p_name = &p.name;
s_lines.push(Line::from(Span::styled(
format!(" • {p_name}"),
Style::default().fg(th.text),
)));
}
if items.len() > 10 {
let remaining = items.len() - 10;
s_lines.push(Line::from(Span::styled(
format!(" ... and {remaining} more"),
Style::default().fg(th.subtext1),
)));
}
}
Paragraph::new(s_lines)
.style(Style::default().fg(th.text).bg(bg_color))
.wrap(Wrap { trim: true })
.block(
Block::default()
.title(Span::styled(
" Plan ",
Style::default()
.fg(border_color)
.add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color))
.style(Style::default().bg(bg_color)),
)
}
fn render_log_panel(
log_lines: &[String],
verbose: bool,
abortable: bool,
title: String,
border_color: ratatui::style::Color,
log_area_height: u16,
) -> Paragraph<'static> {
let th = theme();
let block = Block::default()
.title(Span::styled(
title,
Style::default()
.fg(border_color)
.add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_type(BorderType::Double)
.border_style(Style::default().fg(border_color))
.style(Style::default().bg(th.base));
let inner_height = log_area_height.saturating_sub(2) as usize;
let footer_reserve = usize::from(!abortable);
let max_log_lines = inner_height.saturating_sub(footer_reserve);
let start = log_lines.len().saturating_sub(max_log_lines);
let mut visible_lines: Vec<Line> = if log_lines.is_empty() {
vec![Line::from(Span::styled(
format!("Waiting for output... [v={verbose}]"),
Style::default().fg(th.subtext1),
))]
} else {
log_lines[start..]
.iter()
.map(|l| Line::from(Span::styled(l.clone(), Style::default().fg(th.text))))
.collect()
};
if !abortable && !log_lines.is_empty() {
visible_lines.push(Line::from(Span::styled(
"[Press q/Esc/Enter to close]",
Style::default().fg(th.subtext0),
)));
}
Paragraph::new(visible_lines)
.style(Style::default().fg(th.text).bg(th.base))
.block(block)
}
fn render_header_chips(chips: &PreflightHeaderChips) -> Line<'static> {
let th = theme();
let mut spans = Vec::new();
let package_count = chips.package_count;
let aur_count = chips.aur_count;
let pkg_text = if aur_count > 0 {
format!("{package_count} ({aur_count} AUR)")
} else {
format!("{package_count}")
};
spans.push(Span::styled(
format!("Packages: {pkg_text}"),
Style::default()
.fg(th.sapphire)
.add_modifier(Modifier::BOLD),
));
spans.push(Span::styled(" • ", Style::default().fg(th.overlay1)));
spans.push(Span::styled(
format!("DL: {}", format_bytes(chips.download_bytes)),
Style::default().fg(th.sapphire),
));
spans.push(Span::styled(" • ", Style::default().fg(th.overlay1)));
let delta_color = match chips.install_delta_bytes.cmp(&0) {
std::cmp::Ordering::Greater => th.green,
std::cmp::Ordering::Less => th.red,
std::cmp::Ordering::Equal => th.overlay1, };
spans.push(Span::styled(
format!("Size: {}", format_signed_bytes(chips.install_delta_bytes)),
Style::default().fg(delta_color),
));
spans.push(Span::styled(" • ", Style::default().fg(th.overlay1)));
let risk_label = match chips.risk_level {
crate::state::modal::RiskLevel::Low => "Low",
crate::state::modal::RiskLevel::Medium => "Medium",
crate::state::modal::RiskLevel::High => "High",
};
let risk_color = match chips.risk_level {
crate::state::modal::RiskLevel::Low => th.green,
crate::state::modal::RiskLevel::Medium => th.yellow,
crate::state::modal::RiskLevel::High => th.red,
};
spans.push(Span::styled(
format!("Risk: {} ({})", risk_label, chips.risk_score),
Style::default().fg(risk_color).add_modifier(Modifier::BOLD),
));
Line::from(spans)
}
#[allow(clippy::too_many_arguments)]
pub fn render_preflight_exec(
f: &mut Frame,
app: &crate::state::AppState,
area: Rect,
items: &[PackageItem],
action: PreflightAction,
tab: PreflightTab,
verbose: bool,
log_lines: &[String],
abortable: bool,
header_chips: &PreflightHeaderChips,
) {
let th = theme();
let (_rect, _inner, cols) = calculate_modal_layout(area, f);
let border_color = th.lavender;
let bg_color = th.crust;
let title = match action {
PreflightAction::Install => crate::i18n::t(app, "app.modals.preflight_exec.title_install"),
PreflightAction::Remove => crate::i18n::t(app, "app.modals.preflight_exec.title_remove"),
PreflightAction::Downgrade => {
crate::i18n::t(app, "app.modals.preflight_exec.title_downgrade")
}
};
let sidebar = render_sidebar(items, tab, header_chips, border_color, bg_color);
f.render_widget(sidebar, cols[0]);
let log_panel = render_log_panel(
log_lines,
verbose,
abortable,
title,
border_color,
cols[1].height,
);
f.render_widget(log_panel, cols[1]);
}