use ratatui::{
Frame,
prelude::Rect,
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Paragraph, Wrap},
};
use crate::i18n;
use crate::state::{AppState, Focus, RightPaneFocus};
use crate::theme::{KeyChord, Theme, theme};
const fn get_label_color(is_focused: bool, th: &Theme) -> ratatui::style::Color {
if is_focused { th.mauve } else { th.overlay1 }
}
fn add_keybind_entry(
spans: &mut Vec<Span<'static>>,
key_opt: Option<&KeyChord>,
key_style: Style,
text: &str,
sep_style: Style,
) {
if let Some(k) = key_opt {
spans.extend([
Span::styled(format!("[{}]", k.label()), key_style),
Span::raw(format!(" {text}")),
Span::styled(" | ", sep_style),
]);
}
}
fn add_dual_keybind_entry(
spans: &mut Vec<Span<'static>>,
up_opt: Option<&KeyChord>,
down_opt: Option<&KeyChord>,
key_style: Style,
text: &str,
sep_style: Style,
) {
if let (Some(up), Some(dn)) = (up_opt, down_opt) {
spans.extend([
Span::styled(format!("[{} / {}]", up.label(), dn.label()), key_style),
Span::raw(format!(" {text}")),
Span::styled(" | ", sep_style),
]);
}
}
fn add_multi_keybind_entry(
spans: &mut Vec<Span<'static>>,
keys: &[KeyChord],
key_style: Style,
text: &str,
sep_style: Style,
) {
if !keys.is_empty() {
let keys_str = keys
.iter()
.map(KeyChord::label)
.collect::<Vec<_>>()
.join(" / ");
spans.extend([
Span::styled(format!("[{keys_str}]"), key_style),
Span::raw(format!(" {text}")),
Span::styled(" | ", sep_style),
]);
}
}
fn build_section_header(label: String, label_color: ratatui::style::Color) -> Vec<Span<'static>> {
vec![
Span::styled(
label,
Style::default()
.fg(label_color)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
]
}
fn build_globals_section(
app: &AppState,
th: &Theme,
key_style: Style,
sep_style: Style,
) -> Vec<Span<'static>> {
let km = &app.keymap;
let mut spans = build_section_header(
format!("{} ", i18n::t(app, "app.headings.globals")),
th.overlay1,
);
add_keybind_entry(
&mut spans,
km.exit.first(),
key_style,
&i18n::t(app, "app.actions.exit"),
sep_style,
);
add_keybind_entry(
&mut spans,
km.help_overlay.first(),
key_style,
&i18n::t(app, "app.actions.help"),
sep_style,
);
add_keybind_entry(
&mut spans,
km.reload_config.first(),
key_style,
&i18n::t(app, "app.actions.reload_config"),
sep_style,
);
add_keybind_entry(
&mut spans,
km.show_pkgbuild.first(),
key_style,
&i18n::t(app, "app.actions.show_hide_pkgbuild"),
sep_style,
);
add_keybind_entry(
&mut spans,
km.comments_toggle.first(),
key_style,
&i18n::t(app, "app.actions.show_hide_comments"),
sep_style,
);
add_keybind_entry(
&mut spans,
km.run_pkgbuild_checks.first(),
key_style,
&i18n::t(app, "app.actions.run_pkgbuild_checks"),
sep_style,
);
add_keybind_entry(
&mut spans,
km.cycle_pkgbuild_sections.first(),
key_style,
&i18n::t(app, "app.actions.cycle_pkgbuild_sections"),
sep_style,
);
add_keybind_entry(
&mut spans,
km.change_sort.first(),
key_style,
&i18n::t(app, "app.actions.change_sort_mode"),
sep_style,
);
add_keybind_entry(
&mut spans,
km.search_normal_toggle.first(),
key_style,
&i18n::t(app, "app.actions.insert_mode"),
sep_style,
);
spans
}
fn build_search_section(
app: &AppState,
th: &Theme,
key_style: Style,
sep_style: Style,
) -> Vec<Span<'static>> {
let km = &app.keymap;
let search_label_color = get_label_color(matches!(app.focus, Focus::Search), th);
let mut spans = build_section_header(
format!("{} ", i18n::t(app, "app.headings.search")),
search_label_color,
);
add_dual_keybind_entry(
&mut spans,
km.search_move_up.first(),
km.search_move_down.first(),
key_style,
&i18n::t(app, "app.actions.move"),
sep_style,
);
add_dual_keybind_entry(
&mut spans,
km.search_page_up.first(),
km.search_page_down.first(),
key_style,
&i18n::t(app, "app.actions.move_page"),
sep_style,
);
let add_text = if app.installed_only_mode {
i18n::t(app, "app.actions.add_to_remove")
} else {
i18n::t(app, "app.actions.add_to_install")
};
add_keybind_entry(
&mut spans,
km.search_add.first(),
key_style,
&add_text,
sep_style,
);
if app.installed_only_mode {
spans.extend([
Span::styled("[Ctrl+Space]", key_style),
Span::raw(format!(" {}", i18n::t(app, "app.actions.add_to_downgrade"))),
Span::styled(" | ", sep_style),
]);
}
add_keybind_entry(
&mut spans,
km.search_install.first(),
key_style,
&i18n::t(app, "app.actions.install"),
sep_style,
);
if app.fuzzy_search_enabled {
add_keybind_entry(
&mut spans,
km.toggle_fuzzy.first(),
key_style,
&i18n::t(app, "app.modals.help.key_labels.toggle_normal"),
sep_style,
);
} else {
add_keybind_entry(
&mut spans,
km.toggle_fuzzy.first(),
key_style,
&i18n::t(app, "app.modals.help.key_labels.toggle_fuzzy"),
sep_style,
);
}
if app.search_normal_mode {
add_keybind_entry(
&mut spans,
km.search_normal_clear.first(),
key_style,
&i18n::t(app, "app.modals.help.key_labels.clear_input"),
sep_style,
);
} else {
add_keybind_entry(
&mut spans,
km.search_insert_clear.first(),
key_style,
&i18n::t(app, "app.modals.help.key_labels.clear_input"),
sep_style,
);
}
spans.extend([
Span::styled("[Ctrl+E]", key_style),
Span::raw(" Toggle AUR vote for selected package"),
Span::styled(" | ", sep_style),
]);
spans
}
fn build_right_pane_spans(
app: &AppState,
label: String,
label_color: ratatui::style::Color,
confirm_text: &str,
key_style: Style,
sep_style: Style,
) -> Vec<Span<'static>> {
let km = &app.keymap;
let mut spans = build_section_header(label, label_color);
add_dual_keybind_entry(
&mut spans,
km.install_move_up.first(),
km.install_move_down.first(),
key_style,
&i18n::t(app, "app.actions.move"),
sep_style,
);
add_keybind_entry(
&mut spans,
km.install_confirm.first(),
key_style,
confirm_text,
sep_style,
);
add_multi_keybind_entry(
&mut spans,
&km.install_remove,
key_style,
&i18n::t(app, "app.actions.remove_from_list"),
sep_style,
);
add_keybind_entry(
&mut spans,
km.install_clear.first(),
key_style,
&i18n::t(app, "app.actions.clear"),
sep_style,
);
add_keybind_entry(
&mut spans,
km.install_find.first(),
key_style,
&i18n::t(app, "app.details.footer.search_hint"),
sep_style,
);
add_keybind_entry(
&mut spans,
km.install_to_search.first(),
key_style,
&i18n::t(app, "app.actions.go_to_search"),
sep_style,
);
spans
}
fn build_install_section(
app: &AppState,
th: &Theme,
key_style: Style,
sep_style: Style,
) -> (
Option<Line<'static>>,
Option<(Line<'static>, Line<'static>)>,
) {
if app.installed_only_mode {
let downgrade_color = get_label_color(
matches!(app.focus, Focus::Install)
&& matches!(app.right_pane_focus, RightPaneFocus::Downgrade),
th,
);
let remove_color = get_label_color(
matches!(app.focus, Focus::Install)
&& matches!(app.right_pane_focus, RightPaneFocus::Remove),
th,
);
let d_spans = build_right_pane_spans(
app,
format!("{}:", i18n::t(app, "app.headings.downgrade")),
downgrade_color,
&i18n::t(app, "app.details.footer.confirm_downgrade"),
key_style,
sep_style,
);
let r_spans = build_right_pane_spans(
app,
format!("{}: ", i18n::t(app, "app.headings.remove")),
remove_color,
&i18n::t(app, "app.details.footer.confirm_removal"),
key_style,
sep_style,
);
(None, Some((Line::from(d_spans), Line::from(r_spans))))
} else {
let install_color = get_label_color(matches!(app.focus, Focus::Install), th);
let i_spans = build_right_pane_spans(
app,
format!("{} ", i18n::t(app, "app.headings.install")),
install_color,
&i18n::t(app, "app.details.footer.confirm_installation"),
key_style,
sep_style,
);
(Some(Line::from(i_spans)), None)
}
}
fn build_recent_section(
app: &AppState,
th: &Theme,
key_style: Style,
sep_style: Style,
) -> Vec<Span<'static>> {
let km = &app.keymap;
let recent_label_color = get_label_color(matches!(app.focus, Focus::Recent), th);
let mut spans = build_section_header(
format!("{} ", i18n::t(app, "app.headings.recent")),
recent_label_color,
);
add_dual_keybind_entry(
&mut spans,
km.recent_move_up.first(),
km.recent_move_down.first(),
key_style,
&i18n::t(app, "app.actions.move"),
sep_style,
);
add_keybind_entry(
&mut spans,
km.recent_use.first(),
key_style,
&i18n::t(app, "app.actions.add_to_search"),
sep_style,
);
add_multi_keybind_entry(
&mut spans,
&km.recent_remove,
key_style,
&i18n::t(app, "app.actions.remove_from_list"),
sep_style,
);
add_keybind_entry(
&mut spans,
km.recent_clear.first(),
key_style,
&i18n::t(app, "app.actions.clear"),
sep_style,
);
add_keybind_entry(
&mut spans,
km.recent_add.first(),
key_style,
&i18n::t(app, "app.actions.add_first_match_to_install"),
sep_style,
);
add_keybind_entry(
&mut spans,
km.recent_find.first(),
key_style,
&i18n::t(app, "app.actions.search_hint_enter_next_esc_cancel"),
sep_style,
);
add_keybind_entry(
&mut spans,
km.recent_to_search.first(),
key_style,
&i18n::t(app, "app.actions.go_to_search"),
sep_style,
);
spans
}
fn build_normal_mode_section(app: &AppState, th: &Theme, key_style: Style) -> Vec<Line<'static>> {
let km = &app.keymap;
let mut lines = Vec::new();
let label =
|v: &Vec<KeyChord>, def: &str| v.first().map_or_else(|| def.to_string(), KeyChord::label);
let toggle_label = label(&km.search_normal_toggle, "Esc");
let insert_label = label(&km.search_normal_insert, "i");
let clear_label = label(&km.search_normal_clear, "Shift+Del");
let normal_mode_label = i18n::t(app, "app.modals.help.normal_mode.label");
let insert_mode_text = i18n::t(app, "app.modals.help.normal_mode.insert_mode");
let move_text = i18n::t(app, "app.modals.help.normal_mode.move");
let page_text = i18n::t(app, "app.modals.help.normal_mode.page");
let clear_input_text = i18n::t(app, "app.modals.help.normal_mode.clear_input");
let n_spans: Vec<Span> = vec![
Span::styled(
normal_mode_label,
Style::default().fg(th.mauve).add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(format!("[{toggle_label} / {insert_label}]"), key_style),
Span::raw(insert_mode_text),
Span::styled("[j / k]", key_style),
Span::raw(move_text),
Span::styled("[Ctrl+d / Ctrl+u]", key_style),
Span::raw(page_text),
Span::styled(format!("[{clear_label}]"), key_style),
Span::raw(clear_input_text),
];
lines.push(Line::from(n_spans));
let mut second_line_spans: Vec<Span> = Vec::new();
if !km.config_menu_toggle.is_empty()
|| !km.options_menu_toggle.is_empty()
|| !km.panels_menu_toggle.is_empty()
{
let open_config_list_text = i18n::t(app, "app.modals.help.normal_mode.open_config_list");
let open_options_text = i18n::t(app, "app.modals.help.normal_mode.open_options");
let open_panels_text = i18n::t(app, "app.modals.help.normal_mode.open_panels");
if let Some(k) = km.config_menu_toggle.first() {
second_line_spans.push(Span::raw(" • "));
second_line_spans.push(Span::styled(format!("[{}]", k.label()), key_style));
second_line_spans.push(Span::raw(open_config_list_text));
}
if let Some(k) = km.options_menu_toggle.first() {
second_line_spans.push(Span::raw(" • "));
second_line_spans.push(Span::styled(format!("[{}]", k.label()), key_style));
second_line_spans.push(Span::raw(open_options_text));
}
if let Some(k) = km.panels_menu_toggle.first() {
second_line_spans.push(Span::raw(" • "));
second_line_spans.push(Span::styled(format!("[{}]", k.label()), key_style));
second_line_spans.push(Span::raw(open_panels_text));
}
}
if !app.installed_only_mode
&& (!km.search_normal_import.is_empty()
|| !km.search_normal_export.is_empty()
|| !km.search_normal_updates.is_empty())
{
let install_list_text = i18n::t(app, "app.modals.help.normal_mode.install_list");
let import_text = i18n::t(app, "app.modals.help.normal_mode.import");
let export_text = i18n::t(app, "app.modals.help.normal_mode.export");
let updates_text = i18n::t(app, "app.modals.help.normal_mode.updates");
second_line_spans.push(Span::raw(install_list_text));
if let Some(k) = km.search_normal_import.first() {
second_line_spans.push(Span::styled(format!("[{}]", k.label()), key_style));
second_line_spans.push(Span::raw(import_text));
if let Some(k2) = km.search_normal_export.first() {
second_line_spans.push(Span::raw(", "));
second_line_spans.push(Span::styled(format!("[{}]", k2.label()), key_style));
second_line_spans.push(Span::raw(export_text));
}
if let Some(k3) = km.search_normal_updates.first() {
second_line_spans.push(Span::raw(", "));
second_line_spans.push(Span::styled(format!("[{}]", k3.label()), key_style));
second_line_spans.push(Span::raw(updates_text));
}
} else if let Some(k) = km.search_normal_export.first() {
second_line_spans.push(Span::styled(format!("[{}]", k.label()), key_style));
second_line_spans.push(Span::raw(export_text));
if let Some(k2) = km.search_normal_updates.first() {
second_line_spans.push(Span::raw(", "));
second_line_spans.push(Span::styled(format!("[{}]", k2.label()), key_style));
second_line_spans.push(Span::raw(updates_text));
}
} else if let Some(k) = km.search_normal_updates.first() {
second_line_spans.push(Span::styled(format!("[{}]", k.label()), key_style));
second_line_spans.push(Span::raw(updates_text));
}
}
if !second_line_spans.is_empty() {
lines.push(Line::from(second_line_spans));
}
lines
}
pub fn render_footer(f: &mut Frame, app: &AppState, bottom_container: Rect, help_h: u16) {
let th = theme();
let footer_container = bottom_container;
if footer_container.height > help_h + 2 {
let x = footer_container.x + 1; let y_top = footer_container.y + footer_container.height.saturating_sub(help_h);
let w = footer_container.width.saturating_sub(2);
let h = help_h;
let footer_rect = Rect {
x,
y: y_top,
width: w,
height: h,
};
let key_style = Style::default()
.fg(th.text)
.bg(th.surface2)
.add_modifier(Modifier::BOLD);
let sep_style = Style::default().fg(th.overlay2);
let g_spans = build_globals_section(app, &th, key_style, sep_style);
let s_spans = build_search_section(app, &th, key_style, sep_style);
let (right_lines_install, right_lines_split) =
build_install_section(app, &th, key_style, sep_style);
let r_spans = build_recent_section(app, &th, key_style, sep_style);
let mut lines: Vec<Line<'static>> = vec![Line::from(g_spans)];
if matches!(app.focus, Focus::Search) {
lines.push(Line::from(s_spans));
}
if matches!(app.focus, Focus::Install) {
if let Some(i_line) = right_lines_install {
lines.push(i_line);
}
if let Some((d_line, rm_line)) = right_lines_split {
match app.right_pane_focus {
RightPaneFocus::Downgrade => lines.push(d_line),
RightPaneFocus::Remove => lines.push(rm_line),
RightPaneFocus::Install => {}
}
}
}
if matches!(app.focus, Focus::Recent) {
lines.push(Line::from(r_spans));
}
if matches!(app.focus, Focus::Search) && app.search_normal_mode {
lines.extend(build_normal_mode_section(app, &th, key_style));
}
let content_lines: u16 = h;
let content_y = y_top;
let content_rect = Rect {
x,
y: content_y,
width: w,
height: content_lines,
};
f.render_widget(
Block::default().style(Style::default().bg(th.base)),
footer_rect,
);
let footer = Paragraph::new(lines)
.style(Style::default().fg(th.subtext1))
.wrap(Wrap { trim: true });
f.render_widget(footer, content_rect);
}
}
fn build_news_footer_lines(app: &AppState, th: &Theme) -> Vec<Line<'static>> {
let key_style = Style::default()
.fg(th.text)
.bg(th.surface2)
.add_modifier(Modifier::BOLD);
let sep_style = Style::default().fg(th.overlay2);
let mut lines: Vec<Line<'static>> = Vec::new();
let mut global_spans: Vec<Span<'static>> = Vec::new();
global_spans.extend(build_section_header("Global".to_string(), th.overlay1));
add_keybind_entry(
&mut global_spans,
app.keymap.help_overlay.first(),
key_style,
"Help",
sep_style,
);
add_keybind_entry(
&mut global_spans,
app.keymap.exit.first(),
key_style,
"Exit",
sep_style,
);
lines.push(Line::from(global_spans));
let mut nav_menus_spans: Vec<Span<'static>> = Vec::new();
nav_menus_spans.extend(build_section_header("Nav".to_string(), th.overlay1));
add_dual_keybind_entry(
&mut nav_menus_spans,
app.keymap.search_move_up.first(),
app.keymap.search_move_down.first(),
key_style,
"Move",
sep_style,
);
add_dual_keybind_entry(
&mut nav_menus_spans,
app.keymap.search_page_up.first(),
app.keymap.search_page_down.first(),
key_style,
"Page",
sep_style,
);
nav_menus_spans.extend(build_section_header("Menus".to_string(), th.overlay1));
add_keybind_entry(
&mut nav_menus_spans,
app.keymap.options_menu_toggle.first(),
key_style,
"Options",
sep_style,
);
add_keybind_entry(
&mut nav_menus_spans,
app.keymap.panels_menu_toggle.first(),
key_style,
"Panels",
sep_style,
);
add_keybind_entry(
&mut nav_menus_spans,
app.keymap.config_menu_toggle.first(),
key_style,
"Config/Lists",
sep_style,
);
lines.push(Line::from(nav_menus_spans));
let mut news_spans: Vec<Span<'static>> = Vec::new();
news_spans.extend(build_section_header("News".to_string(), th.overlay1));
add_multi_keybind_entry(
&mut news_spans,
&app.keymap.news_mark_read_feed,
key_style,
"Mark read",
sep_style,
);
add_multi_keybind_entry(
&mut news_spans,
&app.keymap.news_mark_unread_feed,
key_style,
"Mark unread",
sep_style,
);
add_multi_keybind_entry(
&mut news_spans,
&app.keymap.news_toggle_read_feed,
key_style,
"Toggle read",
sep_style,
);
lines.push(Line::from(news_spans));
if matches!(app.focus, Focus::Search) {
if app.search_normal_mode {
lines.extend(build_normal_mode_section(app, th, key_style));
} else {
let label = |v: &Vec<KeyChord>, def: &str| {
v.first().map_or_else(|| def.to_string(), KeyChord::label)
};
let toggle_label = label(&app.keymap.search_normal_toggle, "Esc");
let clear_label = label(&app.keymap.search_insert_clear, "Shift+Del");
let fuzzy_label = label(&app.keymap.toggle_fuzzy, "Ctrl+f");
let change_sort_label = label(&app.keymap.change_sort, "Shift+Tab");
let mut insert_spans = build_section_header(
i18n::t(app, "app.modals.help.normal_mode.insert_mode"),
th.mauve,
);
insert_spans.push(Span::styled(format!("[{toggle_label}]"), key_style));
insert_spans.push(Span::raw(format!(
" {}",
i18n::t(app, "app.modals.help.key_labels.toggle_normal")
)));
insert_spans.push(Span::styled(" | ", sep_style));
insert_spans.push(Span::styled(format!("[{clear_label}]"), key_style));
insert_spans.push(Span::raw(format!(
" {}",
i18n::t(app, "app.modals.help.key_labels.clear_input")
)));
insert_spans.push(Span::styled(" | ", sep_style));
insert_spans.push(Span::styled(format!("[{fuzzy_label}]"), key_style));
insert_spans.push(Span::raw(format!(
" {}",
i18n::t(app, "app.modals.help.key_labels.toggle_fuzzy")
)));
insert_spans.push(Span::styled(" | ", sep_style));
insert_spans.push(Span::styled(format!("[{change_sort_label}]"), key_style));
insert_spans.push(Span::raw(format!(
" {}",
i18n::t(app, "app.actions.change_sort_mode")
)));
lines.push(Line::from(insert_spans));
}
}
lines
}
pub fn render_news_footer(f: &mut Frame, app: &AppState, area: Rect, footer_height: u16) {
if footer_height == 0 || area.height < footer_height {
return;
}
let th = theme();
let lines = build_news_footer_lines(app, &th);
if lines.is_empty() {
return;
}
let footer_rect = Rect {
x: area.x + 1, y: area
.y
.saturating_add(area.height.saturating_sub(footer_height)),
width: area.width.saturating_sub(2),
height: footer_height,
};
f.render_widget(
Block::default().style(Style::default().bg(th.base)),
Rect {
x: area.x,
y: footer_rect.y,
width: area.width,
height: footer_height,
},
);
let paragraph = Paragraph::new(lines)
.style(Style::default().fg(th.subtext1))
.wrap(Wrap { trim: true });
f.render_widget(paragraph, footer_rect);
}
pub fn news_footer_height(app: &AppState) -> u16 {
let th = theme();
let line_count = build_news_footer_lines(app, &th).len();
u16::try_from(line_count.max(1)).unwrap_or(1)
}