use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use jiff::Zoned;
use ratatui::Frame;
use ratatui::crossterm::event::{KeyCode, KeyEvent, MouseEvent, MouseEventKind};
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{List, ListItem, ListState, Paragraph};
use crate::config::NewsConfig;
use crate::feed::Story;
use crate::frame::{Binding, FRAME_WIDTH};
use crate::panel::{KeyOutcome, Panel, RenderContext, describe_age};
const BINDINGS: &[Binding] = &[
Binding::primary("r", "refresh"),
Binding::primary("o", "show link"),
Binding::extra("↑ / ↓", "select"),
Binding::extra("j / k", "select"),
];
const USEFUL_WIDTH: u16 = 62;
const HTTP_TIMEOUT: Duration = Duration::from_secs(20);
#[derive(Debug, Default)]
struct State {
stories: Vec<Story>,
fetched: Option<Instant>,
error: Option<String>,
}
pub struct NewsPanel {
state: Arc<Mutex<State>>,
refresh: Arc<Mutex<bool>>,
stop: Arc<AtomicBool>,
generation: Arc<AtomicU64>,
seen: u64,
selected: ListState,
showing_link: Option<String>,
drawn: usize,
shown: Vec<Story>,
fetched: Option<Instant>,
failing: Option<String>,
}
impl Drop for NewsPanel {
fn drop(&mut self) {
self.stop.store(true, Ordering::Relaxed);
}
}
impl NewsPanel {
pub fn new(config: &NewsConfig) -> Self {
let state = Arc::new(Mutex::new(State::default()));
let refresh = Arc::new(Mutex::new(false));
let stop = Arc::new(AtomicBool::new(false));
let generation = Arc::new(AtomicU64::new(0));
let interval = Duration::from_secs(config.refresh_minutes.max(60) * 60);
let feeds: Vec<(String, String)> = config
.feeds
.iter()
.filter(|feed| !feed.url.trim().is_empty())
.map(|feed| (feed.name.clone(), feed.url.clone()))
.collect();
let per_feed = config.per_feed.clamp(1, 20);
let shared = (
Arc::clone(&state),
Arc::clone(&refresh),
Arc::clone(&stop),
Arc::clone(&generation),
);
std::thread::Builder::new()
.name("mirador-news".into())
.spawn(move || {
let (state, refresh, stop, generation) = shared;
fetch_loop(
&feeds,
per_feed,
interval,
&state,
&refresh,
&stop,
&generation,
);
})
.expect("spawning the news thread");
Self {
state,
refresh,
stop,
generation,
seen: 0,
selected: ListState::default(),
showing_link: None,
drawn: 0,
shown: Vec::new(),
fetched: None,
failing: None,
}
}
fn snapshot(&self) -> State {
match self.state.lock() {
Ok(guard) => State {
stories: guard.stories.clone(),
fetched: guard.fetched,
error: guard.error.clone(),
},
Err(poisoned) => {
let guard = poisoned.into_inner();
State {
stories: guard.stories.clone(),
fetched: guard.fetched,
error: guard.error.clone(),
}
}
}
}
}
impl Panel for NewsPanel {
fn title(&self) -> String {
"News".into()
}
fn counter(&self) -> Option<String> {
None
}
fn bindings(&self) -> &'static [Binding] {
BINDINGS
}
fn max_width(&self) -> Option<u16> {
Some(USEFUL_WIDTH + FRAME_WIDTH)
}
fn refresh_interval(&self) -> Duration {
Duration::from_secs(20)
}
fn tick(&mut self) -> bool {
let now = self.generation.load(Ordering::Acquire);
let moved = now != self.seen;
self.seen = now;
if moved {
let state = self.snapshot();
self.shown = state.stories;
self.fetched = state.fetched;
self.failing = state.error;
}
moved
}
fn handle_key(&mut self, key: KeyEvent) -> KeyOutcome {
self.showing_link = None;
match key.code {
KeyCode::Char('r') => {
if let Ok(mut flag) = self.refresh.lock() {
*flag = true;
}
}
KeyCode::Char('o') => {
let index = self.selected.selected().unwrap_or(0);
self.showing_link = self
.shown
.get(index)
.map(|story| story.link.clone())
.filter(|link| !link.is_empty());
if self.showing_link.is_some() {
self.selected.select(Some(index));
}
}
KeyCode::Down | KeyCode::Char('j') => {
crate::selection::down(&mut self.selected, 1, self.drawn);
}
KeyCode::Up | KeyCode::Char('k') => {
crate::selection::up(&mut self.selected, 1, self.drawn);
}
_ => return KeyOutcome::Ignored,
}
KeyOutcome::Consumed
}
fn handle_mouse(&mut self, event: MouseEvent, _area: Rect) -> KeyOutcome {
match event.kind {
MouseEventKind::ScrollDown => {
crate::selection::down(&mut self.selected, 1, self.drawn);
}
MouseEventKind::ScrollUp => crate::selection::up(&mut self.selected, 1, self.drawn),
_ => return KeyOutcome::Ignored,
}
KeyOutcome::Consumed
}
fn shutdown(&mut self) {
self.stop.store(true, Ordering::Relaxed);
}
fn render(&mut self, frame: &mut Frame, area: Rect, ctx: RenderContext<'_>) {
let theme = ctx.theme;
if area.width == 0 || area.height == 0 {
return;
}
let width = usize::from(area.width);
let foot_rows = match &self.showing_link {
Some(link) => u16::try_from(link_lines(link, area.width).len())
.unwrap_or(u16::MAX)
.clamp(1, (area.height / 2).max(1)),
None => 1.min(area.height),
};
let footer = Rect {
y: area.y + area.height.saturating_sub(foot_rows),
height: foot_rows,
..area
};
let body = Rect {
height: area.height.saturating_sub(foot_rows),
..area
};
if self.shown.is_empty() {
let message = match &self.failing {
Some(why) => vec![
Line::from(Span::styled(
"Cannot read the feeds",
Style::default()
.fg(theme.error)
.add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled(
crate::grid::truncate(why, width),
Style::default().fg(theme.muted),
)),
],
None if self.fetched.is_some() => vec![Line::from(Span::styled(
"No stories.",
Style::default().fg(theme.muted),
))],
None => vec![Line::from(Span::styled(
"Reading…",
Style::default().fg(theme.muted),
))],
};
frame.render_widget(Paragraph::new(message), body);
self.drawn = 0;
return;
}
let blocks = story_blocks(&self.shown, width, theme);
let budget = usize::from(body.height);
let mut keep = how_many_fit(&blocks, budget);
let clipped = keep == 0 && budget > 0;
if clipped {
keep = 1;
}
let mut items: Vec<ListItem> = Vec::with_capacity(keep);
for (index, mut lines) in blocks.into_iter().take(keep).enumerate() {
if clipped {
lines.truncate(budget);
}
if index + 1 != keep {
lines.push(Line::from(""));
}
items.push(ListItem::new(lines));
}
self.drawn = items.len();
frame.render_stateful_widget(
List::new(items)
.highlight_symbol(if ctx.focused { "▸ " } else { " " })
.highlight_style(if ctx.focused {
Style::default().add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.muted)
}),
body,
&mut self.selected,
);
let (foot, foot_style) = match (&self.showing_link, &self.failing) {
(Some(link), _) => (
link_lines(link, area.width).join("\n"),
Style::default().fg(theme.accent),
),
(None, Some(_)) => (
format!(
"{} — refresh failing",
self.fetched
.map_or_else(|| "never read".to_string(), |at| describe_age(at.elapsed()))
),
Style::default().fg(theme.warning),
),
(None, None) => (
self.fetched
.map_or_else(String::new, |at| describe_age(at.elapsed())),
Style::default().fg(theme.muted),
),
};
if footer.height > 0 {
frame.render_widget(Paragraph::new(foot).style(foot_style), footer);
}
}
}
fn link_lines(link: &str, width: u16) -> Vec<String> {
const MARK: &str = "↳ ";
let indent = " ".repeat(crate::grid::display_width(MARK));
let avail = usize::from(width).saturating_sub(indent.len()).max(1);
crate::grid::wrap(link, avail)
.into_iter()
.enumerate()
.map(|(row, line)| {
if row == 0 {
format!("{MARK}{line}")
} else {
format!("{indent}{line}")
}
})
.collect()
}
fn story_blocks(
stories: &[Story],
width: usize,
theme: &crate::theme::Theme,
) -> Vec<Vec<Line<'static>>> {
stories
.iter()
.map(|story| {
let mut lines = vec![Line::from(masthead(story, theme))];
for line in crate::grid::wrap(&story.title, width) {
lines.push(Line::from(Span::styled(
line,
Style::default().fg(theme.text),
)));
}
lines
})
.collect()
}
fn how_many_fit(blocks: &[Vec<Line<'static>>], budget: usize) -> usize {
let mut used = 0usize;
let mut keep = 0usize;
for block in blocks {
let cost = block.len() + usize::from(keep > 0);
if used + cost > budget {
break;
}
used += cost;
keep += 1;
}
keep
}
fn masthead(story: &Story, theme: &crate::theme::Theme) -> Vec<Span<'static>> {
let mut spans = vec![Span::styled(
crate::glyphs::utility(&story.source),
Style::default()
.fg(theme.label)
.add_modifier(Modifier::BOLD),
)];
if let Some(at) = &story.published {
let age = Zoned::now().duration_since(at);
if let Ok(age) = std::time::Duration::try_from(age) {
spans.push(Span::styled(
format!(" · {}", describe_age(age)),
Style::default().fg(theme.muted),
));
}
}
spans
}
#[allow(clippy::too_many_arguments)]
fn fetch_loop(
feeds: &[(String, String)],
per_feed: usize,
interval: Duration,
state: &Arc<Mutex<State>>,
refresh: &Arc<Mutex<bool>>,
stop: &Arc<AtomicBool>,
generation: &Arc<AtomicU64>,
) {
while !stop.load(Ordering::Relaxed) {
let mut stories = Vec::new();
let mut failures = Vec::new();
for (name, url) in feeds {
if stop.load(Ordering::Relaxed) {
return;
}
match read_feed(url) {
Ok(mut found) => {
found.truncate(per_feed);
for mut story in found {
story.source = name.chars().take(crate::feed::MAX_SOURCE).collect();
stories.push(story);
}
}
Err(e) => failures.push(format!("{name}: {e:#}")),
}
}
let stories = interleave(stories);
let failed = (!failures.is_empty()).then(|| failures.join("; "));
match state.lock() {
Ok(mut guard) => {
if !stories.is_empty() {
guard.stories = stories;
guard.fetched = Some(Instant::now());
}
guard.error = failed;
}
Err(poisoned) => {
let mut guard = poisoned.into_inner();
if !stories.is_empty() {
guard.stories = stories;
guard.fetched = Some(Instant::now());
}
guard.error = failed;
}
}
generation.fetch_add(1, Ordering::Release);
let woke = crate::poll::wait(interval, stop, || match refresh.lock() {
Ok(mut flag) => std::mem::replace(&mut *flag, false),
Err(poisoned) => std::mem::replace(&mut *poisoned.into_inner(), false),
});
if woke == crate::poll::Wake::Stop {
return;
}
}
}
fn interleave(stories: Vec<Story>) -> Vec<Story> {
let mut by_source: Vec<Vec<Story>> = Vec::new();
for story in stories {
match by_source
.iter_mut()
.find(|group| group.first().is_some_and(|s| s.source == story.source))
{
Some(group) => group.push(story),
None => by_source.push(vec![story]),
}
}
for group in &mut by_source {
group.sort_by(|a, b| match (&b.published, &a.published) {
(Some(later), Some(earlier)) => later.cmp(earlier),
(Some(_), None) => std::cmp::Ordering::Greater,
(None, Some(_)) => std::cmp::Ordering::Less,
(None, None) => std::cmp::Ordering::Equal,
});
}
let deepest = by_source.iter().map(Vec::len).max().unwrap_or(0);
let mut out = Vec::new();
for rank in 0..deepest {
for group in &by_source {
if let Some(story) = group.get(rank) {
out.push(story.clone());
}
}
}
out
}
fn read_feed(url: &str) -> anyhow::Result<Vec<Story>> {
let body = ureq::get(url)
.config()
.timeout_global(Some(HTTP_TIMEOUT))
.build()
.call()?
.body_mut()
.read_to_string()?;
crate::feed::parse(&body)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_selected_story_is_visibly_marked() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let config = crate::config::Config::default();
let gradients = config.theme.gradients();
let mut panel = NewsPanel::new(&NewsConfig {
feeds: Vec::new(),
..NewsConfig::default()
});
panel.shown = vec![
story("NASA", "First story, the one selected", 10),
story("PHYS.ORG", "Second story", 20),
];
panel.selected.select(Some(1));
let draw = |panel: &mut NewsPanel, focused: bool| -> String {
let mut terminal = Terminal::new(TestBackend::new(46, 12)).unwrap();
terminal
.draw(|frame| {
panel.render(
frame,
frame.area(),
RenderContext {
theme: &config.theme,
gradients: &gradients,
focused,
watch: &crate::watch::WatchLog::default(),
},
);
})
.unwrap();
let buffer = terminal.backend().buffer().clone();
(0..12)
.map(|y| {
(0..46)
.filter_map(|x| buffer.cell((x, y)).map(|c| c.symbol().to_string()))
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
};
let focused = draw(&mut panel, true);
assert!(
focused.contains('\u{25B8}'),
"the focused panel must mark which story the cursor is on:\n{focused}"
);
let unfocused = draw(&mut panel, false);
assert!(
!unfocused.contains('\u{25B8}'),
"an unfocused panel should not keep pointing:\n{unfocused}"
);
}
#[test]
fn the_whole_link_is_shown_rather_than_truncated() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::crossterm::event::KeyModifiers;
let config = crate::config::Config::default();
let gradients = config.theme.gradients();
let link = "https://arstechnica.com/security/2026/07/mythos-attack-on-3rd-round-pqc-algorithm-puts-it-out-of-commission/";
let mut panel = NewsPanel::new(&NewsConfig {
feeds: Vec::new(),
..NewsConfig::default()
});
panel.shown = vec![Story {
source: "Ars Technica".into(),
title: "Mythos attack on 3rd-round PQC algorithm".into(),
link: link.into(),
published: Zoned::now().checked_sub(jiff::Span::new().minutes(30)).ok(),
}];
panel.selected.select(Some(0));
panel.handle_key(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE));
for (width, height) in [(40u16, 14u16), (56, 18), (72, 12), (100, 20)] {
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
terminal
.draw(|frame| {
panel.render(
frame,
frame.area(),
RenderContext {
theme: &config.theme,
gradients: &gradients,
focused: true,
watch: &crate::watch::WatchLog::default(),
},
);
})
.unwrap();
let buffer = terminal.backend().buffer().clone();
let screen: String = (0..height)
.flat_map(|y| (0..width).map(move |x| (x, y)))
.filter_map(|(x, y)| buffer.cell((x, y)).map(|c| c.symbol().to_string()))
.collect();
let squashed: String = screen.chars().filter(|c| !c.is_whitespace()).collect();
assert!(
!squashed.contains('\u{2026}'),
"the link was truncated with an ellipsis at {width}x{height}"
);
assert!(
squashed.contains(&link.replace(' ', "")),
"the whole link should be on screen at {width}x{height}"
);
}
}
fn story(source: &str, title: &str, minutes_ago: i64) -> Story {
Story {
source: source.into(),
title: title.into(),
link: String::new(),
published: Zoned::now()
.checked_sub(jiff::Span::new().minutes(minutes_ago))
.ok(),
}
}
#[test]
fn the_top_of_the_panel_holds_one_story_from_each_feed() {
let mixed = interleave(vec![
story("PHYS", "phys newest", 5),
story("PHYS", "phys second", 10),
story("PHYS", "phys third", 15),
story("NASA", "nasa newest", 60),
story("ARS", "ars newest", 90),
]);
let first_three: Vec<&str> = mixed.iter().take(3).map(|s| s.source.as_str()).collect();
assert_eq!(first_three, ["PHYS", "NASA", "ARS"], "one from each first");
assert_eq!(
mixed[0].title, "phys newest",
"and the newest within a feed"
);
assert_eq!(mixed[3].title, "phys second", "then the second round");
}
#[test]
fn an_undated_story_goes_last_within_its_feed() {
let undated = Story {
source: "PHYS".into(),
title: "undated".into(),
link: String::new(),
published: None,
};
let ordered = interleave(vec![
undated,
story("PHYS", "older", 90),
story("PHYS", "newer", 5),
]);
let titles: Vec<&str> = ordered.iter().map(|s| s.title.as_str()).collect();
assert_eq!(titles, ["newer", "older", "undated"]);
}
#[test]
fn the_fitting_rule_counts_the_separators_between_stories() {
let block = |rows: usize| vec![Line::from(""); rows];
let three = vec![block(2), block(2), block(2)];
assert_eq!(how_many_fit(&three, 2), 1, "one story, no separator");
assert_eq!(
how_many_fit(&three, 4),
1,
"two need a separator: 2+1+2 = 5"
);
assert_eq!(how_many_fit(&three, 5), 2, "exactly two");
assert_eq!(how_many_fit(&three, 7), 2, "three need 2+1+2+1+2 = 8");
assert_eq!(how_many_fit(&three, 8), 3, "exactly three");
assert_eq!(how_many_fit(&three, 99), 3, "never more than there are");
assert_eq!(how_many_fit(&three, 0), 0, "no room, no stories");
assert_eq!(how_many_fit(&three, 1), 0, "not even one block fits");
assert_eq!(how_many_fit(&[], 10), 0, "nothing to fit");
}
#[test]
fn the_link_is_not_the_colour_every_masthead_already_wears() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let config = crate::config::Config::default();
let gradients = config.theme.gradients();
let mut panel = loaded_panel();
panel.handle_key(KeyEvent::from(KeyCode::Char('o')));
let (w, h) = (46u16, 10u16);
let mut terminal = Terminal::new(TestBackend::new(w, h)).unwrap();
terminal
.draw(|frame| {
panel.render(
frame,
frame.area(),
RenderContext {
theme: &config.theme,
gradients: &gradients,
focused: true,
watch: &crate::watch::WatchLog::default(),
},
);
})
.unwrap();
let buffer = terminal.backend().buffer().clone();
let link_row = (0..h)
.find(|y| (0..w).any(|x| buffer.cell((x, *y)).is_some_and(|c| c.symbol() == "↳")))
.expect("the link should be on screen, marked with ↳");
let link_colour = buffer
.cell((1, link_row))
.and_then(|c| c.style().fg)
.expect("the link is coloured");
let masthead_colour = masthead(&panel.shown[0], &config.theme)
.first()
.and_then(|span| span.style.fg)
.expect("the masthead is coloured");
assert_ne!(
link_colour, masthead_colour,
"the link is drawn in the masthead's own colour, so it reads as more \
furniture — which is what #117 was filed about"
);
assert_eq!(
link_colour, config.theme.accent,
"and it should be brass, the dashboard's attention colour"
);
}
#[test]
fn the_link_mark_is_one_cell_wide() {
assert_eq!(crate::grid::display_width("↳"), 1);
}
#[test]
fn the_link_is_never_clipped_by_a_footer_sized_from_a_different_rule() {
let link = "https://arstechnica.com/science/2026/07/\
if-a-quantum-computer-outperforms-normal-ones-can-you-tell/";
for width in 20..80u16 {
let lines = link_lines(link, width);
for line in &lines {
assert!(
crate::grid::display_width(line) <= usize::from(width),
"a link row overflowed at width {width}: {line:?}"
);
}
let rejoined: String = lines
.iter()
.map(|l| l.trim_start_matches(['↳', ' ']))
.collect();
assert_eq!(
rejoined.replace('-', ""),
link.replace(['-', ' '], ""),
"the link lost characters at width {width}"
);
}
}
#[test]
fn o_shows_the_top_story_before_the_cursor_has_been_moved() {
let mut panel = loaded_panel();
assert_eq!(panel.selected.selected(), None, "nothing selected yet");
panel.handle_key(KeyEvent::from(KeyCode::Char('o')));
assert_eq!(
panel.showing_link.as_deref(),
Some(panel.shown[0].link.as_str()),
"the first press should show the top story's link"
);
assert_eq!(
panel.selected.selected(),
Some(0),
"and mark the story it belongs to"
);
}
fn loaded_panel() -> NewsPanel {
let mut panel = NewsPanel::new(&NewsConfig {
feeds: Vec::new(),
..NewsConfig::default()
});
panel.shown = (0..12)
.map(|n| Story {
link: format!("https://example.test/story-{n}"),
..story("NASA", &format!("Story number {n}"), n * 10)
})
.collect();
panel
}
fn draw(panel: &mut NewsPanel, width: u16, height: u16) -> String {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let config = crate::config::Config::default();
let gradients = config.theme.gradients();
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
terminal
.draw(|frame| {
panel.render(
frame,
frame.area(),
RenderContext {
theme: &config.theme,
gradients: &gradients,
focused: true,
watch: &crate::watch::WatchLog::default(),
},
);
})
.unwrap();
let buffer = terminal.backend().buffer().clone();
(0..height)
.map(|y| {
(0..width)
.filter_map(|x| buffer.cell((x, y)).map(|c| c.symbol().to_string()))
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn the_panel_builds_only_the_stories_that_fit() {
let mut panel = loaded_panel();
draw(&mut panel, 46, 10);
let short = panel.drawn;
assert!(
short > 0 && short < 12,
"a 10-row panel should hold some of twelve stories, held {short}"
);
draw(&mut panel, 46, 30);
assert!(
panel.drawn > short,
"a taller panel must show more, not the same {short}"
);
}
#[test]
fn the_cursor_cannot_leave_the_visible_stories() {
let mut panel = loaded_panel();
let visible = {
draw(&mut panel, 46, 10);
panel.drawn
};
for _ in 0..50 {
panel.handle_key(KeyEvent::from(KeyCode::Char('j')));
}
assert!(
panel.selected.selected().unwrap_or(0) < visible,
"the cursor reached {:?} with only {visible} stories on screen",
panel.selected.selected()
);
let after = draw(&mut panel, 46, 10);
assert!(
after.contains("Story number 0"),
"the first story scrolled away; got:\n{after}"
);
}
#[test]
fn the_panel_never_offers_a_counter() {
let mut panel = loaded_panel();
assert_eq!(panel.counter(), None, "no counter in the frame");
let screen = draw(&mut panel, 46, 10);
for claim in ["12", "new", "more", "of 12"] {
assert!(
!screen.contains(claim),
"the panel said `{claim}`, which counts or promises more below:\n{screen}"
);
}
}
#[test]
fn a_story_too_tall_for_the_panel_is_clipped_rather_than_dropped() {
let mut panel = NewsPanel::new(&NewsConfig {
feeds: Vec::new(),
..NewsConfig::default()
});
panel.shown = vec![story("NASA", &"a very long headline ".repeat(20), 5)];
let screen = draw(&mut panel, 24, 4);
assert_eq!(panel.drawn, 1, "the only story must still be built");
assert!(
screen.contains("NASA"),
"nothing was drawn at all; got:\n{screen}"
);
}
}