use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
use crate::app::App;
use crate::recurrence::detect::DueWarning;
pub const WARNING_KEYS: &str = "g: generate d: defer i: ignore c/Esc: close";
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RecurringWarning {
pub warnings: Vec<DueWarning>,
pub selected: usize,
}
impl RecurringWarning {
pub fn new(warnings: Vec<DueWarning>) -> Self {
Self {
warnings,
selected: 0,
}
}
pub fn len(&self) -> usize {
self.warnings.len()
}
pub fn is_empty(&self) -> bool {
self.warnings.is_empty()
}
pub fn current(&self) -> Option<&DueWarning> {
self.warnings.get(self.selected)
}
pub fn move_selected(&mut self, delta: isize) -> bool {
let last = self.warnings.len().saturating_sub(1);
let before = self.selected;
let next = if delta.is_negative() {
self.selected.saturating_sub(delta.unsigned_abs())
} else {
self.selected.saturating_add(delta as usize)
};
self.selected = next.min(last);
self.selected != before
}
pub fn remove_current(&mut self) {
if self.selected < self.warnings.len() {
self.warnings.remove(self.selected);
}
if self.selected >= self.warnings.len() {
self.selected = self.warnings.len().saturating_sub(1);
}
}
}
fn warning_lines(warning: &DueWarning, selected: bool) -> Vec<Line<'static>> {
let marker = if selected { "> " } else { " " };
let title_style = if selected {
Style::default().add_modifier(Modifier::BOLD)
} else {
Style::default()
};
vec![
Line::from(vec![
Span::raw(marker),
Span::styled(
format!("{} · {}", warning.series_name, warning.due_date),
title_style,
),
]),
Line::from(Span::styled(
format!(" {} · {}", warning.occurrence_key, warning.rule_summary),
Style::default().fg(Color::DarkGray),
)),
]
}
pub fn render(app: &App, f: &mut Frame, area: Rect) {
let Some(warning) = app.recurring_warning.as_ref() else {
return;
};
let title = format!("Due occurrences ({}) — {}", warning.len(), WARNING_KEYS);
let block = Block::default().borders(Borders::ALL).title(title);
let inner = block.inner(area);
f.render_widget(block, area);
if warning.is_empty() {
f.render_widget(
Paragraph::new("Nothing is due any more. Press Esc to close.")
.style(Style::default().fg(Color::DarkGray)),
inner,
);
return;
}
let panes = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(55), Constraint::Min(24)])
.split(inner);
let mut lines: Vec<Line<'static>> = Vec::new();
for (index, item) in warning.warnings.iter().enumerate() {
lines.extend(warning_lines(item, index == warning.selected));
}
f.render_widget(Paragraph::new(lines), panes[0]);
if let Some(current) = warning.current() {
let detail = vec![
Line::from(vec![
Span::styled("series: ", Style::default().fg(Color::DarkGray)),
Span::raw(current.series_id.as_str().to_string()),
]),
Line::from(vec![
Span::styled("occurrence: ", Style::default().fg(Color::DarkGray)),
Span::raw(current.occurrence_key.as_str().to_string()),
]),
Line::from(vec![
Span::styled("due: ", Style::default().fg(Color::DarkGray)),
Span::raw(current.due_date.as_str().to_string()),
]),
Line::from(vec![
Span::styled("rule: ", Style::default().fg(Color::DarkGray)),
Span::raw(current.rule_summary.clone()),
]),
Line::from(vec![
Span::styled("actions: ", Style::default().fg(Color::DarkGray)),
Span::raw(
current
.available_actions
.iter()
.map(|action| action.as_str())
.collect::<Vec<_>>()
.join(", "),
),
]),
];
f.render_widget(Paragraph::new(detail).wrap(Wrap { trim: false }), panes[1]);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::recurrence::detect::WarningAction;
use crate::recurrence::rule::DateKey;
use crate::recurrence::series::{OccurrenceKey, SeriesId};
fn warning(number: u32) -> DueWarning {
let id = SeriesId::parse(&format!("s-{number}")).expect("a valid id");
let date = DateKey::from_date(
time::Date::from_calendar_date(2026, time::Month::January, number as u8)
.expect("a real date"),
);
DueWarning {
series_id: id.clone(),
series_name: format!("Series {number}"),
occurrence_key: OccurrenceKey::new(&id, &date),
due_date: date,
rule_summary: "daily".to_string(),
available_actions: WarningAction::ALL.to_vec(),
}
}
#[test]
fn the_overlay_starts_on_the_first_row_and_clamps() {
let mut overlay = RecurringWarning::new(vec![warning(1), warning(2)]);
assert_eq!(overlay.len(), 2);
assert!(!overlay.is_empty());
assert_eq!(overlay.current().map(|w| w.series_id.as_str()), Some("s-1"));
assert!(overlay.move_selected(1));
assert_eq!(overlay.current().map(|w| w.series_id.as_str()), Some("s-2"));
assert!(!overlay.move_selected(1), "the last row is the last row");
assert!(overlay.move_selected(-5), "clamped to the first row");
assert_eq!(overlay.current().map(|w| w.series_id.as_str()), Some("s-1"));
}
#[test]
fn acting_on_a_row_drops_it_and_keeps_the_cursor_in_range() {
let mut overlay = RecurringWarning::new(vec![warning(1), warning(2), warning(3)]);
overlay.move_selected(2);
assert_eq!(overlay.current().map(|w| w.series_id.as_str()), Some("s-3"));
overlay.remove_current();
assert_eq!(overlay.len(), 2);
assert_eq!(
overlay.current().map(|w| w.series_id.as_str()),
Some("s-2"),
"the cursor stays on the last available row"
);
overlay.remove_current();
overlay.remove_current();
assert!(overlay.is_empty());
assert!(overlay.current().is_none());
overlay.remove_current();
assert!(overlay.is_empty(), "removing past the end is inert");
}
#[test]
fn the_advertised_keys_are_the_four_documented_answers() {
for expected in ["g: generate", "d: defer", "i: ignore", "close"] {
assert!(
WARNING_KEYS.contains(expected),
"the overlay advertises `{expected}`: {WARNING_KEYS}"
);
}
}
}