use crate::i18n;
use crate::state::AppState;
use crate::state::types::{NewsFeedSource, NewsReadFilter};
use crate::theme::theme;
use ratatui::{
prelude::Rect,
style::{Modifier, Style},
text::{Line, Span},
widgets::ListItem,
};
use unicode_width::UnicodeWidthStr;
pub fn build_news_list_items<'a>(
app: &AppState,
news_loading: bool,
news_results: &'a [crate::state::types::NewsFeedItem],
news_read_ids: &'a std::collections::HashSet<String>,
news_read_urls: &'a std::collections::HashSet<String>,
) -> (Vec<ListItem<'a>>, bool) {
let th = theme();
let prefs = crate::theme::settings();
if news_loading && news_results.is_empty() {
(
vec![
ListItem::new(Line::from(ratatui::text::Span::styled(
i18n::t(app, "app.loading.news"),
Style::default().fg(th.overlay1),
))),
ListItem::new(Line::from(ratatui::text::Span::styled(
i18n::t(app, "app.loading.news_first_load_hint"),
Style::default().fg(th.subtext0),
))),
ListItem::new(Line::from(ratatui::text::Span::styled(
i18n::t(app, "app.loading.news_pkg_impact_hint"),
Style::default().fg(th.yellow),
))),
],
true, )
} else {
(
news_results
.iter()
.map(|item| build_news_list_item(item, news_read_ids, news_read_urls, &th, &prefs))
.collect(),
false, )
}
}
fn build_news_list_item(
item: &crate::state::types::NewsFeedItem,
news_read_ids: &std::collections::HashSet<String>,
news_read_urls: &std::collections::HashSet<String>,
th: &crate::theme::Theme,
prefs: &crate::theme::Settings,
) -> ListItem<'static> {
let is_read = news_read_ids.contains(&item.id)
|| item
.url
.as_ref()
.is_some_and(|u| news_read_urls.contains(u));
let read_symbol = if is_read {
prefs.news_read_symbol.clone()
} else {
prefs.news_unread_symbol.clone()
};
let read_style = if is_read {
Style::default().fg(th.overlay1)
} else {
Style::default().fg(th.green)
};
let (source_label, source_color) = match item.source {
NewsFeedSource::ArchNews => ("Arch", th.sapphire),
NewsFeedSource::SecurityAdvisory => ("Advisory", th.yellow),
NewsFeedSource::InstalledPackageUpdate => ("Update", th.green),
NewsFeedSource::AurPackageUpdate => ("AUR Upd", th.mauve),
NewsFeedSource::AurComment => ("AUR Cmt", th.yellow),
};
let sev = item
.severity
.as_ref()
.map_or_else(String::new, |s| format!("{s:?}"));
let highlight_style = ratatui::style::Style::default()
.fg(th.yellow)
.add_modifier(Modifier::BOLD);
let title_spans = if matches!(item.source, NewsFeedSource::ArchNews) {
render_aur_comment_keywords(&item.title, th, highlight_style)
} else {
vec![ratatui::text::Span::raw(item.title.clone())]
};
let mut spans = vec![
ratatui::text::Span::styled(
format!("{read_symbol} "),
read_style.add_modifier(Modifier::BOLD),
),
ratatui::text::Span::styled(
format!("[{source_label}]"),
Style::default().fg(source_color),
),
ratatui::text::Span::raw(" "),
ratatui::text::Span::raw(item.date.clone()),
ratatui::text::Span::raw(" "),
];
spans.extend(title_spans);
if !sev.is_empty() {
spans.push(ratatui::text::Span::raw(" "));
spans.push(ratatui::text::Span::styled(
format!("[{sev}]"),
Style::default().fg(th.yellow),
));
}
if let Some(summary) = item.summary.as_ref() {
spans.push(ratatui::text::Span::raw(" – "));
spans.extend(render_summary_spans(summary, th, item.source));
}
let item_style = if is_read {
Style::default().fg(th.subtext1)
} else {
Style::default().fg(th.text)
};
ListItem::new(Line::from(spans)).style(item_style)
}
#[allow(clippy::struct_excessive_bools)]
struct NewsTitleContext {
title_text: String,
sort_label: String,
date_label: String,
arch_filter_label: String,
advisory_filter_label: String,
updates_filter_label: String,
aur_updates_filter_label: String,
aur_comments_filter_label: String,
read_filter_label: String,
sort_menu_open: bool,
news_filter_show_arch_news: bool,
news_filter_show_advisories: bool,
news_filter_show_pkg_updates: bool,
news_filter_show_aur_updates: bool,
news_filter_show_aur_comments: bool,
news_filter_read_status: NewsReadFilter,
}
fn extract_news_title_context(app: &AppState) -> NewsTitleContext {
let title_text = if app.news_loading {
"News Feed (loading...)".to_string()
} else {
format!("News Feed ({})", app.news_results.len())
};
let age_label = app
.news_max_age_days
.map_or_else(|| "All".to_string(), |d| format!("{d} Days"));
let sort_label = format!("{} v", i18n::t(app, "app.results.buttons.sort"));
let date_label = format!("Date: {age_label}");
let arch_filter_label = format!("[{}]", i18n::t(app, "app.news.filters.arch"));
let advisory_filter_label = if !app.news_filter_show_advisories {
"[Advisories Off]".to_string()
} else if app.news_filter_installed_only {
"[Advisories Installed]".to_string()
} else {
"[Advisories All]".to_string()
};
let updates_filter_label = "[Updates]".to_string();
let aur_updates_filter_label = "[AUR Upd]".to_string();
let aur_comments_filter_label = "[AUR Comments]".to_string();
let read_filter_label = match app.news_filter_read_status {
NewsReadFilter::All => "[All]".to_string(),
NewsReadFilter::Read => "[Read]".to_string(),
NewsReadFilter::Unread => "[Unread]".to_string(),
};
NewsTitleContext {
title_text,
sort_label,
date_label,
arch_filter_label,
advisory_filter_label,
updates_filter_label,
aur_updates_filter_label,
aur_comments_filter_label,
read_filter_label,
sort_menu_open: app.sort_menu_open,
news_filter_show_arch_news: app.news_filter_show_arch_news,
news_filter_show_advisories: app.news_filter_show_advisories,
news_filter_show_pkg_updates: app.news_filter_show_pkg_updates,
news_filter_show_aur_updates: app.news_filter_show_aur_updates,
news_filter_show_aur_comments: app.news_filter_show_aur_comments,
news_filter_read_status: app.news_filter_read_status,
}
}
struct TitleWidths {
title: u16,
arch: u16,
advisory: u16,
updates: u16,
aur_updates: u16,
aur_comments: u16,
read: u16,
date: u16,
sort: u16,
}
fn calculate_title_widths(ctx: &NewsTitleContext) -> TitleWidths {
TitleWidths {
title: u16::try_from(ctx.title_text.width()).unwrap_or(u16::MAX),
arch: u16::try_from(ctx.arch_filter_label.width()).unwrap_or(u16::MAX),
advisory: u16::try_from(ctx.advisory_filter_label.width()).unwrap_or(u16::MAX),
updates: u16::try_from(ctx.updates_filter_label.width()).unwrap_or(u16::MAX),
aur_updates: u16::try_from(ctx.aur_updates_filter_label.width()).unwrap_or(u16::MAX),
aur_comments: u16::try_from(ctx.aur_comments_filter_label.width()).unwrap_or(u16::MAX),
read: u16::try_from(ctx.read_filter_label.width()).unwrap_or(u16::MAX),
date: u16::try_from(ctx.date_label.width()).unwrap_or(u16::MAX),
sort: u16::try_from(ctx.sort_label.width()).unwrap_or(u16::MAX),
}
}
struct TitleSpans {
title: Span<'static>,
sort_button: Vec<Span<'static>>,
arch_filter: Span<'static>,
advisory_filter: Span<'static>,
updates_filter: Span<'static>,
aur_updates_filter: Span<'static>,
aur_comments_filter: Span<'static>,
read_filter: Span<'static>,
date_button: Vec<Span<'static>>,
}
fn build_title_spans(ctx: &NewsTitleContext) -> TitleSpans {
let th = theme();
let button_style = |is_open: bool| -> Style {
if is_open {
Style::default()
.fg(th.crust)
.bg(th.mauve)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
.fg(th.mauve)
.bg(th.surface2)
.add_modifier(Modifier::BOLD)
}
};
let render_button = |label: &str, is_open: bool| -> Vec<Span<'static>> {
let style = button_style(is_open);
let mut spans = Vec::new();
if let Some(first) = label.chars().next() {
let rest = &label[first.len_utf8()..];
spans.push(Span::styled(
first.to_string(),
style.add_modifier(Modifier::UNDERLINED),
));
spans.push(Span::styled(rest.to_string(), style));
} else {
spans.push(Span::styled(label.to_string(), style));
}
spans
};
let render_filter = |label: &str, active: bool| -> Span<'static> {
let (fg, bg) = if active {
(th.crust, th.green)
} else {
(th.mauve, th.surface2)
};
Span::styled(
label.to_string(),
Style::default().fg(fg).bg(bg).add_modifier(Modifier::BOLD),
)
};
TitleSpans {
title: Span::styled(ctx.title_text.clone(), Style::default().fg(th.overlay1)),
sort_button: render_button(&ctx.sort_label, ctx.sort_menu_open),
arch_filter: render_filter(&ctx.arch_filter_label, ctx.news_filter_show_arch_news),
advisory_filter: render_filter(&ctx.advisory_filter_label, ctx.news_filter_show_advisories),
updates_filter: render_filter(&ctx.updates_filter_label, ctx.news_filter_show_pkg_updates),
aur_updates_filter: render_filter(
&ctx.aur_updates_filter_label,
ctx.news_filter_show_aur_updates,
),
aur_comments_filter: render_filter(
&ctx.aur_comments_filter_label,
ctx.news_filter_show_aur_comments,
),
read_filter: render_filter(
&ctx.read_filter_label,
!matches!(ctx.news_filter_read_status, NewsReadFilter::All),
),
date_button: render_button(&ctx.date_label, false),
}
}
pub fn build_news_title_spans_and_record_rects(
app: &mut AppState,
area: Rect,
) -> Vec<Span<'static>> {
let ctx = extract_news_title_context(app);
let widths = calculate_title_widths(&ctx);
let spans = build_title_spans(&ctx);
let mut title_spans: Vec<Span<'static>> = Vec::new();
title_spans.push(spans.title);
title_spans.push(Span::raw(" "));
let mut x_cursor = area
.x
.saturating_add(1)
.saturating_add(widths.title)
.saturating_add(2);
title_spans.extend(spans.sort_button);
x_cursor = x_cursor.saturating_add(widths.sort).saturating_add(2);
title_spans.push(Span::raw(" "));
title_spans.push(spans.arch_filter);
x_cursor = x_cursor.saturating_add(widths.arch).saturating_add(1);
title_spans.push(Span::raw(" "));
title_spans.push(spans.advisory_filter);
x_cursor = x_cursor.saturating_add(widths.advisory).saturating_add(1);
title_spans.push(Span::raw(" "));
title_spans.push(spans.updates_filter);
x_cursor = x_cursor.saturating_add(widths.updates).saturating_add(1);
title_spans.push(Span::raw(" "));
title_spans.push(spans.aur_updates_filter);
x_cursor = x_cursor
.saturating_add(widths.aur_updates)
.saturating_add(1);
title_spans.push(Span::raw(" "));
title_spans.push(spans.aur_comments_filter);
x_cursor = x_cursor.saturating_add(widths.aur_comments);
title_spans.push(Span::raw(" "));
x_cursor = x_cursor.saturating_add(2);
title_spans.push(spans.read_filter);
x_cursor = x_cursor.saturating_add(widths.read);
title_spans.push(Span::raw(" "));
x_cursor = x_cursor.saturating_add(2);
let date_x = x_cursor;
title_spans.extend(spans.date_button);
let mut x_cursor_rect = area
.x
.saturating_add(1)
.saturating_add(widths.title)
.saturating_add(2);
app.sort_button_rect = Some((x_cursor_rect, area.y, widths.sort, 1));
x_cursor_rect = x_cursor_rect.saturating_add(widths.sort).saturating_add(2);
app.news_filter_arch_rect = Some((x_cursor_rect, area.y, widths.arch, 1));
x_cursor_rect = x_cursor_rect.saturating_add(widths.arch).saturating_add(1);
app.news_filter_advisory_rect = Some((x_cursor_rect, area.y, widths.advisory, 1));
x_cursor_rect = x_cursor_rect
.saturating_add(widths.advisory)
.saturating_add(1);
app.news_filter_updates_rect = Some((x_cursor_rect, area.y, widths.updates, 1));
x_cursor_rect = x_cursor_rect
.saturating_add(widths.updates)
.saturating_add(1);
app.news_filter_aur_updates_rect = Some((x_cursor_rect, area.y, widths.aur_updates, 1));
x_cursor_rect = x_cursor_rect
.saturating_add(widths.aur_updates)
.saturating_add(1);
app.news_filter_aur_comments_rect = Some((x_cursor_rect, area.y, widths.aur_comments, 1));
x_cursor_rect = x_cursor_rect
.saturating_add(widths.aur_comments)
.saturating_add(2);
app.news_filter_read_rect = Some((x_cursor_rect, area.y, widths.read, 1));
let _ = x_cursor_rect.saturating_add(widths.read).saturating_add(2);
app.news_age_button_rect = Some((date_x, area.y, widths.date, 1));
title_spans
}
pub fn render_summary_spans(
summary: &str,
th: &crate::theme::Theme,
source: NewsFeedSource,
) -> Vec<ratatui::text::Span<'static>> {
let highlight_style = ratatui::style::Style::default()
.fg(th.yellow)
.add_modifier(Modifier::BOLD);
let normal = ratatui::style::Style::default().fg(th.subtext1);
if matches!(
source,
NewsFeedSource::InstalledPackageUpdate | NewsFeedSource::AurPackageUpdate
) {
return vec![ratatui::text::Span::styled(
summary.to_string(),
highlight_style,
)];
}
if matches!(source, NewsFeedSource::AurComment) {
return render_aur_comment_keywords(summary, th, highlight_style);
}
if matches!(source, NewsFeedSource::ArchNews) {
return render_aur_comment_keywords(summary, th, highlight_style);
}
vec![ratatui::text::Span::styled(
summary.to_string(),
normal.add_modifier(Modifier::BOLD),
)]
}
pub fn render_aur_comment_keywords(
summary: &str,
th: &crate::theme::Theme,
base: ratatui::style::Style,
) -> Vec<ratatui::text::Span<'static>> {
let normal = base;
let neg = ratatui::style::Style::default()
.fg(th.red)
.add_modifier(Modifier::BOLD);
let pos = ratatui::style::Style::default()
.fg(th.green)
.add_modifier(Modifier::BOLD);
let negative_words = [
"crash",
"crashed",
"crashes",
"critical",
"bug",
"bugs",
"fail",
"fails",
"failed",
"failure",
"failures",
"issue",
"issues",
"trouble",
"troubles",
"panic",
"segfault",
"broken",
"regression",
"hang",
"freeze",
"unstable",
"error",
"errors",
"require manual intervention",
"requires manual intervention",
"corrupting",
];
let positive_words = [
"fix",
"fixed",
"fixes",
"patch",
"patched",
"solve",
"solved",
"solves",
"solution",
"resolve",
"resolved",
"resolves",
"workaround",
];
let neg_set: std::collections::HashSet<&str> = negative_words.into_iter().collect();
let pos_set: std::collections::HashSet<&str> = positive_words.into_iter().collect();
let mut spans = Vec::new();
for token in summary.split_inclusive(' ') {
let cleaned = token
.trim_matches(|c: char| !c.is_alphanumeric() && c != '-' && c != '_')
.to_ascii_lowercase();
let style = if pos_set.contains(cleaned.as_str()) {
pos
} else if neg_set.contains(cleaned.as_str()) {
neg
} else {
normal.add_modifier(Modifier::BOLD)
};
spans.push(ratatui::text::Span::styled(token.to_string(), style));
}
spans
}