use ratatui::{
Frame,
prelude::Rect,
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Clear, Paragraph, Wrap},
};
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use crate::i18n;
use crate::state::AppState;
use crate::theme::theme;
fn calculate_menu_dimensions(
opts: &[String],
results_area: Rect,
extra_width: u16,
) -> (u16, u16, u16) {
let widest = opts
.iter()
.map(|s| u16::try_from(s.width()).map_or(u16::MAX, |x| x))
.max()
.unwrap_or(0);
let max_num_width = u16::try_from(format!("{}", opts.len()).len()).unwrap_or(u16::MAX);
let w = widest
.saturating_add(max_num_width)
.saturating_add(2) .saturating_add(extra_width)
.min(results_area.width.saturating_sub(2));
let h = u16::try_from(opts.len())
.unwrap_or(u16::MAX)
.saturating_add(2); (w, h, max_num_width)
}
fn calculate_menu_rect(
button_rect: Option<(u16, u16, u16, u16)>,
menu_width: u16,
menu_height: u16,
results_area: Rect,
) -> (Rect, (u16, u16, u16, u16)) {
let rect_w = menu_width.saturating_add(2);
let max_x = results_area.x + results_area.width.saturating_sub(rect_w);
let button_x = button_rect.map_or(max_x, |(x, _, _, _)| x);
let menu_x = button_x.min(max_x);
let menu_y = button_rect.map_or_else(
|| results_area.y.saturating_add(1),
|(_, by, _, bh)| by.saturating_add(bh),
);
let rect = Rect {
x: menu_x,
y: menu_y,
width: rect_w,
height: menu_height,
};
let inner_rect = (
rect.x + 1,
rect.y + 1,
menu_width,
menu_height.saturating_sub(2),
);
(rect, inner_rect)
}
fn build_numbered_menu_lines(
opts: &[String],
widest: u16,
max_num_width: u16,
total_line_width: u16,
spacing: u16,
th: crate::theme::Theme,
) -> Vec<Line<'static>> {
let num_start_col = widest + spacing;
let mut lines: Vec<Line> = Vec::new();
for (i, text) in opts.iter().enumerate() {
let num_str = format!("{}", i + 1);
let num_width = u16::try_from(num_str.len()).unwrap_or(u16::MAX);
let num_padding = max_num_width.saturating_sub(num_width);
let padded_num = format!("{}{}", " ".repeat(num_padding as usize), num_str);
let text_display_width = u16::try_from(text.width()).unwrap_or(u16::MAX);
let text_padding = widest.saturating_sub(text_display_width);
let mut complete_line = format!(
"{}{}{}{}",
text,
" ".repeat(text_padding as usize),
" ".repeat(spacing as usize),
padded_num
);
let current_width = u16::try_from(complete_line.width()).unwrap_or(u16::MAX);
if current_width < total_line_width {
complete_line.push_str(&" ".repeat((total_line_width - current_width) as usize));
} else if current_width > total_line_width {
let mut truncated = String::new();
let mut width_so_far = 0u16;
for ch in complete_line.chars() {
let ch_width = u16::try_from(ch.width().unwrap_or(0)).unwrap_or(u16::MAX);
if width_so_far + ch_width > total_line_width {
break;
}
truncated.push(ch);
width_so_far += ch_width;
}
complete_line = truncated;
}
let mut text_part = String::new();
let mut width_so_far = 0u16;
for ch in complete_line.chars() {
let ch_width = u16::try_from(ch.width().unwrap_or(0)).unwrap_or(u16::MAX);
if width_so_far + ch_width > num_start_col {
break;
}
text_part.push(ch);
width_so_far += ch_width;
}
let num_part = complete_line
.chars()
.skip(text_part.chars().count())
.collect::<String>();
lines.push(Line::from(vec![
Span::styled(text_part, Style::default().fg(th.text)),
Span::styled(num_part, Style::default().fg(th.overlay1)),
]));
}
lines
}
fn build_checkbox_menu_lines(
opts: &[(String, bool)],
menu_width: u16,
th: crate::theme::Theme,
) -> Vec<Line<'static>> {
let mut lines: Vec<Line> = Vec::new();
for (text, enabled) in opts {
let indicator = if *enabled { "✓ " } else { " " };
let text_w = u16::try_from(text.width()).unwrap_or(u16::MAX);
let indicator_w = u16::try_from(indicator.width()).unwrap_or(u16::MAX);
let pad = menu_width
.saturating_sub(text_w)
.saturating_sub(indicator_w);
let padding = " ".repeat(pad as usize);
lines.push(Line::from(vec![
Span::styled(
indicator.to_string(),
Style::default().fg(if *enabled { th.green } else { th.overlay1 }),
),
Span::styled(text.clone(), Style::default().fg(th.text)),
Span::raw(padding),
]));
}
lines
}
fn create_menu_block(
lines: Vec<Line<'static>>,
title_first_letter_key: &str,
title_suffix_key: &str,
app: &AppState,
th: crate::theme::Theme,
) -> Paragraph<'static> {
let first_letter = i18n::t(app, title_first_letter_key);
let suffix = i18n::t(app, title_suffix_key);
Paragraph::new(lines)
.style(Style::default().fg(th.text).bg(th.base))
.wrap(Wrap { trim: false })
.block(
Block::default()
.style(Style::default().bg(th.base))
.title(Line::from(vec![
Span::styled(" ", Style::default().fg(th.overlay1)),
Span::styled(
first_letter,
Style::default()
.fg(th.overlay1)
.add_modifier(Modifier::UNDERLINED),
),
Span::styled(suffix, Style::default().fg(th.overlay1)),
]))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(th.mauve)),
)
}
fn render_config_menu(
f: &mut Frame,
app: &mut AppState,
results_area: Rect,
th: crate::theme::Theme,
) {
app.config_menu_rect = None;
if !app.config_menu_open {
return;
}
let opts: Vec<String> = vec![
i18n::t(app, "app.results.config_menu.options.settings"),
i18n::t(app, "app.results.config_menu.options.theme"),
i18n::t(app, "app.results.config_menu.options.keybindings"),
i18n::t(app, "app.results.config_menu.options.repos"),
];
let widest = opts
.iter()
.map(|s| u16::try_from(s.width()).map_or(u16::MAX, |x| x))
.max()
.unwrap_or(0);
let (w, h, max_num_width) = calculate_menu_dimensions(&opts, results_area, 0);
let (rect, inner_rect) = calculate_menu_rect(app.config_button_rect, w, h, results_area);
app.config_menu_rect = Some(inner_rect);
let spacing = 2u16;
let lines = build_numbered_menu_lines(&opts, widest, max_num_width, w, spacing, th);
let menu = create_menu_block(
lines,
"app.results.menus.config_lists.first_letter",
"app.results.menus.config_lists.suffix",
app,
th,
);
f.render_widget(Clear, rect);
f.render_widget(menu, rect);
}
fn render_panels_menu(
f: &mut Frame,
app: &mut AppState,
results_area: Rect,
th: crate::theme::Theme,
) {
app.panels_menu_rect = None;
if !app.panels_menu_open {
return;
}
let news_mode = matches!(app.app_mode, crate::state::types::AppMode::News);
let opts: Vec<String> = if news_mode {
let label_history = if app.show_news_history_pane {
i18n::t(app, "app.results.panels_menu.hide_history")
} else {
i18n::t(app, "app.results.panels_menu.show_history")
};
let label_bookmarks = if app.show_news_bookmarks_pane {
i18n::t(app, "app.results.panels_menu.hide_bookmarks")
} else {
i18n::t(app, "app.results.panels_menu.show_bookmarks")
};
let label_keybinds = if app.show_keybinds_footer {
i18n::t(app, "app.results.panels_menu.hide_keybinds")
} else {
i18n::t(app, "app.results.panels_menu.show_keybinds")
};
vec![label_history, label_bookmarks, label_keybinds]
} else {
let label_recent = if app.show_recent_pane {
i18n::t(app, "app.results.panels_menu.hide_recent")
} else {
i18n::t(app, "app.results.panels_menu.show_recent")
};
let label_install = if app.show_install_pane {
i18n::t(app, "app.results.panels_menu.hide_install_list")
} else {
i18n::t(app, "app.results.panels_menu.show_install_list")
};
let label_keybinds = if app.show_keybinds_footer {
i18n::t(app, "app.results.panels_menu.hide_keybinds")
} else {
i18n::t(app, "app.results.panels_menu.show_keybinds")
};
vec![label_recent, label_install, label_keybinds]
};
let widest = opts
.iter()
.map(|s| u16::try_from(s.width()).map_or(u16::MAX, |x| x))
.max()
.unwrap_or(0);
let (w, h, max_num_width) = calculate_menu_dimensions(&opts, results_area, 0);
let (rect, inner_rect) = calculate_menu_rect(app.panels_button_rect, w, h, results_area);
app.panels_menu_rect = Some(inner_rect);
let spacing = 2u16;
let lines = build_numbered_menu_lines(&opts, widest, max_num_width, w, spacing, th);
let menu = create_menu_block(
lines,
"app.results.menus.panels.first_letter",
"app.results.menus.panels.suffix",
app,
th,
);
f.render_widget(Clear, rect);
f.render_widget(menu, rect);
}
fn render_options_menu(
f: &mut Frame,
app: &mut AppState,
results_area: Rect,
th: crate::theme::Theme,
) {
app.options_menu_rect = None;
if !app.options_menu_open {
return;
}
let news_mode = matches!(app.app_mode, crate::state::types::AppMode::News);
let mode_toggle_label = if news_mode {
i18n::t(app, "app.results.options_menu.package_mode")
} else {
i18n::t(app, "app.results.options_menu.news_management")
};
let mut opts: Vec<String> = Vec::new();
if !news_mode {
let label_toggle = if app.installed_only_mode {
i18n::t(app, "app.results.options_menu.list_all_packages")
} else {
i18n::t(app, "app.results.options_menu.list_installed_packages")
};
opts.push(label_toggle);
}
opts.push(i18n::t(app, "app.results.options_menu.update_system"));
opts.push(i18n::t(app, "app.results.options_menu.tui_optional_deps"));
opts.push(i18n::t(app, "app.results.options_menu.repositories"));
opts.push(mode_toggle_label);
let widest = opts
.iter()
.map(|s| u16::try_from(s.width()).map_or(u16::MAX, |x| x))
.max()
.unwrap_or(0);
let (w, h, max_num_width) = calculate_menu_dimensions(&opts, results_area, 0);
let (rect, inner_rect) = calculate_menu_rect(app.options_button_rect, w, h, results_area);
app.options_menu_rect = Some(inner_rect);
let spacing = 2u16;
let lines = build_numbered_menu_lines(&opts, widest, max_num_width, w, spacing, th);
let menu = create_menu_block(
lines,
"app.results.menus.options.first_letter",
"app.results.menus.options.suffix",
app,
th,
);
f.render_widget(Clear, rect);
f.render_widget(menu, rect);
}
fn render_artix_filter_menu(
f: &mut Frame,
app: &mut AppState,
results_area: Rect,
th: crate::theme::Theme,
) {
app.artix_filter_menu_rect = None;
if !app.artix_filter_menu_open {
return;
}
let has_hidden_filters = app.results_filter_artix_omniverse_rect.is_none()
&& app.results_filter_artix_universe_rect.is_none()
&& app.results_filter_artix_lib32_rect.is_none()
&& app.results_filter_artix_galaxy_rect.is_none()
&& app.results_filter_artix_world_rect.is_none()
&& app.results_filter_artix_system_rect.is_none();
if !has_hidden_filters {
return;
}
let all_on = app.results_filter_show_artix_omniverse
&& app.results_filter_show_artix_universe
&& app.results_filter_show_artix_lib32
&& app.results_filter_show_artix_galaxy
&& app.results_filter_show_artix_world
&& app.results_filter_show_artix_system;
let opts: Vec<(String, bool)> = vec![
(i18n::t(app, "app.results.filters.artix"), all_on),
(
i18n::t(app, "app.results.filters.artix_omniverse"),
app.results_filter_show_artix_omniverse,
),
(
i18n::t(app, "app.results.filters.artix_universe"),
app.results_filter_show_artix_universe,
),
(
i18n::t(app, "app.results.filters.artix_lib32"),
app.results_filter_show_artix_lib32,
),
(
i18n::t(app, "app.results.filters.artix_galaxy"),
app.results_filter_show_artix_galaxy,
),
(
i18n::t(app, "app.results.filters.artix_world"),
app.results_filter_show_artix_world,
),
(
i18n::t(app, "app.results.filters.artix_system"),
app.results_filter_show_artix_system,
),
];
let widest = opts
.iter()
.map(|(s, _)| u16::try_from(s.width()).map_or(u16::MAX, |x| x))
.max()
.unwrap_or(0);
let w = widest
.saturating_add(4) .saturating_add(2)
.min(results_area.width.saturating_sub(2));
let h = u16::try_from(opts.len())
.unwrap_or(u16::MAX)
.saturating_add(2);
let (rect, inner_rect) = calculate_menu_rect(app.results_filter_artix_rect, w, h, results_area);
app.artix_filter_menu_rect = Some(inner_rect);
let lines = build_checkbox_menu_lines(&opts, w, th);
let menu = Paragraph::new(lines)
.style(Style::default().fg(th.text).bg(th.base))
.wrap(Wrap { trim: true })
.block(
Block::default()
.style(Style::default().bg(th.base))
.title(Line::from(vec![Span::styled(
"Artix Filters",
Style::default().fg(th.overlay1),
)]))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(th.mauve)),
);
f.render_widget(Clear, rect);
f.render_widget(menu, rect);
}
fn render_collapsed_menu(
f: &mut Frame,
app: &mut AppState,
results_area: Rect,
th: crate::theme::Theme,
) {
app.collapsed_menu_rect = None;
if !app.collapsed_menu_open {
return;
}
let opts: Vec<String> = vec![
i18n::t(app, "app.results.collapsed_menu.options.config_lists"),
i18n::t(app, "app.results.collapsed_menu.options.panels"),
i18n::t(app, "app.results.collapsed_menu.options.options"),
];
let widest = opts
.iter()
.map(|s| u16::try_from(s.width()).map_or(u16::MAX, |x| x))
.max()
.unwrap_or(0);
let (w, h, max_num_width) = calculate_menu_dimensions(&opts, results_area, 0);
let rect_w = w.saturating_add(2);
let min_x = results_area.x.saturating_add(1);
let max_x = results_area
.x
.saturating_add(results_area.width)
.saturating_sub(rect_w);
let (menu_x, menu_y) = if let Some((bx, by, bw, bh)) = app.collapsed_menu_button_rect {
let btn_right = bx.saturating_add(bw);
let mx = btn_right.saturating_sub(rect_w).max(min_x).min(max_x);
let my = by.saturating_add(bh);
(mx, my)
} else {
let options_button_label = format!("{} v", i18n::t(app, "app.results.buttons.options"));
let options_w = u16::try_from(options_button_label.width()).unwrap_or(u16::MAX);
let inner_width = results_area.width.saturating_sub(2);
let opt_x = results_area
.x
.saturating_add(1)
.saturating_add(inner_width.saturating_sub(options_w));
let opt_right = opt_x.saturating_add(options_w);
let mx = opt_right.saturating_sub(rect_w).max(min_x).min(max_x);
let my = results_area.y.saturating_add(1);
(mx, my)
};
let rect = Rect {
x: menu_x,
y: menu_y,
width: rect_w,
height: h,
};
let inner_rect = (rect.x + 1, rect.y + 1, w, h.saturating_sub(2));
app.collapsed_menu_rect = Some(inner_rect);
let spacing = 2u16;
let lines = build_numbered_menu_lines(&opts, widest, max_num_width, w, spacing, th);
let menu = create_menu_block(
lines,
"app.results.menus.menu.first_letter",
"app.results.menus.menu.suffix",
app,
th,
);
f.render_widget(Clear, rect);
f.render_widget(menu, rect);
}
pub fn render_dropdowns(f: &mut Frame, app: &mut AppState, results_area: Rect) {
let th = theme();
render_config_menu(f, app, results_area, th);
render_panels_menu(f, app, results_area, th);
render_options_menu(f, app, results_area, th);
render_artix_filter_menu(f, app, results_area, th);
render_custom_repos_filter_menu(f, app, results_area, th);
render_collapsed_menu(f, app, results_area, th);
}
fn render_custom_repos_filter_menu(
f: &mut Frame,
app: &mut AppState,
results_area: Rect,
th: crate::theme::Theme,
) {
app.custom_repos_filter_menu_rect = None;
if !app.custom_repos_filter_menu_open || app.results_filter_dynamic.is_empty() {
return;
}
let mut keys: Vec<String> = app.results_filter_dynamic.keys().cloned().collect();
keys.sort();
let all_label = i18n::t(app, "app.results.filters.custom_repos_all");
let all_on = app.results_filter_dynamic.values().all(|v| *v);
let mut opts: Vec<(String, bool)> = vec![(all_label, all_on)];
for k in &keys {
let on = app.results_filter_dynamic.get(k).copied().unwrap_or(true);
opts.push((k.clone(), on));
}
let widest = opts
.iter()
.map(|(s, _)| u16::try_from(s.width()).map_or(u16::MAX, |x| x))
.max()
.unwrap_or(0);
let w = widest
.saturating_add(4)
.saturating_add(2)
.min(results_area.width.saturating_sub(2));
let h = u16::try_from(opts.len())
.unwrap_or(u16::MAX)
.saturating_add(2);
let (rect, inner_rect) =
calculate_menu_rect(app.results_filter_custom_repos_rect, w, h, results_area);
app.custom_repos_filter_menu_rect = Some(inner_rect);
let lines = build_checkbox_menu_lines(&opts, w, th);
let menu = Paragraph::new(lines)
.style(Style::default().fg(th.text).bg(th.base))
.wrap(Wrap { trim: true })
.block(
Block::default()
.style(Style::default().bg(th.base))
.title(Line::from(vec![Span::styled(
i18n::t(app, "app.results.filters.custom_repos_menu_title"),
Style::default().fg(th.overlay1),
)]))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(th.mauve)),
);
f.render_widget(Clear, rect);
f.render_widget(menu, rect);
}
#[cfg(test)]
mod calculate_menu_rect_tests {
use ratatui::layout::Rect;
use super::calculate_menu_rect;
#[test]
fn menu_opens_below_button_when_results_band_is_lower_on_screen() {
let results_area = Rect::new(0, 18, 100, 12);
let button = Some((70u16, 0u16, 8u16, 1u16));
let (rect, _) = calculate_menu_rect(button, 24, 7, results_area);
assert_eq!(
rect.y, 1,
"expected menu directly under top bar, not results_area.y + 1"
);
}
#[test]
fn menu_y_fallback_without_button_rect() {
let results_area = Rect::new(0, 5, 80, 10);
let (rect, _) = calculate_menu_rect(None, 20, 5, results_area);
assert_eq!(rect.y, 6);
}
}