pub mod calendar;
pub mod clocks;
pub mod cpu;
pub mod network;
pub mod notes;
pub mod pomodoro;
pub mod stocks;
pub mod todo;
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", "pomodoro", "cpu", "network",
];
pub fn is_known_widget(name: &str) -> bool {
WIDGET_NAMES.contains(&name)
}
pub fn unused_widgets(config: &Config) -> Vec<&'static str> {
let placed: Vec<&str> = config
.layout
.rows
.iter()
.flat_map(|row| row.panels.iter())
.map(|panel| panel.widget.as_str())
.collect();
WIDGET_NAMES
.iter()
.copied()
.filter(|name| !placed.contains(name))
.collect()
}
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())),
"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())),
"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())),
_ => return Ok(None),
};
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,
},
);
})
.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);
}
#[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
);
}
}
}
}