use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use jiff::civil::Date;
use jiff::tz::TimeZone;
use jiff::{Span, 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 as TextSpan};
use ratatui::widgets::{List, ListItem, Paragraph};
use crate::config::AgendaConfig;
use crate::frame::{Binding, FRAME_HEIGHT, FRAME_WIDTH};
use crate::ical;
use crate::keymap::{KeysConfig, Meta, PanelKeymap};
use crate::panel::{KeyOutcome, Panel, RenderContext, describe_age};
const RELOADING: &str = "reloading…";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AgendaAction {
File,
Reload,
Up,
Down,
First,
Last,
PageUp,
PageDown,
ShowPath,
}
pub const ACTIONS: &[Meta<AgendaAction>] = &[
Meta {
action: AgendaAction::File,
name: "file",
defaults: &[(KeyCode::Char('f'), KeyModifiers::NONE)],
label: "file",
primary: true,
joins: false,
about: "choose the calendar file",
},
Meta {
action: AgendaAction::Reload,
name: "reload",
defaults: &[(KeyCode::Char('r'), KeyModifiers::NONE)],
label: "reload",
primary: true,
joins: false,
about: "read the calendar file again",
},
Meta {
action: AgendaAction::Up,
name: "up",
defaults: &[
(KeyCode::Up, KeyModifiers::NONE),
(KeyCode::Char('k'), KeyModifiers::NONE),
],
label: "scroll",
primary: false,
joins: false,
about: "scroll up",
},
Meta {
action: AgendaAction::Down,
name: "down",
defaults: &[
(KeyCode::Down, KeyModifiers::NONE),
(KeyCode::Char('j'), KeyModifiers::NONE),
],
label: "scroll",
primary: false,
joins: true,
about: "scroll down",
},
Meta {
action: AgendaAction::First,
name: "first",
defaults: &[
(KeyCode::Char('g'), KeyModifiers::NONE),
(KeyCode::Home, KeyModifiers::NONE),
],
label: "first",
primary: false,
joins: false,
about: "scroll to the first event",
},
Meta {
action: AgendaAction::Last,
name: "last",
defaults: &[
(KeyCode::Char('G'), KeyModifiers::NONE),
(KeyCode::End, KeyModifiers::NONE),
],
label: "last",
primary: false,
joins: true,
about: "scroll to the last event",
},
Meta {
action: AgendaAction::PageUp,
name: "page_up",
defaults: &[(KeyCode::PageUp, KeyModifiers::NONE)],
label: "scroll ten rows",
primary: false,
joins: false,
about: "scroll ten rows up",
},
Meta {
action: AgendaAction::PageDown,
name: "page_down",
defaults: &[(KeyCode::PageDown, KeyModifiers::NONE)],
label: "scroll ten rows",
primary: false,
joins: true,
about: "scroll ten rows down",
},
Meta {
action: AgendaAction::ShowPath,
name: "show_path",
defaults: &[(KeyCode::Char('o'), KeyModifiers::NONE)],
label: "show file path",
primary: false,
joins: false,
about: "show which calendar file is read",
},
];
pub fn keymap(keys: &KeysConfig) -> Result<PanelKeymap<AgendaAction>, String> {
PanelKeymap::new("agenda", ACTIONS, keys)
}
fn default_keys() -> PanelKeymap<AgendaAction> {
PanelKeymap::defaults("agenda", ACTIONS)
}
const IMMINENT: std::time::Duration = std::time::Duration::from_mins(10);
const USEFUL_WIDTH: u16 = 52;
#[derive(Debug, Clone, PartialEq, Eq)]
enum Trouble {
NoFile,
Unreadable(String),
}
#[derive(Debug, Default)]
struct State {
events: Vec<ical::Event>,
error: Option<Trouble>,
skipped: usize,
read_at: Option<Instant>,
built_for: Option<Date>,
}
#[derive(Debug)]
pub struct AgendaPanel {
keys: PanelKeymap<AgendaAction>,
state: Arc<Mutex<State>>,
reload: Arc<Mutex<bool>>,
generation: Arc<AtomicU64>,
seen: u64,
stop: Arc<AtomicBool>,
path: Arc<Mutex<PathBuf>>,
days: u16,
show_location: bool,
offset: usize,
max_offset: usize,
status: Option<String>,
asking: Option<crate::prompt::Prompt>,
known: Option<std::collections::HashSet<String>>,
pending: Vec<crate::watch::Event>,
shown: State,
}
impl Drop for AgendaPanel {
fn drop(&mut self) {
self.stop.store(true, Ordering::Relaxed);
}
}
impl AgendaPanel {
pub fn new(config: &AgendaConfig, path: PathBuf) -> Self {
let state = Arc::new(Mutex::new(State::default()));
let reload = Arc::new(Mutex::new(false));
let stop = Arc::new(AtomicBool::new(false));
let generation = Arc::new(AtomicU64::new(0));
let days = config.days.clamp(1, 365);
let interval = Duration::from_secs(config.refresh_secs.max(5));
let shared = (
Arc::clone(&state),
Arc::clone(&reload),
Arc::clone(&stop),
Arc::clone(&generation),
);
let path = Arc::new(Mutex::new(path));
let thread_path = Arc::clone(&path);
std::thread::Builder::new()
.name("mirador-agenda".into())
.spawn(move || {
let (state, reload, stop, generation) = shared;
read_loop(
&thread_path,
days,
interval,
&state,
&reload,
&stop,
&generation,
);
})
.expect("spawning the agenda thread");
Self {
keys: default_keys(),
state,
reload,
generation,
seen: 0,
stop,
path,
days,
show_location: config.show_location,
offset: 0,
max_offset: 0,
status: None,
asking: None,
known: None,
pending: Vec::new(),
shown: State::default(),
}
}
fn handle_prompt_key(&mut self, key: KeyEvent) {
let Some(prompt) = self.asking.as_mut() else {
return;
};
match prompt.handle_key(key) {
crate::prompt::Outcome::Editing | crate::prompt::Outcome::Chose { .. } => {}
crate::prompt::Outcome::Cancelled => self.asking = None,
crate::prompt::Outcome::Submitted(answer) => {
let path = crate::prompt::expand_tilde(&answer);
if !answer.is_empty()
&& let Err(e) = std::fs::metadata(&path)
{
prompt.reject(format!("{e}"));
return;
}
self.set_path(path);
self.asking = None;
self.status = Some(RELOADING.into());
}
}
}
fn note_new_entries(&mut self) {
let state = &self.shown;
if state.error.is_some() {
return;
}
let current: std::collections::HashSet<String> = state
.events
.iter()
.map(|event| format!("{}@{}", event.summary, event.start.timestamp()))
.collect();
if let Some(known) = self.known.take() {
for event in &state.events {
let key = format!("{}@{}", event.summary, event.start.timestamp());
if !known.contains(&key) {
self.pending.push(crate::watch::Event::new(
"agenda",
format!(
"{} appeared in your calendar, {}",
event.summary,
event.start.strftime("%a %d %b at %H:%M")
),
));
}
}
}
self.known = Some(current);
}
pub fn set_path(&mut self, to: PathBuf) {
match self.path.lock() {
Ok(mut guard) => *guard = to,
Err(poisoned) => *poisoned.into_inner() = to,
}
self.offset = 0;
self.ask_for_reload();
}
fn snapshot(&self) -> State {
match self.state.lock() {
Ok(guard) => State {
events: guard.events.clone(),
error: guard.error.clone(),
skipped: guard.skipped,
read_at: guard.read_at,
built_for: guard.built_for,
},
Err(poisoned) => {
let guard = poisoned.into_inner();
State {
events: guard.events.clone(),
error: guard.error.clone(),
skipped: guard.skipped,
read_at: guard.read_at,
built_for: guard.built_for,
}
}
}
}
fn ask_for_reload(&self) {
match self.reload.lock() {
Ok(mut flag) => *flag = true,
Err(poisoned) => *poisoned.into_inner() = true,
}
}
fn rows<'a>(state: &'a State, now: &'a Zoned) -> impl Iterator<Item = Row<'a>> + 'a {
let mut current: Option<Date> = None;
state.events.iter().flat_map(move |event| {
let day = event.start.date();
let heading = (current != Some(day)).then(|| {
current = Some(day);
Row::Day(day)
});
heading.into_iter().chain(std::iter::once(Row::Event {
event,
in_progress: event.contains(now),
}))
})
}
fn scroll_down(&mut self, rows: usize) {
self.offset = self.offset.saturating_add(rows).min(self.max_offset);
}
fn scroll_up(&mut self, rows: usize) {
self.offset = self.offset.saturating_sub(rows);
}
}
enum Row<'a> {
Day(Date),
Event {
event: &'a ical::Event,
in_progress: bool,
},
}
fn day_label(day: Date, today: Date) -> String {
let delta = (day - today).get_days();
match delta {
0 => "TODAY".to_string(),
1 => "TOMORROW".to_string(),
_ => format!("{} {}", weekday_name(day), day.strftime("%-d %b")),
}
}
fn weekday_name(day: Date) -> &'static str {
match day.weekday() {
jiff::civil::Weekday::Monday => "MONDAY",
jiff::civil::Weekday::Tuesday => "TUESDAY",
jiff::civil::Weekday::Wednesday => "WEDNESDAY",
jiff::civil::Weekday::Thursday => "THURSDAY",
jiff::civil::Weekday::Friday => "FRIDAY",
jiff::civil::Weekday::Saturday => "SATURDAY",
jiff::civil::Weekday::Sunday => "SUNDAY",
}
}
const MAX_CALENDAR: u64 = 10 * 1024 * 1024;
fn read_calendar(path: &std::path::Path) -> std::io::Result<String> {
let size = std::fs::metadata(path)?.len();
if size > MAX_CALENDAR {
return Err(std::io::Error::other(format!(
"the calendar is {} MB, over the {} MB limit — mirador reads a \
calendar into memory, so it will not open one this large",
size / (1024 * 1024),
MAX_CALENDAR / (1024 * 1024)
)));
}
std::fs::read_to_string(path)
}
fn current_path(path: &Arc<Mutex<PathBuf>>) -> PathBuf {
match path.lock() {
Ok(guard) => guard.clone(),
Err(poisoned) => poisoned.into_inner().clone(),
}
}
fn read_loop(
path: &Arc<Mutex<PathBuf>>,
days: u16,
interval: Duration,
state: &Arc<Mutex<State>>,
reload: &Arc<Mutex<bool>>,
stop: &Arc<AtomicBool>,
generation: &Arc<AtomicU64>,
) {
while !stop.load(Ordering::Relaxed) {
let tz = TimeZone::system();
let today = ical::today(&tz);
let from = ical::local_midnight(today, &tz);
let until = today
.checked_add(Span::new().days(i64::from(days)))
.ok()
.and_then(|d| ical::local_midnight(d, &tz));
let next = match (from, until) {
(Some(from), Some(until)) => match read_calendar(¤t_path(path)) {
Ok(text) => {
let calendar = ical::parse(&text, &tz, &from, &until);
State {
events: calendar.events,
error: None,
skipped: calendar.skipped.len(),
read_at: Some(Instant::now()),
built_for: Some(today),
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => State {
error: Some(Trouble::NoFile),
read_at: Some(Instant::now()),
built_for: Some(today),
..State::default()
},
Err(e) => State {
error: Some(Trouble::Unreadable(format!("{e}"))),
read_at: Some(Instant::now()),
built_for: Some(today),
..State::default()
},
},
_ => State {
error: Some(Trouble::Unreadable(
"could not work out today's date".into(),
)),
read_at: Some(Instant::now()),
built_for: Some(today),
..State::default()
},
};
match state.lock() {
Ok(mut guard) => *guard = next,
Err(poisoned) => *poisoned.into_inner() = next,
}
generation.fetch_add(1, Ordering::Release);
let woke = crate::poll::wait(interval, stop, || match reload.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;
}
}
}
impl Panel for AgendaPanel {
fn title(&self) -> String {
"Agenda".to_string()
}
fn counter(&self) -> Option<String> {
let state = &self.shown;
match &state.error {
Some(Trouble::NoFile) => return Some("not set up".into()),
Some(Trouble::Unreadable(_)) => return Some("unreadable".into()),
None => {}
}
let today = state.built_for?;
let n = state
.events
.iter()
.filter(|e| e.start.date() == today)
.count();
Some(match n {
0 => "clear".to_string(),
1 => "1 today".to_string(),
n => format!("{n} today"),
})
}
fn bindings(&self) -> &[Binding] {
self.keys.bindings()
}
fn set_keys(&mut self, config: &crate::config::Config) {
self.keys = PanelKeymap::or_defaults("agenda", ACTIONS, &config.agenda.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 before = self.shown.built_for;
self.shown = self.snapshot();
if self.shown.built_for != before {
self.offset = 0;
}
self.note_new_entries();
if self.status.as_deref() == Some(RELOADING) {
self.status = None;
}
}
moved
}
fn alert(&self) -> Option<crate::panel::Alert> {
let now = Zoned::now();
let guard = match self.state.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
if guard.error.is_some() {
return None;
}
let (until, event) = guard
.events
.iter()
.filter(|event| !event.all_day)
.filter_map(|event| {
let until = event.start.duration_since(&now);
let until = std::time::Duration::try_from(until).ok()?;
(until <= IMMINENT).then_some((until, event))
})
.min_by_key(|(until, _)| *until)?;
let minutes = until.as_secs() / 60;
let when = if minutes == 0 {
"now".to_string()
} else {
format!("in {minutes}m")
};
Some(match &event.location {
Some(place) => crate::panel::Alert::soon(format!("{} {when} · {place}", event.summary)),
None => crate::panel::Alert::soon(format!("{} {when}", event.summary)),
})
}
fn events(&mut self) -> Vec<crate::watch::Event> {
std::mem::take(&mut self.pending)
}
fn overlay(&self) -> Option<&crate::prompt::Prompt> {
self.asking.as_ref()
}
fn captures_input(&self) -> bool {
self.asking.is_some()
}
fn handle_key(&mut self, key: KeyEvent) -> KeyOutcome {
if self.asking.is_some() {
self.handle_prompt_key(key);
return KeyOutcome::Consumed;
}
self.status = None;
let Some(action) = self.keys.action(key) else {
return KeyOutcome::Ignored;
};
match action {
AgendaAction::File => {
self.asking = Some(crate::prompt::Prompt::new(
"AGENDA FILE",
"Tab completes · Enter saves · Esc cancels",
¤t_path(&self.path).display().to_string(),
crate::prompt::Completion::Paths,
));
}
AgendaAction::Reload => {
self.ask_for_reload();
self.status = Some(RELOADING.into());
}
AgendaAction::ShowPath => {
self.status = Some(current_path(&self.path).display().to_string());
}
AgendaAction::Down => self.scroll_down(1),
AgendaAction::Up => self.scroll_up(1),
AgendaAction::PageDown => self.scroll_down(10),
AgendaAction::PageUp => self.scroll_up(10),
AgendaAction::Last => self.scroll_down(usize::MAX),
AgendaAction::First => self.scroll_up(usize::MAX),
}
KeyOutcome::Consumed
}
fn handle_mouse(&mut self, event: MouseEvent, _area: Rect) -> KeyOutcome {
match event.kind {
MouseEventKind::ScrollDown => self.scroll_down(1),
MouseEventKind::ScrollUp => self.scroll_up(1),
_ => return KeyOutcome::Ignored,
}
KeyOutcome::Consumed
}
fn render(&mut self, frame: &mut Frame, area: Rect, ctx: RenderContext<'_>) {
let theme = ctx.theme;
if area.width == 0 || area.height == 0 {
return;
}
let state = &self.shown;
let now = Zoned::now();
let mut notices: Vec<Line<'static>> = Vec::new();
if state.skipped > 0 {
notices.push(Line::from(TextSpan::styled(
format!(
"{} entr{} could not be read",
state.skipped,
if state.skipped == 1 { "y" } else { "ies" }
),
Style::default().fg(theme.error),
)));
}
if let Some(message) = &self.status {
notices.push(Line::from(TextSpan::styled(
message.clone(),
Style::default().fg(theme.muted),
)));
}
let (list_area, notice_area) =
split_for_notices(area, u16::try_from(notices.len()).unwrap_or(0));
let rows = if state.error.is_some() {
0
} else {
Self::rows(state, &now).count()
};
self.max_offset = rows.saturating_sub(usize::from(list_area.height));
self.offset = self.offset.min(self.max_offset);
self.draw_list(frame, list_area, state, &now, theme);
if notice_area.height > 0 {
frame.render_widget(Paragraph::new(notices), notice_area);
}
}
fn shutdown(&mut self) {
self.stop.store(true, Ordering::Relaxed);
}
fn remember(&self, state: &mut crate::state::UiState) {
state.agenda_file = Some(current_path(&self.path).display().to_string());
}
}
impl AgendaPanel {
fn draw_list(
&self,
frame: &mut Frame,
area: Rect,
state: &State,
now: &Zoned,
theme: &crate::theme::Theme,
) {
if let Some(trouble) = &state.error {
let muted = Style::default().fg(theme.muted);
let mut lines = match trouble {
Trouble::NoFile => {
let mut lines = vec![
Line::from(TextSpan::styled(
"No agenda file",
Style::default().fg(theme.text).add_modifier(Modifier::BOLD),
)),
Line::from(TextSpan::styled("Nothing to show.", muted)),
Line::from(""),
];
lines.extend(wrapped_lines(
"Set [agenda].file to an .ics you already have — an export, \
or whatever your calendar syncs to, or press f.",
area.width,
muted,
));
lines.push(Line::from(""));
lines.extend(wrapped_lines(
&format!("Looked in {}", current_path(&self.path).display()),
area.width,
muted,
));
lines
}
Trouble::Unreadable(why) => {
let mut lines = vec![Line::from(TextSpan::styled(
"Cannot read the agenda file",
Style::default()
.fg(theme.error)
.add_modifier(Modifier::BOLD),
))];
lines.extend(wrapped_lines(why, area.width, muted));
lines.push(Line::from(""));
lines.extend(wrapped_lines(
¤t_path(&self.path).display().to_string(),
area.width,
muted,
));
lines
}
};
if let Some(age) = state.read_at.map(|at| describe_age(at.elapsed())) {
lines.push(Line::from(TextSpan::styled(
format!("checked {age}"),
muted,
)));
}
frame.render_widget(Paragraph::new(lines), area);
return;
}
if state.events.is_empty() {
let horizon = if self.days == 1 {
"today".to_string()
} else {
format!("the next {} days", self.days)
};
let mut lines = wrapped_lines(
"Nothing scheduled",
area.width,
Style::default().fg(theme.text).add_modifier(Modifier::BOLD),
);
lines.extend(wrapped_lines(
&format!("in {horizon}."),
area.width,
Style::default().fg(theme.muted),
));
frame.render_widget(Paragraph::new(lines), area);
return;
}
self.draw_events(frame, area, state, now, theme);
}
fn draw_events(
&self,
frame: &mut Frame,
area: Rect,
state: &State,
now: &Zoned,
theme: &crate::theme::Theme,
) {
let today = state.built_for.unwrap_or_else(|| now.date());
let height = usize::from(area.height);
let mut rows = Self::rows(state, now).skip(self.offset).peekable();
let top = rows.next().map(|row| match row {
Row::Event { event, .. }
if self.offset > 0
&& height >= 2
&& matches!(rows.peek(), Some(Row::Event { .. })) =>
{
Row::Day(event.start.date())
}
row => row,
});
let items: Vec<ListItem> = top
.into_iter()
.chain(rows.take(height.saturating_sub(1)))
.map(|row| match row {
Row::Day(day) => ListItem::new(Line::from(TextSpan::styled(
crate::grid::truncate(
&crate::glyphs::utility(&day_label(day, today)),
usize::from(area.width),
),
Style::default()
.fg(theme.label)
.add_modifier(Modifier::BOLD),
))),
Row::Event { event, in_progress } => ListItem::new(event_line(
event,
in_progress,
self.show_location,
area.width,
theme,
)),
})
.collect();
frame.render_widget(List::new(items), area);
}
}
fn split_for_notices(area: Rect, notices: u16) -> (Rect, Rect) {
let reserved = notices.min(area.height.saturating_sub(1));
let list = Rect {
height: area.height - reserved,
..area
};
let notice = Rect {
y: area.y + list.height,
height: reserved,
..area
};
(list, notice)
}
fn event_line(
event: &ical::Event,
in_progress: bool,
show_location: bool,
width: u16,
theme: &crate::theme::Theme,
) -> Line<'static> {
const TIME_WIDTH: usize = 6;
let time = if event.all_day {
"all day".to_string()
} else {
event.start.strftime("%H:%M").to_string()
};
let marker = if in_progress { "▸ " } else { " " };
let time_style = if in_progress {
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.muted)
};
let summary_style = if in_progress {
Style::default().fg(theme.text).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.text)
};
let used = marker.len() + TIME_WIDTH.max(crate::grid::display_width(&time)) + 1;
let room = usize::from(width).saturating_sub(used);
let mut text = event.summary.clone();
if show_location && let Some(location) = &event.location {
let combined = format!("{text} · {location}");
if crate::grid::display_width(&combined) <= room {
text = combined;
}
}
crate::grid::assemble(
vec![
vec![
TextSpan::styled(marker.to_string(), time_style),
TextSpan::styled(format!("{time:<TIME_WIDTH$} "), time_style),
],
vec![TextSpan::styled(
crate::grid::truncate(&text, room),
summary_style,
)],
],
width,
)
}
fn wrapped_lines(text: &str, width: u16, style: Style) -> Vec<Line<'static>> {
crate::grid::wrap(text, usize::from(width))
.into_iter()
.map(|row| Line::from(TextSpan::styled(row, style)))
.collect()
}
const _: u16 = FRAME_HEIGHT;
#[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| {
idle_panel().handle_key(event)
});
}
use jiff::civil::date;
#[test]
fn f_asks_for_a_path_and_a_missing_file_is_refused_where_it_was_typed() {
let dir =
std::env::temp_dir().join(format!("mirador-agenda-prompt-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let ics = dir.join("cal.ics");
let mut panel = AgendaPanel::new(&AgendaConfig::default(), ics.clone());
let key = |code| KeyEvent::new(code, ratatui::crossterm::event::KeyModifiers::NONE);
panel.handle_key(key(KeyCode::Char('f')));
assert!(panel.asking.is_some(), "`f` opens the prompt");
panel.handle_key(key(KeyCode::Enter));
let prompt = panel
.asking
.as_ref()
.expect("a missing file keeps the prompt open");
assert_eq!(
prompt.value(),
ics.display().to_string(),
"and keeps what was typed"
);
panel.handle_key(key(KeyCode::Esc));
assert!(panel.asking.is_none(), "Esc backs out");
std::fs::write(&ics, "BEGIN:VCALENDAR\nEND:VCALENDAR\n").unwrap();
panel.handle_key(key(KeyCode::Char('f')));
panel.handle_key(key(KeyCode::Enter));
assert!(panel.asking.is_none(), "a file that exists is taken");
assert_eq!(
panel.status.as_deref(),
Some(RELOADING),
"and a reload is under way"
);
assert_eq!(current_path(&panel.path), ics);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn scrolling_moves_what_is_on_screen_and_stops_at_both_ends() {
let mut panel =
AgendaPanel::new(&AgendaConfig::default(), PathBuf::from("/nonexistent.ics"));
let today = Zoned::now().date();
let tomorrow = today.tomorrow().expect("a tomorrow");
panel.shown.events = (0..12)
.map(|i: i8| {
let day = if i < 6 { today } else { tomorrow };
event(day, 8 + i % 6, &format!("event {i:02}"), false)
})
.collect();
panel.shown.built_for = Some(today);
let key = |panel: &mut AgendaPanel, code| {
panel.handle_key(KeyEvent::from(code));
};
let wheel = |panel: &mut AgendaPanel, kind| {
let event = MouseEvent {
kind,
column: 1,
row: 1,
modifiers: ratatui::crossterm::event::KeyModifiers::NONE,
};
panel.handle_mouse(event, Rect::new(0, 0, 40, 6));
};
let first = screen(&mut panel, 40, 6);
assert!(first[1].contains("event 00"), "{first:#?}");
assert!(
!first.iter().any(|row| row.contains("event 05")),
"{first:#?}"
);
key(&mut panel, KeyCode::Char('j'));
let moved = screen(&mut panel, 40, 6);
assert!(
moved[0].contains("TODAY"),
"the day stays on top: {moved:#?}"
);
assert!(
moved[1].contains("event 01"),
"j moves the view a row: {moved:#?}"
);
assert!(moved[5].contains("event 05"), "{moved:#?}");
for _ in 0..50 {
wheel(&mut panel, MouseEventKind::ScrollDown);
}
let bottom = screen(&mut panel, 40, 6);
assert!(
bottom[5].contains("event 11"),
"the last event is reachable: {bottom:#?}"
);
assert!(
bottom[0].contains("TOMORROW"),
"under its day's heading: {bottom:#?}"
);
assert!(
bottom[1].contains("event 07"),
"and the view stops there: {bottom:#?}"
);
key(&mut panel, KeyCode::Char('j'));
assert_eq!(
screen(&mut panel, 40, 6),
bottom,
"past the end, nothing moves"
);
key(&mut panel, KeyCode::Char('g'));
assert_eq!(screen(&mut panel, 40, 6), first, "g returns to the top");
key(&mut panel, KeyCode::PageDown);
assert_eq!(
screen(&mut panel, 40, 6),
bottom,
"ten rows is past the end here"
);
for _ in 0..50 {
wheel(&mut panel, MouseEventKind::ScrollUp);
}
assert_eq!(
screen(&mut panel, 40, 6),
first,
"and the wheel stops at the top"
);
key(&mut panel, KeyCode::Char('G'));
assert_eq!(
screen(&mut panel, 40, 6),
bottom,
"G goes to the last event"
);
}
#[test]
fn a_heading_never_stands_over_nothing_while_scrolling() {
let mut panel =
AgendaPanel::new(&AgendaConfig::default(), PathBuf::from("/nonexistent.ics"));
let today = Zoned::now().date();
let tomorrow = today.tomorrow().expect("a tomorrow");
panel.shown.events = (0..8)
.map(|i: i8| {
let day = if i < 4 { today } else { tomorrow };
event(day, 8 + i % 4, &format!("event {i:02}"), false)
})
.collect();
panel.shown.built_for = Some(today);
let heading = |row: &str| row.contains("TODAY") || row.contains("TOMORROW");
for height in 2..=6 {
panel.handle_key(KeyEvent::from(KeyCode::Char('g')));
for step in 0..12 {
let rows = screen(&mut panel, 40, height);
for pair in rows.windows(2) {
assert!(
!(heading(&pair[0]) && heading(&pair[1])),
"height {height}, step {step}: {rows:#?}"
);
}
panel.handle_key(KeyEvent::from(KeyCode::Char('j')));
}
}
}
#[test]
fn a_taller_panel_or_a_shorter_calendar_pulls_the_scroll_back() {
let mut panel =
AgendaPanel::new(&AgendaConfig::default(), PathBuf::from("/nonexistent.ics"));
let today = Zoned::now().date();
panel.shown.events = (0..12)
.map(|i: i8| event(today, 8 + i, &format!("event {i:02}"), false))
.collect();
panel.shown.built_for = Some(today);
screen(&mut panel, 40, 6);
panel.handle_key(KeyEvent::from(KeyCode::Char('G')));
screen(&mut panel, 40, 6);
let taller = screen(&mut panel, 40, 13);
assert!(taller[0].contains("TODAY"), "{taller:#?}");
assert!(
taller[1].contains("event 00"),
"all of it fits again: {taller:#?}"
);
panel.handle_key(KeyEvent::from(KeyCode::Char('G')));
screen(&mut panel, 40, 6);
panel.shown.events.truncate(3);
let shorter = screen(&mut panel, 40, 6);
assert!(shorter[1].contains("event 00"), "{shorter:#?}");
assert!(shorter[3].contains("event 02"), "{shorter:#?}");
}
fn screen(panel: &mut AgendaPanel, width: u16, height: u16) -> Vec<String> {
use crate::panel::RenderContext;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let theme = crate::theme::Theme::default();
let gradients = theme.gradients();
let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("backend");
terminal
.draw(|f| {
panel.render(
f,
Rect::new(0, 0, width, height),
RenderContext {
theme: &theme,
gradients: &gradients,
focused: true,
watch: &crate::watch::WatchLog::default(),
},
);
})
.expect("draws");
let buffer = terminal.backend().buffer();
(0..height)
.map(|y| (0..width).map(|x| buffer[(x, y)].symbol()).collect())
.collect()
}
fn tz() -> TimeZone {
TimeZone::get("America/New_York").unwrap()
}
fn event(day: Date, hour: i8, summary: &str, all_day: bool) -> ical::Event {
let start = day.at(hour, 0, 0, 0).to_zoned(tz()).unwrap();
ical::Event {
summary: summary.to_string(),
location: None,
end: start.checked_add(Span::new().hours(1)).ok(),
start,
all_day,
}
}
#[test]
fn the_path_in_the_empty_message_is_wrapped_rather_than_cut_off() {
use crate::panel::RenderContext;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let dir = std::env::temp_dir().join(format!("mirador-agendapath-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("temp dir");
let missing = dir.join("no-such-calendar.ics");
let theme = crate::theme::Theme::default();
let gradients = theme.gradients();
for width in [24u16, 30, 40, 60] {
let mut panel =
AgendaPanel::new(&crate::config::AgendaConfig::default(), missing.clone());
for _ in 0..50 {
if panel.tick() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
let mut terminal = Terminal::new(TestBackend::new(width, 24)).expect("backend");
terminal
.draw(|f| {
panel.render(
f,
Rect::new(0, 0, width, 24),
RenderContext {
theme: &theme,
gradients: &gradients,
focused: true,
watch: &crate::watch::WatchLog::default(),
},
);
})
.expect("draws");
let buffer = terminal.backend().buffer();
let rows: Vec<String> = (0..24)
.map(|y| {
(0..width)
.map(|x| buffer[(x, y)].symbol())
.collect::<String>()
})
.collect();
let screen = rows.join("\n");
for row in &rows {
assert!(
crate::grid::display_width(row.trim_end()) <= usize::from(width),
"a row overflowed at width {width}: {row:?}"
);
}
let joined: String = rows.iter().map(|r| r.trim_end()).collect();
assert!(
joined.contains("no-such-calendar.ics"),
"the path was cut before its filename at width {width}:\n{screen}"
);
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_calendar_too_large_to_be_a_calendar_is_refused_before_it_is_read() {
let dir = std::env::temp_dir().join(format!("mirador-ics-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("test directory");
let path = dir.join("big.ics");
let file = std::fs::File::create(&path).expect("create");
file.set_len(MAX_CALENDAR + 1).expect("grow");
drop(file);
let err = read_calendar(&path).expect_err("must refuse");
let message = err.to_string();
assert!(message.contains("limit"), "says why: {message}");
assert!(
message.contains("10 MB"),
"and what the limit is: {message}"
);
let small = dir.join("small.ics");
std::fs::write(&small, "BEGIN:VCALENDAR\nEND:VCALENDAR\n").expect("write");
assert!(read_calendar(&small).is_ok());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_notice_never_lands_on_top_of_an_event() {
for height in 0u16..12 {
for notices in 0u16..4 {
let area = Rect::new(3, 5, 40, height);
let (list, notice) = split_for_notices(area, notices);
assert_eq!(
list.height + notice.height,
height,
"{height} rows, {notices} notices: the split loses rows"
);
assert_eq!(
notice.y,
list.y + list.height,
"{height} rows, {notices} notices: the notice overlaps the list"
);
if height > 0 {
assert!(
list.height >= 1,
"{height} rows, {notices} notices: the list was squeezed out"
);
}
}
}
}
#[test]
fn today_and_tomorrow_are_named_rather_than_dated() {
let today = date(2026, 8, 1);
assert_eq!(day_label(today, today), "TODAY");
assert_eq!(day_label(date(2026, 8, 2), today), "TOMORROW");
assert_eq!(day_label(date(2026, 8, 5), today), "WEDNESDAY 5 Aug");
}
#[test]
fn each_day_gets_one_heading_however_many_events_it_has() {
let state = State {
events: vec![
event(date(2026, 8, 1), 9, "a", false),
event(date(2026, 8, 1), 11, "b", false),
event(date(2026, 8, 2), 9, "c", false),
],
..State::default()
};
let now = date(2026, 8, 1).at(8, 0, 0, 0).to_zoned(tz()).unwrap();
let rows: Vec<Row> = AgendaPanel::rows(&state, &now).collect();
let headings = rows.iter().filter(|r| matches!(r, Row::Day(_))).count();
assert_eq!(headings, 2, "one heading per day, not per event");
assert_eq!(rows.len(), 5);
}
#[test]
fn an_event_happening_now_is_marked() {
let state = State {
events: vec![event(date(2026, 8, 1), 9, "standup", false)],
..State::default()
};
let during = date(2026, 8, 1).at(9, 30, 0, 0).to_zoned(tz()).unwrap();
let after = date(2026, 8, 1).at(10, 30, 0, 0).to_zoned(tz()).unwrap();
let marked = |now: &Zoned| match AgendaPanel::rows(&state, now).nth(1) {
Some(Row::Event { in_progress, .. }) => in_progress,
_ => unreachable!("row 1 is the event"),
};
assert!(marked(&during), "the meeting you are in must stand out");
assert!(!marked(&after));
}
#[test]
fn an_all_day_event_says_so_instead_of_showing_midnight() {
let theme = crate::theme::Theme::default();
let e = event(date(2026, 8, 1), 0, "Holiday", true);
let line = event_line(&e, false, true, 60, &theme);
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.contains("all day"), "got `{text}`");
assert!(!text.contains("00:00"), "midnight is not a time here");
}
#[test]
fn a_row_never_outgrows_the_panel() {
let theme = crate::theme::Theme::default();
for summary in [
"short",
"an extremely long summary that will not fit in a narrow panel at all",
"日本語のとても長い予定のタイトルです",
] {
for width in [10u16, 20, 40, 80] {
let mut e = event(date(2026, 8, 1), 9, summary, false);
e.location = Some("Room 12, second floor".into());
let line = event_line(&e, false, true, width, &theme);
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
crate::grid::display_width(&text) <= usize::from(width),
"{width} cells: `{text}` is {}",
crate::grid::display_width(&text)
);
}
}
}
#[test]
fn a_location_is_dropped_rather_than_squeezing_the_summary_out() {
let theme = crate::theme::Theme::default();
let mut e = event(date(2026, 8, 1), 9, "Design review", false);
e.location = Some("The very long name of a meeting room".into());
let narrow: String = event_line(&e, false, true, 30, &theme)
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
assert!(narrow.contains("Design"), "the summary lost: `{narrow}`");
assert!(!narrow.contains("very long name"), "got `{narrow}`");
let wide: String = event_line(&e, false, true, 70, &theme)
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
assert!(wide.contains("meeting room"), "got `{wide}`");
}
fn idle_panel() -> AgendaPanel {
AgendaPanel::new(&AgendaConfig::default(), PathBuf::from("/nonexistent.ics"))
}
fn land_a_read(panel: &mut AgendaPanel) {
panel
.generation
.store(panel.seen + 1, std::sync::atomic::Ordering::Release);
panel.tick();
}
#[test]
fn a_landed_reload_takes_its_own_message_down() {
let mut panel = idle_panel();
panel.status = Some(RELOADING.into());
land_a_read(&mut panel);
assert_eq!(
panel.status, None,
"a reload that has landed must stop saying it is reloading"
);
}
#[test]
fn a_path_shown_by_o_survives_a_background_read() {
let mut panel = idle_panel();
panel.status = Some("/home/someone/calendar.ics".into());
land_a_read(&mut panel);
assert_eq!(
panel.status.as_deref(),
Some("/home/someone/calendar.ics"),
"a read landing must not clear a status it did not put up"
);
}
}