use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, PoisonError};
use std::time::{Duration, Instant};
use jiff::Zoned;
use ratatui::Frame;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers, 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::keymap::{KeysConfig, Meta, PanelKeymap};
use crate::panel::{KeyOutcome, Panel, RenderContext, describe_age};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NewsAction {
ShowLink,
Copy,
Open,
Refresh,
Up,
Down,
}
pub const ACTIONS: &[Meta<NewsAction>] = &[
Meta {
action: NewsAction::ShowLink,
name: "show_link",
defaults: &[(KeyCode::Char('o'), KeyModifiers::NONE)],
label: "show link",
primary: true,
joins: false,
about: "show the selected story's link",
},
Meta {
action: NewsAction::Copy,
name: "copy",
defaults: &[(KeyCode::Char('y'), KeyModifiers::NONE)],
label: "copy",
primary: true,
joins: false,
about: "copy the link to the clipboard",
},
Meta {
action: NewsAction::Open,
name: "open",
defaults: &[(KeyCode::Enter, KeyModifiers::NONE)],
label: "open",
primary: true,
joins: false,
about: "open the link with [news].open_command",
},
Meta {
action: NewsAction::Refresh,
name: "refresh",
defaults: &[(KeyCode::Char('r'), KeyModifiers::NONE)],
label: "refresh",
primary: true,
joins: false,
about: "fetch the feeds now",
},
Meta {
action: NewsAction::Up,
name: "up",
defaults: &[
(KeyCode::Up, KeyModifiers::NONE),
(KeyCode::Char('k'), KeyModifiers::NONE),
],
label: "select",
primary: false,
joins: false,
about: "select the story above",
},
Meta {
action: NewsAction::Down,
name: "down",
defaults: &[
(KeyCode::Down, KeyModifiers::NONE),
(KeyCode::Char('j'), KeyModifiers::NONE),
],
label: "select",
primary: false,
joins: true,
about: "select the story below",
},
];
pub fn keymap(keys: &KeysConfig) -> Result<PanelKeymap<NewsAction>, String> {
PanelKeymap::new("news", ACTIONS, keys)
}
fn default_keys() -> PanelKeymap<NewsAction> {
PanelKeymap::defaults("news", ACTIONS)
}
const USEFUL_WIDTH: u16 = 62;
const HTTP_TIMEOUT: Duration = Duration::from_secs(20);
const HIGHLIGHT_WIDTH: u16 = 2;
#[derive(Debug, Default)]
struct State {
stories: Vec<Story>,
fetched: Option<Instant>,
error: Option<String>,
}
pub struct NewsPanel {
keys: PanelKeymap<NewsAction>,
state: Arc<Mutex<State>>,
refresh: Arc<AtomicBool>,
stop: Arc<AtomicBool>,
generation: Arc<AtomicU64>,
seen: u64,
selected: ListState,
showing_link: Option<String>,
open_command: Vec<String>,
action: 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 {
#[cfg(test)]
pub(crate) fn offline(config: &NewsConfig, stories: Vec<Story>) -> Self {
Self {
keys: default_keys(),
state: Arc::new(Mutex::new(State {
stories: stories.clone(),
fetched: Some(Instant::now()),
error: None,
})),
refresh: Arc::new(AtomicBool::new(false)),
stop: Arc::new(AtomicBool::new(false)),
generation: Arc::new(AtomicU64::new(1)),
seen: 1,
selected: ListState::default(),
showing_link: None,
open_command: config.open_command.clone(),
action: None,
drawn: 0,
shown: stories,
fetched: Some(Instant::now()),
failing: None,
}
}
pub fn new(config: &NewsConfig) -> Self {
let state = Arc::new(Mutex::new(State::default()));
let refresh = Arc::new(AtomicBool::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).saturating_mul(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 open_command = config.open_command.clone();
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 {
keys: default_keys(),
state,
refresh,
stop,
generation,
seen: 0,
selected: ListState::default(),
showing_link: None,
open_command,
action: None,
drawn: 0,
shown: Vec::new(),
fetched: None,
failing: None,
}
}
fn link_headlines(&self, frame: &mut Frame, body: Rect, headline_rows: &[(u16, u16, usize)]) {
let indent = self.indent();
let buf = frame.buffer_mut();
for &(y, w, index) in headline_rows {
if y >= body.y + body.height {
continue;
}
let x0 = body.x + indent;
let x1 = (x0 + w).min(body.x + body.width);
crate::link::linkify(
buf,
y,
x0,
x1,
&self.shown[index].link,
&format!("news-{index}"),
);
}
}
fn indent(&self) -> u16 {
if self.selected.selected().is_some() {
HIGHLIGHT_WIDTH
} else {
0
}
}
fn link_under_cursor(&mut self) -> Option<String> {
let index = self.selected.selected().unwrap_or(0);
let link = self
.shown
.get(index)
.map(|story| story.link.clone())
.filter(|link| !link.is_empty())?;
self.selected.select(Some(index));
Some(link)
}
fn copy_link(&mut self) {
let Some(link) = self.link_under_cursor() else {
return;
};
self.action = Some(match crate::clipboard::copy(&link) {
Ok(()) => "sent to the clipboard — paste to check".to_string(),
Err(e) => format!("could not reach the terminal: {e}"),
});
}
fn open_link(&mut self) {
let Some(link) = self.link_under_cursor() else {
return;
};
let Some((program, rest)) = self.open_command.split_first() else {
self.action = Some("set [news].open_command to open links".to_string());
return;
};
if let Some(shell) = shell_name(program) {
self.action = Some(format!("not opened: {shell} is a shell"));
return;
}
let Some(link) = crate::link::openable(&link) else {
self.action = Some("not opened: not a web link".to_string());
return;
};
match std::process::Command::new(program)
.args(rest)
.arg(&link)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
{
Ok(mut child) => {
self.action = Some("opened".to_string());
std::thread::spawn(move || {
let _ = child.wait();
});
}
Err(e) => self.action = Some(format!("{program}: {e}")),
}
}
fn snapshot(&self) -> State {
let guard = self.state.lock().unwrap_or_else(PoisonError::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) -> &[Binding] {
self.keys.bindings()
}
fn set_keys(&mut self, config: &crate::config::Config) {
self.keys = PanelKeymap::or_defaults("news", ACTIONS, &config.news.keys);
}
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;
self.action = None;
let Some(action) = self.keys.action(key) else {
return KeyOutcome::Ignored;
};
match action {
NewsAction::Refresh => self.refresh.store(true, Ordering::Release),
NewsAction::ShowLink => {
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));
}
}
NewsAction::Copy => self.copy_link(),
NewsAction::Open => self.open_link(),
NewsAction::Down => crate::selection::down(&mut self.selected, 1, self.drawn),
NewsAction::Up => crate::selection::up(&mut self.selected, 1, self.drawn),
}
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 = footer_rows(
self.action.as_deref(),
self.showing_link.as_deref(),
area.width,
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 = empty_message(
self.failing.as_deref(),
self.fetched.is_some(),
width,
theme,
);
frame.render_widget(Paragraph::new(message), body);
self.drawn = 0;
return;
}
let drawn_width = area.width.saturating_sub(self.indent());
let blocks = fitted_blocks(
story_blocks(&self.shown, drawn_width, theme),
usize::from(body.height),
drawn_width,
);
let keep = blocks.len();
let mut items: Vec<ListItem> = Vec::with_capacity(keep);
let mut headline_rows: Vec<(u16, u16, usize)> = Vec::new();
let mut row = body.y;
for (index, mut lines) in blocks.into_iter().enumerate() {
for (offset, line) in lines.iter().enumerate().skip(1) {
headline_rows.push((row + offset as u16, line.width() as u16, index));
}
if index + 1 != keep {
lines.push(Line::from(""));
}
row += lines.len() as u16;
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,
);
self.link_headlines(frame, body, &headline_rows);
if let Some(action) = &self.action
&& footer.height > 0
{
frame.render_widget(
Paragraph::new(crate::grid::wrapped(action, area.width))
.style(Style::default().fg(theme.accent)),
footer,
);
return;
}
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 empty_message(
failing: Option<&str>,
has_read: bool,
width: usize,
theme: &crate::theme::Theme,
) -> Vec<Line<'static>> {
match 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 has_read => vec![Line::from(Span::styled(
"No stories.",
Style::default().fg(theme.muted),
))],
None => vec![Line::from(Span::styled(
"Reading…",
Style::default().fg(theme.muted),
))],
}
}
fn shell_name(program: &str) -> Option<&str> {
let name = program.rsplit(['/', '\\']).next().unwrap_or(program);
let name = name.trim_end_matches(['.', ' ']);
let stem = match name
.len()
.checked_sub(4)
.and_then(|at| Some((at, name.get(at..)?)))
{
Some((at, ext)) if ext.eq_ignore_ascii_case(".exe") => &name[..at],
_ => name,
};
["cmd", "powershell", "pwsh"]
.iter()
.any(|shell| stem.eq_ignore_ascii_case(shell))
.then_some(stem)
}
fn footer_rows(action: Option<&str>, link: Option<&str>, width: u16, height: u16) -> u16 {
let cap = (height / 2).max(1);
match (action, link) {
(Some(action), _) => crate::grid::wrapped_height(action, width).clamp(1, cap),
(None, Some(link)) => u16::try_from(link_lines(link, width).len())
.unwrap_or(u16::MAX)
.clamp(1, cap),
(None, None) => 1.min(height),
}
}
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: u16,
theme: &crate::theme::Theme,
) -> Vec<Vec<Line<'static>>> {
stories
.iter()
.map(|story| {
let parts = masthead(story, theme)
.into_iter()
.map(|span| vec![span])
.collect();
let mut lines = vec![crate::grid::assemble(parts, width)];
for line in crate::grid::wrap(&story.title, usize::from(width)) {
lines.push(Line::from(Span::styled(
line,
Style::default().fg(theme.text),
)));
}
lines
})
.collect()
}
fn fitted_blocks(
mut blocks: Vec<Vec<Line<'static>>>,
budget: usize,
width: u16,
) -> Vec<Vec<Line<'static>>> {
let keep = how_many_fit(&blocks, budget);
if keep > 0 || budget == 0 {
blocks.truncate(keep);
return blocks;
}
blocks.truncate(1);
if let Some(first) = blocks.first_mut() {
first.truncate(budget);
if let Some(last) = first.pop() {
first.push(ends_in_ellipsis(last, width));
}
}
blocks
}
fn ends_in_ellipsis(line: Line<'static>, width: u16) -> Line<'static> {
let mut spans = line.spans;
if let Some(last) = spans.last_mut() {
last.content = last.content.trim_end().to_string().into();
}
let style = spans.last().map(|span| span.style).unwrap_or_default();
spans.push(Span::styled("\u{2026}", style));
crate::grid::assemble(vec![spans], width)
}
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
}
fn fetch_loop(
feeds: &[(String, String)],
per_feed: usize,
interval: Duration,
state: &Arc<Mutex<State>>,
refresh: &AtomicBool,
stop: &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;
}
take_feed(name, read_feed(url), per_feed, &mut stories, &mut failures);
}
let stories = interleave(stories);
let replace = !stories.is_empty() || failures.is_empty();
let failed = (!failures.is_empty()).then(|| failures.join("; "));
let mut guard = state.lock().unwrap_or_else(PoisonError::into_inner);
if replace {
guard.stories = stories;
guard.fetched = Some(Instant::now());
}
guard.error = failed;
drop(guard);
generation.fetch_add(1, Ordering::Release);
let woke = crate::poll::wait(interval, stop, || refresh.swap(false, Ordering::AcqRel));
if woke == crate::poll::Wake::Stop {
return;
}
}
}
fn take_feed(
name: &str,
result: anyhow::Result<crate::feed::Feed>,
per_feed: usize,
stories: &mut Vec<Story>,
failures: &mut Vec<String>,
) {
let feed = match result {
Ok(feed) => feed,
Err(e) => {
failures.push(format!("{name}: {e:#}"));
return;
}
};
if let Some(fault) = feed.fault {
failures.push(format!("{name}: {fault}"));
}
for mut story in feed.stories.into_iter().take(per_feed) {
story.source = crate::feed::clip(name, crate::feed::MAX_SOURCE).to_string();
stories.push(story);
}
}
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<crate::feed::Feed> {
let body = crate::fetch::get(url, HTTP_TIMEOUT, None)?;
crate::feed::parse(&body)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_key_in_the_map_works_and_is_advertised() {
crate::keymap::assert_every_key_works(&default_keys(), |event| {
NewsPanel::offline(&NewsConfig::default(), Vec::new()).handle_key(event)
});
}
#[test]
fn a_refresh_asked_for_ends_the_wait_and_is_taken() {
let state = Arc::new(Mutex::new(State::default()));
let refresh = Arc::new(AtomicBool::new(true));
let stop = Arc::new(AtomicBool::new(false));
let generation = Arc::new(AtomicU64::new(0));
let thread = {
let (state, refresh, stop, generation) = (
Arc::clone(&state),
Arc::clone(&refresh),
Arc::clone(&stop),
Arc::clone(&generation),
);
std::thread::spawn(move || {
let hour = Duration::from_hours(1);
fetch_loop(&[], 1, hour, &state, &refresh, &stop, &generation);
})
};
let deadline = Instant::now() + Duration::from_secs(5);
while generation.load(Ordering::Acquire) < 2 && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(10));
}
stop.store(true, Ordering::Relaxed);
thread.join().expect("the fetch thread");
assert!(
generation.load(Ordering::Acquire) >= 2,
"the refresh did not end the wait"
);
assert!(
!refresh.load(Ordering::Acquire),
"and the request was taken"
);
}
#[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| crate::link::without_links(c.symbol()))
})
.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| crate::link::without_links(c.symbol()))
})
.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(),
}
}
fn raw_buffer(panel: &mut NewsPanel, width: u16, height: u16) -> ratatui::buffer::Buffer {
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();
terminal.backend().buffer().clone()
}
fn row_containing(buffer: &ratatui::buffer::Buffer, needle: &str) -> u16 {
let area = buffer.area;
(0..area.height)
.find(|y| {
let text: String = (0..area.width)
.filter_map(|x| {
buffer
.cell((x, *y))
.map(|c| crate::link::without_links(c.symbol()))
})
.collect();
text.contains(needle)
})
.unwrap_or_else(|| panic!("no row shows {needle:?}"))
}
#[test]
fn a_headline_is_a_link_to_its_story() {
let mut panel = NewsPanel::new(&NewsConfig {
feeds: Vec::new(),
..NewsConfig::default()
});
panel.shown = vec![
Story {
link: "https://example.test/one".into(),
..story("NASA", "Orbit story", 5)
},
Story {
link: "https://example.test/two".into(),
..story("BBC", "Second story", 10)
},
];
let buffer = raw_buffer(&mut panel, 46, 12);
let title_row = row_containing(&buffer, "Orbit story");
let first = buffer.cell((0, title_row)).unwrap();
assert!(
first
.symbol()
.starts_with("\x1b]8;id=news-0;https://example.test/one\x1b\\"),
"the first title cell must open its story's link, got {:?}",
first.symbol()
);
assert!(
first.symbol().ends_with("\x1b]8;;\x1b\\"),
"every linked cell closes its own sequence — OSC 8 is modal, and \
a cell that leaves it open leaks the link onto whatever the diff \
prints next"
);
let masthead_row = row_containing(&buffer, "NASA");
let masthead: String = (0..46)
.filter_map(|x| {
buffer
.cell((x, masthead_row))
.map(|c| c.symbol().to_string())
})
.collect();
assert!(
!masthead.contains('\x1b'),
"the masthead is not part of the link"
);
let second_row = row_containing(&buffer, "Second story");
assert!(
buffer
.cell((0, second_row))
.unwrap()
.symbol()
.contains("id=news-1;https://example.test/two"),
"each story links to its own URL under its own id"
);
}
#[test]
fn a_wrapped_headline_is_one_logical_link_across_its_rows() {
let mut panel = NewsPanel::new(&NewsConfig {
feeds: Vec::new(),
..NewsConfig::default()
});
panel.shown = vec![Story {
link: "https://example.test/long".into(),
..story(
"NASA",
"A headline comfortably long enough to wrap onto a second row",
5,
)
}];
let buffer = raw_buffer(&mut panel, 30, 12);
let first_row = row_containing(&buffer, "A headline");
let mut linked_rows = 0;
for y in first_row..buffer.area.height {
let symbol = buffer.cell((0, y)).unwrap().symbol().to_string();
if symbol.contains("]8;") {
assert!(
symbol.contains("id=news-0;https://example.test/long"),
"row {y} of the wrapped headline must carry the same id \
and URL as the first, got {symbol:?}"
);
linked_rows += 1;
}
}
assert!(
linked_rows >= 2,
"the title was meant to wrap; only {linked_rows} linked row(s) \
found — widen the fixture if wrapping changed"
);
}
#[test]
fn a_hostile_link_never_reaches_the_terminal() {
for link in [
"https://example.test/\x1b]8;;https://evil.test\x1b\\",
"https://example.test/\x07",
"file:///etc/passwd",
] {
let mut panel = NewsPanel::new(&NewsConfig {
feeds: Vec::new(),
..NewsConfig::default()
});
panel.shown = vec![Story {
link: link.into(),
..story("NASA", "An ordinary headline", 5)
}];
let buffer = raw_buffer(&mut panel, 46, 12);
let title_row = row_containing(&buffer, "An ordinary headline");
for x in 0..46 {
let symbol = buffer.cell((x, title_row)).unwrap().symbol().to_string();
assert!(
!symbol.contains('\x1b'),
"link {link:?} must not reach the terminal; cell {x} \
holds {symbol:?}"
);
}
}
}
#[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"
);
}
#[test]
fn enter_launches_nothing_until_a_command_is_named() {
let mut panel = loaded_panel();
assert!(panel.open_command.is_empty(), "the default is empty");
panel.handle_key(KeyEvent::from(KeyCode::Enter));
assert_eq!(
panel.action.as_deref(),
Some("set [news].open_command to open links"),
"it should say how to switch it on, not fail silently"
);
}
#[test]
fn a_named_command_runs_with_the_link_as_its_own_argument() {
let mut panel = loaded_panel();
panel.open_command = harmless_command();
panel.handle_key(KeyEvent::from(KeyCode::Enter));
assert_eq!(
panel.action.as_deref(),
Some("opened"),
"the spawn should have succeeded; got {:?}",
panel.action
);
}
#[test]
fn a_link_that_is_not_a_web_address_is_not_opened() {
for link in [
"file:///etc/passwd",
"smb://evil.test/share",
"javascript:alert(1)",
"-flag-for-the-opener",
"https://example.test/\x07",
"https://example.test/\x1b]8;;evil",
] {
let mut panel = loaded_panel();
panel.shown[0].link = link.into();
panel.open_command = harmless_command();
panel.handle_key(KeyEvent::from(KeyCode::Enter));
assert_eq!(
panel.action.as_deref(),
Some("not opened: not a web link"),
"{link:?}"
);
}
}
#[test]
fn a_web_address_in_another_script_still_opens() {
for link in [
"https://de.wikipedia.org/wiki/Gödel",
"HTTPS://example.test/a",
] {
let mut panel = loaded_panel();
panel.shown[0].link = link.into();
panel.open_command = harmless_command();
panel.handle_key(KeyEvent::from(KeyCode::Enter));
assert_eq!(panel.action.as_deref(), Some("opened"), "{link:?}");
}
}
#[test]
fn a_shell_named_as_the_opener_is_refused() {
for (program, said) in [
("cmd", "cmd"),
("CMD.EXE", "CMD"),
("C:\\Windows\\System32\\cmd.exe", "cmd"),
("powershell", "powershell"),
("/usr/local/bin/pwsh", "pwsh"),
("cmd.exe. ", "cmd"),
] {
let mut panel = loaded_panel();
panel.open_command = vec![program.into(), "/c".into(), "start".into()];
panel.handle_key(KeyEvent::from(KeyCode::Enter));
assert_eq!(
panel.action.as_deref(),
Some(format!("not opened: {said} is a shell").as_str()),
"{program}"
);
}
}
#[test]
fn a_command_that_does_not_exist_reports_itself() {
let mut panel = loaded_panel();
panel.open_command = vec!["mirador-no-such-program".into()];
panel.handle_key(KeyEvent::from(KeyCode::Enter));
let said = panel.action.clone().unwrap_or_default();
assert!(
said.starts_with("mirador-no-such-program:"),
"the failure should name the program: {said:?}"
);
}
#[test]
fn copying_does_not_claim_more_than_it_knows() {
let mut panel = loaded_panel();
panel.handle_key(KeyEvent::from(KeyCode::Char('y')));
let said = panel.action.clone().unwrap_or_default();
assert!(
!said.starts_with("copied"),
"the terminal may have ignored it; do not assert success: {said:?}"
);
assert!(
said.contains("clipboard"),
"but it should say what was attempted: {said:?}"
);
}
#[test]
fn copy_and_open_act_on_the_story_the_cursor_marks() {
let mut panel = loaded_panel();
draw(&mut panel, 46, 20);
panel.handle_key(KeyEvent::from(KeyCode::Char('j')));
let marked = panel.selected.selected().expect("cursor placed");
panel.handle_key(KeyEvent::from(KeyCode::Char('o')));
assert_eq!(
panel.showing_link.as_deref(),
Some(panel.shown[marked].link.as_str()),
"`o` shows the marked story"
);
panel.open_command = harmless_command();
panel.handle_key(KeyEvent::from(KeyCode::Enter));
assert_eq!(
panel.selected.selected(),
Some(marked),
"`Enter` must not move the cursor to a different story"
);
}
fn harmless_command() -> Vec<String> {
if cfg!(windows) {
vec!["whoami".into()]
} else {
vec!["true".into()]
}
}
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 {
let buffer = crate::widgets::testing::rendered(panel, width, height);
(0..height)
.map(|y| {
(0..width)
.filter_map(|x| {
buffer
.cell((x, y))
.map(|c| crate::link::without_links(c.symbol()))
})
.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_pass_that_found_nothing_says_so_rather_than_reading_for_ever() {
let mut panel = NewsPanel::new(&NewsConfig {
feeds: Vec::new(),
..NewsConfig::default()
});
let deadline = Instant::now() + Duration::from_secs(5);
while !panel.tick() {
assert!(
Instant::now() < deadline,
"the fetch thread never finished its pass"
);
std::thread::sleep(Duration::from_millis(5));
}
let screen = draw(&mut panel, 46, 6);
assert!(
screen.contains("No stories."),
"a finished pass with nothing in it is a quiet day, got:\n{screen}"
);
assert!(
!screen.contains("Reading"),
"the pass has finished, so nothing is being read:\n{screen}"
);
}
#[test]
fn a_feed_name_is_bounded_where_the_panel_names_it() {
let name = "Ω".repeat(crate::feed::MAX_SOURCE + 5);
let feed = "<rss><channel><item><title>T</title></item></channel></rss>";
let mut stories = Vec::new();
take_feed(
&name,
crate::feed::parse(feed),
12,
&mut stories,
&mut Vec::new(),
);
assert_eq!(stories[0].source, "Ω".repeat(crate::feed::MAX_SOURCE));
}
#[test]
fn a_partly_readable_feed_is_shown_and_still_reported() {
let broken = "<rss><channel>\
<item><title>First</title></item>\
<item><title>Second</title></item>\
<item><title>Third</titel></item>\
</channel></rss>";
let atom = r#"<feed xmlns="http://www.w3.org/2005/Atom"><entry/></feed>"#;
let mut stories = Vec::new();
let mut failures = Vec::new();
take_feed(
"NASA",
crate::feed::parse(broken),
12,
&mut stories,
&mut failures,
);
take_feed(
"RELEASES",
crate::feed::parse(atom),
12,
&mut stories,
&mut failures,
);
let shown: Vec<(&str, &str)> = stories
.iter()
.map(|s| (s.source.as_str(), s.title.as_str()))
.collect();
assert_eq!(shown, [("NASA", "First"), ("NASA", "Second")]);
assert_eq!(failures.len(), 2, "{failures:?}");
assert!(
failures[0].starts_with("NASA: partly unreadable"),
"{failures:?}"
);
assert!(
failures[1].starts_with("RELEASES: ") && failures[1].contains("Atom"),
"{failures:?}"
);
}
#[test]
fn no_headline_is_cut_silently_once_a_story_is_selected() {
let stories = [
(
"NASA",
"Anak Krakatau rumbles again as satellites watch the plume",
),
(
"PHYS.ORG",
"Some black holes grow much faster than their galaxies",
),
(
"ARS TECHNICA",
"A very long headline that has to wrap on any panel narrower than it",
),
]
.into_iter()
.enumerate()
.map(|(n, (source, title))| Story {
link: format!("https://example.test/story-{n}"),
..story(source, title, 10)
})
.collect();
let mut panel = NewsPanel::offline(&NewsConfig::default(), stories);
panel.selected.select(Some(0));
let height = 14u16;
let cells = |panel: &mut NewsPanel, width: u16| -> Vec<Vec<String>> {
let buffer = raw_buffer(panel, width, height);
(0..height)
.map(|y| {
(0..width)
.map(|x| crate::link::without_links(buffer[(x, y)].symbol()))
.collect()
})
.collect()
};
let mut findings = Vec::new();
let mut prev = cells(&mut panel, 6);
for width in 7..=100u16 {
let cur = cells(&mut panel, width);
let w = usize::from(width) - 1;
for (y, (narrow, wide)) in prev.iter().zip(&cur).enumerate() {
let narrow_text: String = narrow.concat();
let overflowed = wide[w].trim() != "";
let same_prefix = narrow[..] == wide[..w];
let marked = narrow_text.trim_end().ends_with('\u{2026}');
let mid_token = narrow[w - 1].trim() != "";
let wrapped = prev
.get(y + 1)
.is_some_and(|next| next.concat().trim_start().starts_with(wide[w].as_str()));
if overflowed && same_prefix && !marked && mid_token && !wrapped {
findings.push(format!(
"width {w} row {y}: {:?} continues as {:?}",
narrow_text.trim_end(),
wide[w]
));
}
}
prev = cur;
}
assert!(
panel.selected.selected().is_some(),
"the sweep must have run with a story selected"
);
assert!(
findings.is_empty(),
"{} silent cut(s) with a story selected:\n {}",
findings.len(),
findings.join("\n ")
);
}
#[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}"
);
}
#[test]
fn a_headline_taller_than_the_panel_ends_in_an_ellipsis() {
const TITLE: &str = "Anak Krakatau rumbles again as satellites watch the plume from orbit";
for selected in [false, true] {
let mut panel =
NewsPanel::offline(&NewsConfig::default(), vec![story("NASA", TITLE, 5)]);
if selected {
panel.selected.select(Some(0));
}
let indent = if selected { HIGHLIGHT_WIDTH } else { 0 };
for height in 2..=5u16 {
let body = usize::from(height) - 1;
let (mut cut, mut whole) = (0, 0);
for width in 12..=120u16 {
let screen = draw(&mut panel, width, height);
let rows: Vec<&str> = screen.lines().collect();
let last = rows[body - 1].trim_end();
let title_rows = crate::grid::wrap(TITLE, usize::from(width - indent)).len();
if 1 + title_rows > body {
cut += 1;
assert!(
last.ends_with('\u{2026}'),
"a headline needing {title_rows} rows was cut to {} without \
saying so at {width}x{height}:\n{screen}",
body - 1
);
} else {
whole += 1;
assert!(
!screen.contains('\u{2026}'),
"a headline that fits must not claim to be cut at \
{width}x{height}:\n{screen}"
);
}
}
assert!(
cut > 0 && (whole > 0 || body == 1),
"the sweep must reach both a cut and a whole headline at height \
{height}: cut {cut}, whole {whole}"
);
}
}
}
}