use ratatui::{
Frame,
layout::{Constraint, Direction, Layout},
prelude::Rect,
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Clear, Paragraph, Wrap},
};
use crate::i18n;
use crate::state::{AppState, types::NewsFeedItem, types::NewsFeedSource};
use crate::theme::{KeyChord, theme};
const MODAL_WIDTH_RATIO: u16 = 2;
const MODAL_WIDTH_DIVISOR: u16 = 3;
const MODAL_HEIGHT_PADDING: u16 = 8;
const MODAL_MAX_HEIGHT: u16 = 20;
const BORDER_WIDTH: u16 = 1;
const HEADER_LINES: u16 = 2;
const KEYBINDS_PANE_HEIGHT: u16 = 3;
fn is_critical_news(title: &str) -> bool {
let title_lower = title.to_lowercase();
title_lower.contains("critical")
|| title_lower.contains("require manual intervention")
|| title_lower.contains("requires manual intervention")
|| title_lower.contains("corrupting")
}
fn compute_item_colors(
is_selected: bool,
is_critical: bool,
) -> (ratatui::style::Color, Option<ratatui::style::Color>) {
let th = theme();
let fg = if is_critical { th.red } else { th.text };
let bg = if is_selected { Some(th.surface1) } else { None };
(fg, bg)
}
fn calculate_modal_rect(area: Rect) -> Rect {
let w = (area.width * MODAL_WIDTH_RATIO) / MODAL_WIDTH_DIVISOR;
let h = area
.height
.saturating_sub(MODAL_HEIGHT_PADDING)
.min(MODAL_MAX_HEIGHT);
let x = area.x + (area.width.saturating_sub(w)) / 2;
let y = area.y + (area.height.saturating_sub(h)) / 2;
Rect {
x,
y,
width: w,
height: h,
}
}
fn highlight_keywords(text: &str, th: &crate::theme::Theme) -> Vec<Span<'static>> {
let normal = Style::default().fg(th.yellow).add_modifier(Modifier::BOLD);
let neg = Style::default().fg(th.red).add_modifier(Modifier::BOLD);
let pos = 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 text.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
};
spans.push(Span::styled(token.to_string(), style));
}
spans
}
fn format_news_item(
item: &NewsFeedItem,
is_selected: bool,
is_read: bool,
is_critical: bool,
) -> Line<'static> {
let th = theme();
let prefs = crate::theme::settings();
let symbol = if is_read {
&prefs.news_read_symbol
} else {
&prefs.news_unread_symbol
};
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 source_span = Span::styled(
format!("[{source_label}] "),
Style::default().fg(source_color),
);
let symbol_span = Span::raw(format!("{symbol} "));
let date_span = Span::raw(format!("{} ", item.date));
let (display_text, should_highlight) = match item.source {
NewsFeedSource::AurComment => {
let text = item
.summary
.as_ref()
.map_or_else(|| item.title.as_str(), String::as_str);
(text.to_string(), true)
}
NewsFeedSource::ArchNews => {
(item.title.clone(), true)
}
_ => {
(item.title.clone(), false)
}
};
let (fg, bg) = compute_item_colors(is_selected, is_critical);
let base_style = bg.map_or_else(
|| Style::default().fg(fg),
|bg_color| Style::default().fg(fg).bg(bg_color),
);
let mut content_spans = if should_highlight {
let highlighted = highlight_keywords(&display_text, &th);
highlighted
.into_iter()
.map(|mut span| {
if let Some(bg_color) = bg {
span.style = span.style.bg(bg_color);
}
span
})
.collect()
} else {
vec![Span::styled(display_text, base_style)]
};
let mut all_spans = vec![source_span, symbol_span, date_span];
all_spans.append(&mut content_spans);
Line::from(all_spans)
}
fn build_keybinds_lines(app: &AppState) -> Vec<Line<'static>> {
let th = theme();
let mark_read_key = app
.keymap
.news_mark_read
.first()
.map_or_else(|| "R".to_string(), KeyChord::label);
let mark_all_read_key = app
.keymap
.news_mark_all_read
.first()
.map_or_else(|| "Ctrl+R".to_string(), KeyChord::label);
let footer_template = i18n::t(app, "app.modals.news.keybinds_hint");
let footer_text =
footer_template
.replacen("{}", &mark_read_key, 1)
.replacen("{}", &mark_all_read_key, 1);
vec![
Line::from(""), Line::from(Span::styled(footer_text, Style::default().fg(th.subtext1))),
]
}
fn build_news_lines(app: &AppState, items: &[NewsFeedItem], selected: usize) -> Vec<Line<'static>> {
let th = theme();
let mut lines = Vec::new();
lines.push(Line::from(Span::styled(
i18n::t(app, "app.modals.news.heading"),
Style::default().fg(th.mauve).add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(""));
if items.is_empty() {
lines.push(Line::from(Span::styled(
i18n::t(app, "app.modals.news.none"),
Style::default().fg(th.subtext1),
)));
} else {
for (i, item) in items.iter().enumerate() {
let is_critical = is_critical_news(&item.title);
let is_selected = selected == i;
let is_read = app.news_read_ids.contains(&item.id)
|| item
.url
.as_ref()
.is_some_and(|url| app.news_read_urls.contains(url));
lines.push(format_news_item(item, is_selected, is_read, is_critical));
}
}
lines
}
#[allow(clippy::missing_const_for_fn)] fn calculate_list_rect(rect: Rect) -> (u16, u16, u16, u16) {
let list_inner_x = rect.x + BORDER_WIDTH;
let list_inner_y = rect.y + BORDER_WIDTH + HEADER_LINES;
let list_inner_w = rect.width.saturating_sub(BORDER_WIDTH * 2);
let inner_h = rect.height.saturating_sub(BORDER_WIDTH * 2);
let list_rows = inner_h.saturating_sub(HEADER_LINES + KEYBINDS_PANE_HEIGHT);
(list_inner_x, list_inner_y, list_inner_w, list_rows)
}
fn build_news_paragraph(app: &AppState, lines: Vec<Line<'static>>) -> Paragraph<'static> {
let th = theme();
Paragraph::new(lines)
.style(Style::default().fg(th.text).bg(th.mantle))
.wrap(Wrap { trim: true })
.block(
Block::default()
.title(Span::styled(
i18n::t(app, "app.modals.news.title"),
Style::default().fg(th.mauve).add_modifier(Modifier::BOLD),
))
.borders(Borders::TOP | Borders::LEFT | Borders::RIGHT)
.border_type(BorderType::Double)
.border_style(Style::default().fg(th.mauve))
.style(Style::default().bg(th.mantle)),
)
}
fn prepare_news_modal(
app: &mut AppState,
area: Rect,
items: &[NewsFeedItem],
selected: usize,
) -> (Rect, Vec<Line<'static>>, Rect) {
let rect = calculate_modal_rect(area);
app.news_rect = Some((rect.x, rect.y, rect.width, rect.height));
let (list_x, list_y, list_w, list_h) = calculate_list_rect(rect);
app.news_list_rect = Some((list_x, list_y, list_w, list_h));
let lines = build_news_lines(app, items, selected);
let keybinds_rect = Rect {
x: rect.x + BORDER_WIDTH,
y: rect.y + rect.height - KEYBINDS_PANE_HEIGHT - BORDER_WIDTH,
width: rect.width.saturating_sub(BORDER_WIDTH * 2),
height: KEYBINDS_PANE_HEIGHT,
};
(rect, lines, keybinds_rect)
}
fn render_news_modal(
f: &mut Frame,
rect: Rect,
content_lines: Vec<Line<'static>>,
_keybinds_rect: Rect,
app: &AppState,
scroll: u16,
) {
let th = theme();
f.render_widget(Clear, rect);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(1), Constraint::Length(KEYBINDS_PANE_HEIGHT), ])
.split(rect);
let mut paragraph = build_news_paragraph(app, content_lines);
paragraph = paragraph.scroll((scroll, 0));
f.render_widget(paragraph, chunks[0]);
let keybinds_lines = build_keybinds_lines(app);
let keybinds_widget = Paragraph::new(keybinds_lines)
.style(Style::default().fg(th.text).bg(th.mantle))
.wrap(Wrap { trim: true })
.block(
Block::default()
.borders(Borders::LEFT | Borders::BOTTOM | Borders::RIGHT)
.border_type(BorderType::Double)
.border_style(Style::default().fg(th.mauve))
.style(Style::default().bg(th.mantle)),
);
f.render_widget(keybinds_widget, chunks[1]);
}
pub fn render_news(
f: &mut Frame,
app: &mut AppState,
area: Rect,
items: &[NewsFeedItem],
selected: usize,
scroll: u16,
) {
let (rect, content_lines, keybinds_rect) = prepare_news_modal(app, area, items, selected);
render_news_modal(f, rect, content_lines, keybinds_rect, app, scroll);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::state::types::NewsFeedItem;
#[test]
fn test_is_critical_news() {
assert!(is_critical_news("Critical security update"));
assert!(is_critical_news("CRITICAL: Important"));
assert!(is_critical_news("Require manual intervention"));
assert!(is_critical_news("Requires manual intervention"));
assert!(is_critical_news("Corrupting filesystem"));
assert!(!is_critical_news("Regular update"));
assert!(!is_critical_news("Minor bug fix"));
}
#[test]
fn test_compute_item_colors() {
let (fg_normal, bg_normal) = compute_item_colors(false, false);
let (fg_critical, _bg_critical) = compute_item_colors(false, true);
let (_fg_selected, bg_selected) = compute_item_colors(true, false);
let (_fg_selected_critical, bg_selected_critical) = compute_item_colors(true, true);
assert_eq!(bg_normal, None);
assert!(bg_selected.is_some());
assert!(bg_selected_critical.is_some());
assert_ne!(fg_normal, fg_critical);
}
#[test]
fn test_calculate_modal_rect() {
let area = Rect {
x: 0,
y: 0,
width: 100,
height: 30,
};
let rect = calculate_modal_rect(area);
assert_eq!(rect.width, 66);
assert_eq!(rect.x, 17);
assert_eq!(rect.y, 5);
assert_eq!(rect.height, MODAL_MAX_HEIGHT);
}
#[test]
fn test_format_news_item() {
let item = NewsFeedItem {
id: "https://example.com".to_string(),
date: "2025-01-01".to_string(),
title: "Test News".to_string(),
summary: None,
url: Some("https://example.com".to_string()),
source: crate::state::types::NewsFeedSource::ArchNews,
severity: None,
packages: Vec::new(),
};
let line = format_news_item(&item, false, false, false);
assert!(!line.spans.is_empty());
let line_critical = format_news_item(&item, false, false, true);
assert!(!line_critical.spans.is_empty());
}
#[test]
fn test_calculate_list_rect() {
let rect = Rect {
x: 10,
y: 5,
width: 50,
height: 20,
};
let (x, y, w, h) = calculate_list_rect(rect);
assert_eq!(x, rect.x + BORDER_WIDTH);
assert_eq!(y, rect.y + BORDER_WIDTH + HEADER_LINES);
assert_eq!(w, rect.width.saturating_sub(BORDER_WIDTH * 2));
assert!(h <= rect.height);
}
}