use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, PoisonError};
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 live_keys(keys: &KeysConfig) -> PanelKeymap<AgendaAction> {
PanelKeymap::or_defaults("agenda", ACTIONS, keys)
}
pub(crate) fn file_key(keys: &KeysConfig) -> Option<crate::keymap::Key> {
live_keys(keys).keys(AgendaAction::File).first().copied()
}
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>,
built_until: Option<jiff::Timestamp>,
source: Option<PathBuf>,
}
#[derive(Debug)]
pub struct AgendaPanel {
keys: PanelKeymap<AgendaAction>,
state: Arc<Mutex<State>>,
reload: Arc<AtomicBool>,
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>>,
known_until: Option<jiff::Timestamp>,
known_from: Option<PathBuf>,
pending: Vec<crate::watch::Event>,
shown: State,
checked: Option<Zoned>,
}
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(AtomicBool::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,
known_until: None,
known_from: None,
pending: Vec::new(),
shown: State::default(),
checked: None,
}
}
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) = calendar_file(&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();
let horizon = self.known_until.take();
let known = self
.known
.take()
.filter(|_| self.known_from == state.source);
if let Some(known) = known {
for event in &state.events {
if horizon.is_some_and(|until| event.start.timestamp() >= until) {
continue;
}
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);
self.known_until = state.built_until;
self.known_from.clone_from(&state.source);
}
pub fn set_path(&mut self, to: PathBuf) {
*self.path.lock().unwrap_or_else(PoisonError::into_inner) = to;
self.offset = 0;
self.ask_for_reload();
}
fn snapshot(&self) -> State {
let guard = self.state.lock().unwrap_or_else(PoisonError::into_inner);
State {
events: guard.events.clone(),
error: guard.error.clone(),
skipped: guard.skipped,
read_at: guard.read_at,
built_for: guard.built_for,
built_until: guard.built_until,
source: guard.source.clone(),
}
}
fn ask_for_reload(&self) {
self.reload.store(true, Ordering::Release);
}
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);
}
fn tick_at(&mut self, clock: &Zoned) -> bool {
let passed = self.checked.as_ref().is_some_and(|then| {
self.shown
.events
.iter()
.any(|event| standing(event, then) != standing(event, clock))
});
self.checked = Some(clock.clone());
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 || passed
}
}
fn countdown(event: &ical::Event, now: &Zoned) -> Option<std::time::Duration> {
if event.all_day {
return None;
}
std::time::Duration::try_from(event.start.duration_since(now))
.ok()
.filter(|until| *until <= IMMINENT)
}
fn standing(event: &ical::Event, now: &Zoned) -> (bool, Option<u64>) {
(
event.contains(now),
countdown(event, now).map(|until| until.as_secs() / 60),
)
}
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 = calendar_file(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)
)));
}
read_capped(std::fs::File::open(path)?, MAX_CALENDAR)
}
fn calendar_file(path: &std::path::Path) -> std::io::Result<std::fs::Metadata> {
let meta = std::fs::metadata(path)?;
if meta.is_file() {
return Ok(meta);
}
Err(std::io::Error::other(format!(
"{}, not a calendar file",
what_is_not_a_file(meta.file_type())
)))
}
fn what_is_not_a_file(kind: std::fs::FileType) -> &'static str {
#[cfg(unix)]
{
use std::os::unix::fs::FileTypeExt;
if kind.is_fifo() {
return "a pipe";
}
if kind.is_char_device() || kind.is_block_device() {
return "a device";
}
if kind.is_socket() {
return "a socket";
}
}
if kind.is_dir() {
"a directory"
} else {
"something other than a file"
}
}
fn read_capped(reader: impl std::io::Read, cap: u64) -> std::io::Result<String> {
use std::io::Read;
let mut bytes = Vec::new();
reader.take(cap.saturating_add(1)).read_to_end(&mut bytes)?;
if bytes.len() as u64 > cap {
return Err(std::io::Error::other(format!(
"the calendar is over the {} MB limit — mirador reads a calendar \
into memory, so it will not open one this large",
cap / (1024 * 1024)
)));
}
String::from_utf8(bytes).map_err(|_| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
"stream did not contain valid UTF-8",
)
})
}
fn current_path(path: &Arc<Mutex<PathBuf>>) -> PathBuf {
path.lock().unwrap_or_else(PoisonError::into_inner).clone()
}
fn read_loop(
path: &Arc<Mutex<PathBuf>>,
days: u16,
interval: Duration,
state: &Arc<Mutex<State>>,
reload: &AtomicBool,
stop: &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 source = current_path(path);
let next = match (from, until) {
(Some(from), Some(until)) => match read_calendar(&source) {
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),
built_until: Some(until.timestamp()),
source: Some(source),
}
}
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()
},
};
*state.lock().unwrap_or_else(PoisonError::into_inner) = next;
generation.fetch_add(1, Ordering::Release);
let woke = crate::poll::wait(interval, stop, || reload.swap(false, Ordering::AcqRel));
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 = live_keys(&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 {
self.tick_at(&Zoned::now())
}
fn alert(&self) -> Option<crate::panel::Alert> {
let now = Zoned::now();
let guard = self.state.lock().unwrap_or_else(PoisonError::into_inner);
if guard.error.is_some() {
return None;
}
let (until, event) = guard
.events
.iter()
.filter_map(|event| countdown(event, &now).map(|until| (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.extend(wrapped_lines(
message,
area.width,
Style::default().fg(theme.muted),
));
}
let (list_area, notice_area) =
split_for_notices(area, u16::try_from(notices.len()).unwrap_or(u16::MAX));
let fits = usize::from(notice_area.height);
if notices.len() > fits {
notices.truncate(fits);
if let Some(last) = notices.last_mut() {
let text: String = last
.spans
.iter()
.map(|span| span.content.as_ref())
.collect();
let style = last
.spans
.first()
.map_or_else(Style::default, |span| span.style);
*last = Line::from(TextSpan::styled(
crate::grid::truncate(
&format!("{}…", text.trim_end()),
usize::from(area.width),
),
style,
));
}
}
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(""),
];
let set_it = "Set [agenda].file to an .ics you already have — an \
export, or whatever your calendar syncs to";
let set_it = match self.keys.keys(AgendaAction::File).first() {
Some(key) => format!("{set_it}, or press {key}."),
None => format!("{set_it}."),
};
lines.extend(wrapped_lines(&set_it, 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 =
crate::grid::display_width(marker) + 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::*;
use crate::store::testing::TempDir;
#[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 = TempDir::new("agenda-prompt");
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);
}
#[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> {
crate::widgets::testing::rows(&crate::widgets::testing::rendered(panel, width, height))
}
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 = TempDir::new("agendapath");
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 rows = crate::widgets::testing::rows(terminal.backend().buffer());
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}"
);
}
}
#[test]
fn a_calendar_too_large_to_be_a_calendar_is_refused_before_it_is_read() {
let dir = TempDir::new("ics");
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());
}
#[test]
fn a_read_is_cut_off_a_byte_past_the_limit_however_long_the_source() {
use std::io::Read;
let err = read_capped(std::io::repeat(b'X'), MAX_CALENDAR)
.map(|text| text.len())
.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 cap = 64;
let at = read_capped(std::io::repeat(b'X').take(cap), cap).expect("at the cap");
assert_eq!(at.len() as u64, cap);
assert!(read_capped(std::io::repeat(b'X').take(cap + 1), cap).is_err());
let accents = "é".repeat(100);
let err = read_capped(accents.as_bytes(), cap).expect_err("over the cap");
assert!(err.to_string().contains("limit"), "{err}");
let err = read_capped(&[0xFFu8, 0xFE][..], cap).expect_err("not UTF-8");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
}
#[cfg(unix)]
#[test]
fn a_pipe_nobody_writes_to_is_refused_before_it_is_opened() {
let dir = TempDir::new("ics-fifo");
let path = dir.join("cal.ics");
let made = std::process::Command::new("mkfifo")
.arg(&path)
.status()
.expect("running mkfifo");
assert!(made.success(), "mkfifo failed");
let (sent, outcome) = std::sync::mpsc::channel();
let reader = {
let path = path.clone();
std::thread::spawn(move || {
let _ = sent.send(read_calendar(&path).map(|text| text.len()));
})
};
let Ok(outcome) = outcome.recv_timeout(Duration::from_secs(5)) else {
drop(std::fs::OpenOptions::new().write(true).open(&path));
let _ = reader.join();
panic!("read_calendar was still waiting on a pipe with no writer after 5 s");
};
reader.join().expect("the reader thread");
let message = outcome.expect_err("a pipe must be refused").to_string();
assert!(
message.starts_with("a pipe"),
"says what it is, first, for a status cut to the panel: {message}"
);
let message = read_calendar(&dir)
.expect_err("a directory must be refused")
.to_string();
assert!(message.starts_with("a directory"), "{message}");
}
#[cfg(unix)]
#[test]
fn f_refuses_a_pipe_where_it_was_typed() {
let dir = TempDir::new("agenda-prompt-fifo");
let path = dir.join("cal.ics");
let made = std::process::Command::new("mkfifo")
.arg(&path)
.status()
.expect("running mkfifo");
assert!(made.success(), "mkfifo failed");
let mut panel = AgendaPanel::new(&AgendaConfig::default(), path.clone());
let key = |code| KeyEvent::new(code, ratatui::crossterm::event::KeyModifiers::NONE);
panel.handle_key(key(KeyCode::Char('f')));
panel.handle_key(key(KeyCode::Enter));
let prompt = panel.asking.as_ref().expect("a pipe keeps the prompt open");
assert_eq!(prompt.value(), path.display().to_string());
}
#[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_reload_asked_for_ends_the_wait_and_is_taken() {
let dir = TempDir::new("agenda-reload");
let path = Arc::new(Mutex::new(dir.join("cal.ics")));
std::fs::write(dir.join("cal.ics"), "BEGIN:VCALENDAR\nEND:VCALENDAR\n").unwrap();
let state = Arc::new(Mutex::new(State::default()));
let reload = Arc::new(AtomicBool::new(true));
let stop = Arc::new(AtomicBool::new(false));
let generation = Arc::new(AtomicU64::new(0));
let thread = {
let (path, state, reload, stop, generation) = (
Arc::clone(&path),
Arc::clone(&state),
Arc::clone(&reload),
Arc::clone(&stop),
Arc::clone(&generation),
);
std::thread::spawn(move || {
let hour = Duration::from_hours(1);
read_loop(&path, 1, hour, &state, &reload, &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 reader thread");
assert!(
generation.load(Ordering::Acquire) >= 2,
"the reload did not end the wait"
);
assert!(!reload.load(Ordering::Acquire), "and the request was taken");
}
#[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"
);
}
fn still_panel() -> AgendaPanel {
let mut panel = idle_panel();
panel.stop.store(true, Ordering::Relaxed);
let deadline = Instant::now() + Duration::from_secs(10);
while Arc::strong_count(&panel.generation) > 1 {
assert!(Instant::now() < deadline, "the reader thread did not stop");
std::thread::sleep(Duration::from_millis(5));
}
panel.tick();
panel
}
#[test]
fn an_event_starting_or_ending_asks_for_a_redraw_without_a_read() {
let mut panel = still_panel();
let day = date(2026, 8, 1);
panel.shown.events = vec![event(day, 9, "standup", false)];
panel.shown.error = None;
let at = |hour: i8, minute: i8, second: i8| {
day.at(hour, minute, second, 0).to_zoned(tz()).unwrap()
};
let steps = [
(at(8, 40, 0), false, "where the test starts"),
(at(8, 45, 0), false, "twenty minutes out, nothing is drawn"),
(at(8, 50, 0), true, "ten minutes out the alert appears"),
(at(8, 50, 30), true, "and counts down a minute"),
(at(8, 50, 40), false, "but not between minutes"),
(
at(9, 0, 1),
true,
"it starts: the marker comes, the alert goes",
),
(at(9, 30, 0), false, "under way, nothing moves"),
(at(10, 0, 0), true, "it ends: the marker goes"),
(at(10, 30, 0), false, "over, nothing moves"),
];
for (now, expected, why) in steps {
assert_eq!(panel.tick_at(&now), expected, "{why}, at {now}");
}
}
#[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"
);
}
#[test]
fn the_path_shown_by_o_is_wrapped_rather_than_cut_off() {
let path = PathBuf::from("/Users/someone/Library/Application Support/mirador/calendar.ics");
let mut panel = AgendaPanel::new(&AgendaConfig::default(), path);
panel.handle_key(KeyEvent::from(KeyCode::Char('o')));
let rows = screen(&mut panel, 30, 12);
let joined: String = rows.iter().map(|row| row.trim_end()).collect();
assert!(
joined.contains("mirador/calendar.ics"),
"the path lost its end at 30 columns: {rows:#?}"
);
assert!(
!joined.contains('…'),
"and a path that fits says nothing was cut: {rows:#?}"
);
let short = screen(&mut panel, 30, 3);
assert!(
short[2].trim_end().ends_with('…'),
"a path with fewer rows than it needs says so: {short:#?}"
);
}
#[test]
fn the_meeting_under_way_has_the_room_any_other_row_has() {
let theme = crate::theme::Theme::default();
let mut e = event(date(2026, 8, 1), 9, "Design review", false);
e.location = Some("Room 12".into());
for in_progress in [false, true] {
let text: String = event_line(&e, in_progress, true, 34, &theme)
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
assert!(
text.contains("Room 12"),
"in progress {in_progress}: `{text}`"
);
assert_eq!(crate::grid::display_width(&text), 34, "`{text}`");
}
}
#[test]
fn a_day_coming_into_the_window_is_not_news() {
let midnight = |day: Date| day.at(0, 0, 0, 0).to_zoned(tz()).unwrap().timestamp();
let read = |first: Date, events: Vec<ical::Event>| State {
events,
built_for: Some(first),
built_until: Some(midnight(first + Span::new().days(2))),
..State::default()
};
let (wed, thu, fri) = (date(2026, 10, 7), date(2026, 10, 8), date(2026, 10, 9));
let standup = |day| event(day, 9, "Standup", false);
let mut panel = idle_panel();
panel.shown = read(wed, vec![standup(wed), standup(thu)]);
panel.note_new_entries();
assert!(panel.pending.is_empty(), "a first read is never news");
panel.shown = read(thu, vec![standup(thu), standup(fri)]);
panel.note_new_entries();
let logged: Vec<&str> = panel.pending.iter().map(|e| e.text.as_str()).collect();
assert!(logged.is_empty(), "Friday came into view: {logged:?}");
panel.shown = read(
thu,
vec![standup(thu), event(fri, 15, "Dentist", false), standup(fri)],
);
panel.note_new_entries();
let logged: Vec<&str> = panel.pending.iter().map(|e| e.text.as_str()).collect();
assert_eq!(logged.len(), 1, "{logged:?}");
assert!(
logged[0].starts_with("Dentist appeared"),
"an event added inside the window is still news: {logged:?}"
);
}
#[test]
fn another_calendar_is_read_as_quietly_as_the_first() {
let wed = date(2026, 10, 7);
let until = wed.at(0, 0, 0, 0).to_zoned(tz()).unwrap().timestamp() + Span::new().hours(72);
let read = |file: &str, events: Vec<ical::Event>| State {
events,
built_for: Some(wed),
built_until: Some(until),
source: Some(PathBuf::from(file)),
..State::default()
};
let logged = |panel: &AgendaPanel| -> Vec<String> {
panel.pending.iter().map(|e| e.text.clone()).collect()
};
let mut panel = idle_panel();
panel.shown = read("home.ics", vec![event(wed, 9, "Standup", false)]);
panel.note_new_entries();
panel.set_path(PathBuf::from("work.ics"));
panel.shown = read("home.ics", vec![event(wed, 9, "Standup", false)]);
panel.note_new_entries();
let work = vec![
event(wed, 10, "Planning", false),
event(wed, 14, "Review", false),
];
panel.shown = read("work.ics", work.clone());
panel.note_new_entries();
assert!(logged(&panel).is_empty(), "{:?}", logged(&panel));
let mut more = work;
more.push(event(wed, 16, "Retro", false));
panel.shown = read("work.ics", more);
panel.note_new_entries();
let logged = logged(&panel);
assert_eq!(logged.len(), 1, "{logged:?}");
assert!(
logged[0].starts_with("Retro appeared"),
"the new calendar's own additions are still news: {logged:?}"
);
}
#[test]
fn a_read_says_where_its_window_ends() {
let dir = TempDir::new("agenda-until");
let ics = dir.join("cal.ics");
std::fs::write(&ics, "BEGIN:VCALENDAR\nEND:VCALENDAR\n").unwrap();
let config = AgendaConfig {
days: 3,
..AgendaConfig::default()
};
let mut panel = AgendaPanel::new(&config, ics);
for _ in 0..100 {
if panel.tick() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
let today = panel.shown.built_for.expect("a read landed");
let until = panel.shown.built_until.expect("and said where it stops");
let tz = TimeZone::system();
let expected = ical::local_midnight(today + Span::new().days(3), &tz).unwrap();
assert_eq!(until, expected.timestamp());
}
#[test]
fn the_empty_panel_names_the_file_key_it_has() {
let dir = TempDir::new("agenda-empty-keys");
let missing = dir.join("no-such-calendar.ics");
let text = |keys: &str| {
let mut panel = AgendaPanel::new(&AgendaConfig::default(), missing.clone());
for _ in 0..50 {
if panel.tick() {
break;
}
std::thread::sleep(Duration::from_millis(20));
}
let config: crate::config::Config =
toml::from_str(&format!("[agenda.keys]\n{keys}")).expect("a config");
panel.set_keys(&config);
screen(&mut panel, 60, 14)
.iter()
.map(|row| row.trim_end())
.collect::<Vec<_>>()
.join(" ")
};
let moved = text("file = \"i\"");
assert!(moved.contains("No agenda file"), "the empty state: {moved}");
assert!(moved.contains("or press i."), "{moved}");
assert!(!moved.contains("press f"), "{moved}");
let unbound = text("file = []");
assert!(unbound.contains("syncs to."), "{unbound}");
assert!(!unbound.contains("press"), "{unbound}");
assert!(text("").contains("or press f."));
}
}