use ratatui::{
Frame,
layout::{Constraint, Layout, Position, Rect},
style::{Color, Modifier, Style, Stylize},
text::{Line, Span, Text},
widgets::{Block, Clear, List, ListItem, Paragraph, Wrap},
};
use crate::ui::{app::App, input::InputMode};
use textwrap::wrap;
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
let popup_layout = Layout::vertical([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
])
.split(r);
Layout::horizontal([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(popup_layout[1])[1]
}
pub fn draw_ui(frame: &mut Frame, app: &mut App) {
let vertical_root = Layout::vertical([
Constraint::Length(1), Constraint::Length(3), Constraint::Min(1), ]);
let [help_area_root, tabs_area, content_area] = vertical_root.areas(frame.area());
let (help_lines, style) = match app.input_mode {
InputMode::Normal => (
vec![
"Press ".into(),
"q".bold(),
" to quit, ".into(),
"e".bold(),
" to edit, ".into(),
"Tab".bold(),
" to switch tabs, ".into(),
"?".bold(),
" for help".into(),
],
Style::default().add_modifier(Modifier::RAPID_BLINK),
),
InputMode::Editing => (
vec![
"Press ".into(),
"Esc".bold(),
" to stop editing, ".into(),
"Enter".bold(),
" to submit".into(),
],
Style::default(),
),
};
let text = Text::from(Line::from(help_lines)).patch_style(style);
frame.render_widget(Paragraph::new(text), help_area_root);
let tab_titles = vec!["Search", "Installed", "Updates"];
let tabs = ratatui::widgets::Tabs::new(tab_titles)
.block(Block::bordered().title("Views"))
.select(match app.current_tab {
crate::ui::app::Tab::Search => 0,
crate::ui::app::Tab::Installed => 1,
crate::ui::app::Tab::Updates => 2,
})
.highlight_style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
frame.render_widget(tabs, tabs_area);
let horizontal = Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]);
let [search_area, details_area] = horizontal.areas(content_area);
let (input_area, list_area) = if let crate::ui::app::Tab::Search = app.current_tab {
let vertical_search = Layout::vertical([
Constraint::Length(3), Constraint::Min(1), ]);
let [i, l] = vertical_search.areas(search_area);
(Some(i), l)
} else {
(None, search_area)
};
let search_title = match app.current_tab {
crate::ui::app::Tab::Search => {
if app.loading {
"Search [Searching...]"
} else {
"Search"
}
}
crate::ui::app::Tab::Installed => "Installed Packages",
crate::ui::app::Tab::Updates => "Available Updates",
};
if let Some(i_area) = input_area {
let input = Paragraph::new(app.input.as_str())
.style(match app.input_mode {
InputMode::Normal => Style::default(),
InputMode::Editing => Style::default().fg(Color::Yellow),
})
.block(Block::bordered().title(search_title));
frame.render_widget(input, i_area);
}
let items: Vec<ListItem> = if app.packages.is_empty() {
app.messages
.iter()
.enumerate()
.map(|(i, m)| ListItem::new(Line::from(Span::raw(format!("{i}: {m}")))))
.collect()
} else {
app.packages
.iter()
.enumerate()
.map(|(_i, p)| {
let parts: Vec<&str> = p.name.split('/').collect();
let name = parts.last().unwrap();
let pkg_name = if name.len() > 16 {
let slice = &name[..14]; format!("{}...", slice)
} else {
name.to_string()
};
let provider = format!("{}", &p.provider);
let version = if p.version.len() > 12 {
format!("{}...", &p.version[..8])
} else {
p.version.clone()
};
let checked_symbol = if app.selected_names.contains(&p.name) {
Span::styled(
"[*]",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
)
} else {
Span::raw("[ ]")
};
let installed_indicator = if app.installed_packages.contains(&p.name) {
Span::styled("(I) ", Style::default().fg(Color::Green))
} else {
Span::raw(" ")
};
let content = Line::from(vec![
checked_symbol,
Span::raw(" "),
installed_indicator,
Span::styled(
format!("{:<28}", pkg_name),
Style::default().add_modifier(Modifier::BOLD),
),
Span::styled(
format!("{:<20}", version),
Style::default().fg(Color::Green),
),
Span::styled(provider, Style::default().fg(Color::Cyan)),
]);
ListItem::new(content)
})
.collect::<Vec<ListItem>>()
};
let list_title = format!("Packages ({})", app.manager.name());
let list = List::new(items)
.block(Block::bordered().title(list_title))
.highlight_style(Style::default().bg(Color::Blue).fg(Color::White))
.highlight_symbol("» ");
frame.render_stateful_widget(list, list_area, &mut app.list_state);
let mut details_lines: Vec<Line> = Vec::new();
match &app.details_state {
crate::ui::app::DetailsState::Empty => {
details_lines.push(Line::from("No package selected"));
}
crate::ui::app::DetailsState::Loading => {
details_lines.push(Line::from("Loading details..."));
}
crate::ui::app::DetailsState::Error(err) => {
details_lines.push(Line::from(vec![
Span::styled("Error: ", Style::default().fg(Color::Red)),
Span::raw(err),
]));
}
crate::ui::app::DetailsState::Success(info) => {
let mut sorted: Vec<_> = info.iter().collect();
sorted.sort_by_key(|(k, _)| *k);
let key_width = 15;
for (key, value) in sorted {
let key_text = format!("{:<key_width$}: ", key, key_width = key_width);
let indent = " ".repeat(key_text.len());
let value_wrapped = wrap(value, 80 - key_text.len());
if let Some(first) = value_wrapped.get(0) {
details_lines.push(Line::from(vec![
Span::styled(
key_text.clone(),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw(first.to_string()),
]));
}
for line in value_wrapped.iter().skip(1) {
details_lines.push(Line::from(format!("{}{}", indent, line)));
}
}
}
}
frame.render_widget(
Paragraph::new(details_lines)
.wrap(Wrap { trim: false })
.block(Block::bordered().title("Details")),
details_area,
);
if let InputMode::Editing = app.input_mode {
if let Some(i_area) = input_area {
frame.set_cursor_position(Position::new(
i_area.x + app.character_index as u16 + 1,
i_area.y + 1,
));
}
}
if app.show_help {
let area = centered_rect(60, 40, frame.area());
frame.render_widget(Clear, area); let help_text = vec![
Line::from(vec![Span::styled(
"Keybindings",
Style::default().add_modifier(Modifier::BOLD),
)]),
Line::from(""),
Line::from(vec![
Span::styled("q", Style::default().fg(Color::Yellow)),
Span::raw(": Quit"),
]),
Line::from(vec![
Span::styled("e", Style::default().fg(Color::Yellow)),
Span::raw(": Edit Search (Search tab only)"),
]),
Line::from(vec![
Span::styled("Esc", Style::default().fg(Color::Yellow)),
Span::raw(": Normal Mode"),
]),
Line::from(vec![
Span::styled("Tab", Style::default().fg(Color::Yellow)),
Span::raw(": Switch Tabs"),
]),
Line::from(vec![
Span::styled("Space", Style::default().fg(Color::Yellow)),
Span::raw(": Select/Unselect"),
]),
Line::from(vec![
Span::styled("i", Style::default().fg(Color::Yellow)),
Span::raw(": Install Selected"),
]),
Line::from(vec![
Span::styled("x", Style::default().fg(Color::Yellow)),
Span::raw(": Remove Selected"),
]),
Line::from(vec![
Span::styled("U", Style::default().fg(Color::Yellow)),
Span::raw(": Full System Upgrade"),
]),
Line::from(vec![
Span::styled("R", Style::default().fg(Color::Yellow)),
Span::raw(": Refresh Databases"),
]),
Line::from(vec![
Span::styled("j / Down", Style::default().fg(Color::Yellow)),
Span::raw(": Move Down"),
]),
Line::from(vec![
Span::styled("k / Up", Style::default().fg(Color::Yellow)),
Span::raw(": Move Up"),
]),
Line::from(vec![
Span::styled("?", Style::default().fg(Color::Yellow)),
Span::raw(": Toggle Help"),
]),
];
frame.render_widget(
Paragraph::new(help_text)
.block(Block::bordered().title("Help"))
.wrap(Wrap { trim: true }),
area,
);
}
}