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 stories = &self.shown;
self.showing_link = self
.selected
.selected()
.and_then(|index| stories.get(index))
.map(|story| story.link.clone())
.filter(|link| !link.is_empty());
}
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 footer = Rect {
y: area.y + area.height.saturating_sub(1),
height: 1.min(area.height),
..area
};
let body = Rect {
height: area.height.saturating_sub(1),
..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 mut items: Vec<ListItem> = Vec::new();
let last = self.shown.len().saturating_sub(1);
for (index, story) in self.shown.iter().enumerate() {
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),
)));
}
if index != last {
lines.push(Line::from(""));
}
items.push(ListItem::new(lines));
}
self.drawn = items.len();
frame.render_stateful_widget(List::new(items), body, &mut self.selected);
let foot = match (&self.showing_link, &self.failing) {
(Some(link), _) => Span::styled(
crate::grid::truncate(link, width),
Style::default().fg(theme.label),
),
(None, Some(_)) => Span::styled(
format!(
"{} — refresh failing",
self.fetched
.map_or_else(|| "never read".to_string(), |at| describe_age(at.elapsed()))
),
Style::default().fg(theme.warning),
),
(None, None) => Span::styled(
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(Line::from(foot)), footer);
}
}
}
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.clone_from(name);
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::*;
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"]);
}
}