pub mod agenda;
pub mod calculator;
pub mod calendar;
pub mod clocks;
pub mod cpu;
pub mod network;
pub mod news;
pub mod notes;
pub mod pomodoro;
pub mod stocks;
pub mod todo;
pub mod watchlog;
pub mod weather;
use anyhow::Result;
use crate::config::Config;
use crate::panel::Panel;
pub const WIDGET_NAMES: &[&str] = &[
"clocks",
"weather",
"todo",
"notes",
"stocks",
"calendar",
"agenda",
"pomodoro",
"watchlog",
"news",
"cpu",
"network",
"calculator",
];
pub fn is_known_widget(name: &str) -> bool {
WIDGET_NAMES.contains(&name)
}
pub fn build(name: &str, config: &Config) -> Result<Option<Box<dyn Panel>>> {
let panel: Box<dyn Panel> = match name {
"clocks" => Box::new(clocks::ClocksPanel::new(
config.clocks.clone(),
crate::config::Config::zones_path()?,
)?),
"weather" => Box::new(weather::WeatherPanel::new(config.weather.clone())),
"todo" => Box::new(todo::TodoPanel::new(
config.todo.clone(),
config.todo_path()?,
)?),
"notes" => Box::new(notes::NotesPanel::new(
config.notes.clone(),
config.notes_path()?,
)?),
"stocks" => Box::new(stocks::StocksPanel::new(
config.stocks.clone(),
config.stocks_path()?,
)?),
"calendar" => Box::new(calendar::CalendarPanel::new(config.calendar.clone())),
"watchlog" => Box::new(watchlog::WatchLogPanel::new()),
"news" => Box::new(news::NewsPanel::new(&config.news)),
"agenda" => Box::new(agenda::AgendaPanel::new(
&config.agenda,
config.agenda_path()?,
)),
"pomodoro" => Box::new(pomodoro::PomodoroPanel::new(config.pomodoro.clone())),
"cpu" => Box::new(cpu::CpuPanel::new(config.cpu.clone())),
"network" => Box::new(network::NetworkPanel::new(config.network.clone())),
"calculator" => Box::new(calculator::CalculatorPanel::new(config.calculator)),
_ => {
let Some(plugin) = config.plugin(name) else {
return Ok(None);
};
Box::new(crate::plugin::PluginPanel::new(plugin.clone()))
}
};
Ok(Some(panel))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_advertised_widget_is_recognised() {
for name in WIDGET_NAMES {
assert!(is_known_widget(name), "{name} is advertised but unknown");
}
}
#[test]
fn unknown_widgets_are_rejected() {
assert!(!is_known_widget("nope"));
assert!(!is_known_widget(""));
assert!(!is_known_widget("CLOCKS"), "matching must be exact");
}
#[test]
fn every_widget_renders_at_any_size_without_panicking() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let dir = std::env::temp_dir().join(format!("mirador-render-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut config = Config::default();
config.todo.file = Some(dir.join("todos.toml"));
config.weather.latitude = Some(42.36);
config.weather.longitude = Some(-71.06);
for name in WIDGET_NAMES {
let mut panel = build(name, &config)
.unwrap_or_else(|e| panic!("building `{name}` failed: {e:#}"))
.unwrap_or_else(|| panic!("`{name}` is advertised but did not build"));
panel.tick();
let gradients = config.theme.gradients();
for (width, height) in [(1, 1), (2, 3), (10, 4), (40, 12), (200, 60)] {
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
terminal
.draw(|frame| {
let area = frame.area();
panel.render(
frame,
area,
crate::panel::RenderContext {
theme: &config.theme,
gradients: &gradients,
focused: true,
watch: &crate::watch::WatchLog::default(),
},
);
})
.unwrap_or_else(|e| panic!("`{name}` failed to draw at {width}x{height}: {e}"));
}
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_full_dashboard_renders_at_any_size_without_panicking() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let dir = std::env::temp_dir().join(format!("mirador-full-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut config = Config::default();
config.todo.file = Some(dir.join("todos.toml"));
config.weather.latitude = Some(42.36);
config.weather.longitude = Some(-71.06);
let mut app = crate::app::App::new(config).unwrap();
for (width, height) in [(1, 1), (3, 2), (20, 5), (80, 24), (250, 80)] {
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
terminal
.draw(|frame| app.render_for_test(frame))
.unwrap_or_else(|e| panic!("dashboard failed to draw at {width}x{height}: {e}"));
}
let _ = std::fs::remove_dir_all(&dir);
}
fn offline_panels(
dir: &std::path::Path,
config: &Config,
) -> Vec<(&'static str, Box<dyn Panel>)> {
let today = jiff::Zoned::now().date();
let tomorrow = today.tomorrow().unwrap_or(today);
let ics = dir.join("sample.ics");
std::fs::write(
&ics,
format!(
"BEGIN:VCALENDAR\nVERSION:2.0\nBEGIN:VEVENT\nDTSTART:{d}T091500\nDTEND:{d}T093000\n\
SUMMARY:Standup\nLOCATION:Zoom\nEND:VEVENT\nBEGIN:VEVENT\nDTSTART;VALUE=DATE:{d}\n\
SUMMARY:Quarterly planning day\nEND:VEVENT\nEND:VCALENDAR\n",
d = tomorrow.strftime("%Y%m%d")
),
)
.unwrap();
let story = |source: &str, title: &str| crate::feed::Story {
source: source.to_string(),
title: title.to_string(),
link: "https://example.test/story".to_string(),
published: None,
};
let stories = vec![
story(
"NASA",
"Anak Krakatau rumbles again as satellites watch the plume",
),
story(
"PHYS.ORG",
"Some black holes grow much faster than their galaxies",
),
story(
"ARS TECHNICA",
"A very long headline that has to wrap on any panel narrower than it",
),
];
let panels: Vec<(&'static str, Box<dyn Panel>)> = vec![
(
"clocks",
Box::new(
clocks::ClocksPanel::new(config.clocks.clone(), dir.join("zones.toml"))
.unwrap(),
),
),
(
"weather",
Box::new(weather::WeatherPanel::offline(config.weather.clone())),
),
(
"todo",
Box::new(
todo::TodoPanel::new(config.todo.clone(), dir.join("todos.toml")).unwrap(),
),
),
(
"notes",
Box::new(
notes::NotesPanel::new(config.notes.clone(), dir.join("notes.toml")).unwrap(),
),
),
(
"stocks",
Box::new(
stocks::StocksPanel::offline(config.stocks.clone(), dir.join("watchlist.toml"))
.unwrap(),
),
),
(
"calendar",
Box::new(calendar::CalendarPanel::new(config.calendar.clone())),
),
(
"agenda",
Box::new(agenda::AgendaPanel::new(&config.agenda, ics)),
),
(
"pomodoro",
Box::new(pomodoro::PomodoroPanel::new(config.pomodoro.clone())),
),
("watchlog", Box::new(watchlog::WatchLogPanel::new())),
(
"news",
Box::new(news::NewsPanel::offline(&config.news, stories)),
),
("cpu", Box::new(cpu::CpuPanel::new(config.cpu.clone()))),
(
"network",
Box::new(network::NetworkPanel::new(config.network.clone())),
),
(
"calculator",
Box::new(calculator::CalculatorPanel::new(config.calculator)),
),
];
let listed: Vec<&str> = panels.iter().map(|(name, _)| *name).collect();
assert_eq!(
listed, WIDGET_NAMES,
"the offline sweep must build every widget, in the same order"
);
panels
}
#[test]
fn no_panel_cuts_a_value_silently_at_any_width() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let dir = std::env::temp_dir().join(format!("mirador-clip-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let config = Config::default();
let gradients = config.theme.gradients();
let height = 14u16;
let cells = |panel: &mut Box<dyn Panel>, width: u16| -> Vec<Vec<String>> {
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
terminal
.draw(|frame| {
let area = frame.area();
panel.render(
frame,
area,
crate::panel::RenderContext {
theme: &config.theme,
gradients: &gradients,
focused: true,
watch: &crate::watch::WatchLog::default(),
},
);
})
.unwrap();
let buf = terminal.backend().buffer().clone();
(0..height)
.map(|y| {
(0..width)
.map(|x| buf[(x, y)].symbol().to_string())
.collect()
})
.collect()
};
let mut findings = Vec::new();
for (name, mut panel) in offline_panels(&dir, &config) {
for _ in 0..4 {
std::thread::sleep(std::time::Duration::from_millis(25));
panel.tick();
}
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.iter()).enumerate() {
let narrow_text: String = narrow.concat();
if narrow_text.trim().is_empty() {
continue;
}
let overflowed = wide[w].trim() != "";
let same_prefix = narrow[..] == wide[..w];
let marked = narrow_text.trim_end().ends_with('\u{2026}');
let distinct = narrow
.iter()
.filter(|c| c.trim() != "")
.collect::<std::collections::BTreeSet<_>>()
.len();
let mid_token = narrow[w - 1].trim() != "";
let fill_run =
wide[w] == narrow[w - 1] && !wide[w].chars().all(char::is_alphanumeric);
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
&& distinct > 1
&& mid_token
&& !wrapped
&& !fill_run
{
findings.push(format!(
"{name} at width {w} row {y}: {:?} continues as {:?}",
narrow_text.trim_end(),
wide[w]
));
}
}
prev = cur;
}
}
let _ = std::fs::remove_dir_all(&dir);
assert!(
findings.is_empty(),
"{} silent cut(s) — text lost past the edge with no ellipsis:\n {}",
findings.len(),
findings.join("\n ")
);
}
#[test]
#[ignore = "renders every panel at many widths for eyeballing, not an assertion"]
fn dump_width_sweep() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let dir = std::env::temp_dir().join(format!("mirador-sweep-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut config = Config::default();
config.todo.file = Some(dir.join("todos.toml"));
config.notes.file = Some(dir.join("notes.toml"));
config.stocks.file = Some(dir.join("watchlist.toml"));
config.weather.latitude = Some(42.36);
config.weather.longitude = Some(-71.06);
for name in WIDGET_NAMES {
let mut panel = build(name, &config).unwrap().unwrap();
panel.tick();
let gradients = config.theme.gradients();
for width in [12u16, 16, 20, 24, 26, 30, 40] {
let mut terminal = Terminal::new(TestBackend::new(width, 12)).unwrap();
terminal
.draw(|frame| {
let area = frame.area();
panel.render(
frame,
area,
crate::panel::RenderContext {
theme: &config.theme,
gradients: &gradients,
focused: true,
watch: &crate::watch::WatchLog::default(),
},
);
})
.unwrap();
let buf = terminal.backend().buffer().clone();
println!("\n=== {name} @ {width} ===");
for y in 0..buf.area.height {
let mut line = String::new();
for x in 0..buf.area.width {
line.push_str(buf[(x, y)].symbol());
}
if !line.trim().is_empty() {
println!("|{line}|");
}
}
}
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "renders the first-run dashboard to stdout for eyeballing, not an assertion"]
fn dump_first_run() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let dir = std::env::temp_dir().join("mirador-dump-first-run");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut config = Config::default();
config.todo.file = Some(dir.join("todos.toml"));
config.notes.file = Some(dir.join("notes.toml"));
config.stocks.file = Some(dir.join("watchlist.toml"));
config.weather.latitude = Some(42.36);
config.weather.longitude = Some(-71.06);
let mut app = crate::app::App::new(config).unwrap();
std::thread::sleep(std::time::Duration::from_secs(3));
let mut terminal = Terminal::new(TestBackend::new(100, 34)).unwrap();
terminal.draw(|f| app.render_for_test(f)).unwrap();
let buf = terminal.backend().buffer().clone();
println!("\n+{}+", "-".repeat(100));
for y in 0..buf.area.height {
let mut line = String::new();
for x in 0..buf.area.width {
line.push_str(buf[(x, y)].symbol());
}
println!("|{line}|");
}
println!("+{}+", "-".repeat(100));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "renders the dashboard to stdout for eyeballing, not an assertion"]
fn dump_dashboard() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let dir = std::env::temp_dir().join("mirador-dump");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let todo_path = dir.join("todos.toml");
std::fs::write(
&todo_path,
r#"
[[task]]
id = 1
title = "Renew the domain"
notes = "Expires soon"
due = "2026-07-23"
priority = "high"
tags = ["admin"]
done = false
created = "2026-07-01"
[[task]]
id = 2
title = "Reply to the design review"
priority = "medium"
due = "2026-07-25"
tags = ["work"]
done = false
created = "2026-07-20"
[[task]]
id = 3
title = "Publish 0.0.0 placeholder"
notes = "Reserve the crates.io name before someone else does"
due = "2026-07-28"
priority = "low"
tags = ["mirador", "rust"]
done = false
created = "2026-07-25"
"#,
)
.unwrap();
let mut config = Config::default();
config.todo.file = Some(todo_path);
config.weather.latitude = Some(42.36);
config.weather.longitude = Some(-71.06);
let mut app = crate::app::App::new(config).unwrap();
std::thread::sleep(std::time::Duration::from_secs(3));
let mut terminal = Terminal::new(TestBackend::new(100, 34)).unwrap();
terminal.draw(|f| app.render_for_test(f)).unwrap();
let buf = terminal.backend().buffer().clone();
println!("\n+{}+", "-".repeat(100));
for y in 0..buf.area.height {
let mut line = String::new();
for x in 0..buf.area.width {
line.push_str(buf[(x, y)].symbol());
}
println!("|{line}|");
}
println!("+{}+", "-".repeat(100));
}
#[test]
fn the_default_layout_only_references_real_widgets() {
let config = Config::default();
for row in &config.layout.rows {
for panel in &row.panels {
assert!(
is_known_widget(&panel.widget),
"default layout references unknown widget `{}`",
panel.widget
);
}
}
}
}