use crate::state::AppState;
use crate::state::types::AppMode;
use crate::theme::theme;
use ratatui::{
Frame,
prelude::Rect,
style::Style,
text::Line,
widgets::{Block, BorderType, Borders, List, ListItem},
};
mod dropdowns;
mod list;
mod news;
mod sort_menu;
mod status;
mod title;
pub use title::{
clear_top_bar_menu_rects, render_top_bar_menu_cluster, top_bar_menu_cluster_width,
};
mod utils;
pub struct RenderContext {
pub results_len: usize,
pub optional_repos: OptionalRepos,
pub menu_states: MenuStates,
pub filter_states: FilterStates,
}
#[allow(clippy::struct_excessive_bools)]
pub struct OptionalRepos {
pub has_eos: bool,
pub has_cachyos: bool,
pub has_artix: bool,
pub has_artix_omniverse: bool,
pub has_artix_universe: bool,
pub has_artix_lib32: bool,
pub has_artix_galaxy: bool,
pub has_artix_world: bool,
pub has_artix_system: bool,
pub has_blackarch: bool,
pub has_manjaro: bool,
}
#[allow(clippy::struct_excessive_bools)]
pub struct MenuStates {
pub sort_menu_open: bool,
}
#[allow(clippy::struct_excessive_bools)]
pub struct FilterStates {
pub show_aur: bool,
pub show_core: bool,
pub show_extra: bool,
pub show_multilib: bool,
pub show_eos: bool,
pub show_cachyos: bool,
pub show_artix: bool,
pub show_artix_omniverse: bool,
pub show_artix_universe: bool,
pub show_artix_lib32: bool,
pub show_artix_galaxy: bool,
pub show_artix_world: bool,
pub show_artix_system: bool,
pub show_blackarch: bool,
pub show_manjaro: bool,
}
pub fn render_results(f: &mut Frame, app: &mut AppState, area: Rect) {
if matches!(app.app_mode, AppMode::News) {
render_news_results(f, app, area);
return;
}
utils::center_selection(app, area);
let ctx = utils::extract_render_context(app);
let title_spans = title::build_title_spans_from_context(app, &ctx, area);
title::record_title_rects_from_context(app, &ctx, area);
render_list_widget(f, app, area, &title_spans);
status::render_status(f, app, area);
let btn_x = app.sort_button_rect.map_or(area.x, |(x, _, _, _)| x);
sort_menu::render_sort_menu(f, app, area, btn_x);
utils::record_results_rect(app, area);
}
fn render_list_widget(
f: &mut Frame,
app: &mut AppState,
area: Rect,
title_spans: &[ratatui::text::Span<'static>],
) {
let th = theme();
let list_offset = app.list_state.offset();
let viewport_rows = area.height.saturating_sub(2) as usize;
let start = list_offset;
let end = std::cmp::min(app.results.len(), start + viewport_rows);
let prefs = crate::theme::settings();
let items: Vec<ListItem> = app
.results
.iter()
.enumerate()
.map(|(i, p)| {
let in_viewport = i >= start && i < end;
list::build_list_item(p, app, &th, &prefs, in_viewport)
})
.collect();
let list = List::new(items)
.style(Style::default().fg(th.text).bg(th.base))
.block(
Block::default()
.title(Line::from(title_spans.to_vec()))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(th.surface2)),
)
.highlight_style(Style::default().bg(th.surface1))
.highlight_symbol("> ");
f.render_stateful_widget(list, area, &mut app.list_state);
}
pub use dropdowns::render_dropdowns;
fn render_news_results(f: &mut Frame, app: &mut AppState, area: Rect) {
let th = theme();
app.results_rect = Some((area.x, area.y, area.width, area.height));
let news_loading = app.news_loading;
let news_results = app.news_results.clone();
let news_read_ids = app.news_read_ids.clone();
let news_read_urls = app.news_read_urls.clone();
let needs_select_none = news_loading && news_results.is_empty();
if needs_select_none {
app.news_list_state.select(None);
}
let title_spans = news::build_news_title_spans_and_record_rects(app, area);
f.render_stateful_widget(
List::new(
news::build_news_list_items(
app,
news_loading,
&news_results,
&news_read_ids,
&news_read_urls,
)
.0,
)
.style(Style::default().fg(th.text).bg(th.base))
.block(
Block::default()
.title(Line::from(title_spans))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(th.surface2)),
)
.highlight_style(Style::default().bg(th.surface1))
.highlight_symbol("> "),
area,
&mut app.news_list_state,
);
let btn_x = app.sort_button_rect.map_or(area.x, |(x, _, _, _)| x);
sort_menu::render_sort_menu(f, app, area, btn_x);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::state::types::NewsFeedSource;
#[test]
fn results_sets_title_button_rects_and_status_rect() {
use ratatui::{Terminal, backend::TestBackend};
let backend = TestBackend::new(120, 20);
let mut term = Terminal::new(backend).expect("failed to create test terminal");
let mut app = crate::state::AppState::default();
init_test_translations(&mut app);
app.results = vec![crate::state::PackageItem {
name: "pkg".into(),
version: "1".into(),
description: String::new(),
source: crate::state::Source::Aur,
popularity: Some(1.0),
out_of_date: None,
orphaned: false,
}];
app.arch_status_text = "All systems operational".into();
app.arch_status_color = crate::state::ArchStatusColor::Operational;
term.draw(|f| {
let area = f.area();
let chunks = ratatui::layout::Layout::default()
.direction(ratatui::layout::Direction::Vertical)
.constraints([
ratatui::layout::Constraint::Length(1),
ratatui::layout::Constraint::Min(0),
])
.split(area);
crate::ui::updates::render_updates_button(f, &mut app, chunks[0]);
render_results(f, &mut app, chunks[1]);
})
.expect("failed to draw test terminal");
assert!(app.sort_button_rect.is_some());
assert!(app.options_button_rect.is_some());
assert!(app.config_button_rect.is_some());
assert!(app.panels_button_rect.is_some());
assert!(app.arch_status_rect.is_some());
assert!(app.results_rect.is_some());
}
fn init_test_translations(app: &mut crate::state::AppState) {
use std::collections::HashMap;
let mut translations = HashMap::new();
translations.insert("app.results.title".to_string(), "Results".to_string());
translations.insert("app.results.buttons.sort".to_string(), "Sort".to_string());
translations.insert(
"app.results.buttons.options".to_string(),
"Options".to_string(),
);
translations.insert(
"app.results.buttons.panels".to_string(),
"Panels".to_string(),
);
translations.insert(
"app.results.buttons.config_lists".to_string(),
"Config/Lists".to_string(),
);
translations.insert("app.results.buttons.menu".to_string(), "Menu".to_string());
translations.insert("app.results.filters.aur".to_string(), "AUR".to_string());
translations.insert("app.results.filters.core".to_string(), "core".to_string());
translations.insert("app.results.filters.extra".to_string(), "extra".to_string());
translations.insert(
"app.results.filters.multilib".to_string(),
"multilib".to_string(),
);
translations.insert("app.results.filters.eos".to_string(), "EOS".to_string());
translations.insert(
"app.results.filters.cachyos".to_string(),
"CachyOS".to_string(),
);
translations.insert("app.results.filters.artix".to_string(), "Artix".to_string());
translations.insert(
"app.results.filters.artix_omniverse".to_string(),
"OMNI".to_string(),
);
translations.insert(
"app.results.filters.artix_universe".to_string(),
"UNI".to_string(),
);
translations.insert(
"app.results.filters.artix_lib32".to_string(),
"LIB32".to_string(),
);
translations.insert(
"app.results.filters.artix_galaxy".to_string(),
"GALAXY".to_string(),
);
translations.insert(
"app.results.filters.artix_world".to_string(),
"WORLD".to_string(),
);
translations.insert(
"app.results.filters.artix_system".to_string(),
"SYSTEM".to_string(),
);
translations.insert(
"app.results.filters.blackarch".to_string(),
"BlackArch".to_string(),
);
translations.insert(
"app.results.filters.manjaro".to_string(),
"Manjaro".to_string(),
);
translations.insert("app.news.filters.arch".to_string(), "Arch".to_string());
translations.insert(
"app.news.filters.advisories".to_string(),
"Advisories".to_string(),
);
translations.insert(
"app.news.filters.installed_only".to_string(),
"Installed".to_string(),
);
app.translations = translations.clone();
app.translations_fallback = translations;
}
#[test]
fn news_filters_leave_gap_between_aur_comments_and_read_toggle() {
use ratatui::{Terminal, backend::TestBackend};
let backend = TestBackend::new(160, 10);
let mut term = Terminal::new(backend).expect("failed to create test terminal");
let mut app = crate::state::AppState::default();
init_test_translations(&mut app);
app.app_mode = AppMode::News;
app.news_results = vec![crate::state::types::NewsFeedItem {
id: "1".into(),
date: "2025-01-01".into(),
title: "Example update".into(),
summary: None,
url: None,
source: NewsFeedSource::AurComment,
severity: None,
packages: Vec::new(),
}];
term.draw(|f| {
let area = f.area();
render_results(f, &mut app, area);
})
.expect("failed to draw test terminal");
let buffer = term.backend().buffer();
let mut title_line = String::new();
for x in 0..buffer.area.width {
title_line.push_str(buffer[(x, 0)].symbol());
}
let trimmed = title_line.trim_end();
assert!(
trimmed.contains("[AUR Comments] [All]"),
"expected spacing between AUR comments and read filters, saw: {trimmed}"
);
}
}