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("o", "show link"),
Binding::primary("y", "copy"),
Binding::primary("↵", "open"),
Binding::primary("r", "refresh"),
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>,
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 {
state: Arc::new(Mutex::new(State {
stories: stories.clone(),
fetched: Some(Instant::now()),
error: None,
})),
refresh: Arc::new(Mutex::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(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 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 {
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 = if self.selected.selected().is_some() {
2
} else {
0
};
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 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;
};
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 {
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;
self.action = 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::Char('y') => self.copy_link(),
KeyCode::Enter => self.open_link(),
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 = 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 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);
let mut headline_rows: Vec<(u16, u16, usize)> = Vec::new();
let mut row = body.y;
for (index, mut lines) in blocks.into_iter().take(keep).enumerate() {
if clipped {
lines.truncate(budget);
}
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 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: 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
}
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 = crate::fetch::get(url, HTTP_TIMEOUT, None)?;
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| 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_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!["cmd".into(), "/c".into(), "exit".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 {
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| 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_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}"
);
}
}