1use ratatui::Frame;
32use ratatui::layout::{Constraint, Direction, Layout, Rect};
33use ratatui::style::{Color, Modifier, Style};
34use ratatui::text::{Line, Span};
35use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
36
37use crate::app::App;
38use crate::recurrence::detect::DueWarning;
39
40pub const WARNING_KEYS: &str = "g: generate d: defer i: ignore c/Esc: close";
42
43#[derive(Debug, Clone, Default, PartialEq, Eq)]
49pub struct RecurringWarning {
50 pub warnings: Vec<DueWarning>,
52 pub selected: usize,
54}
55
56impl RecurringWarning {
57 pub fn new(warnings: Vec<DueWarning>) -> Self {
59 Self {
60 warnings,
61 selected: 0,
62 }
63 }
64
65 pub fn len(&self) -> usize {
67 self.warnings.len()
68 }
69
70 pub fn is_empty(&self) -> bool {
72 self.warnings.is_empty()
73 }
74
75 pub fn current(&self) -> Option<&DueWarning> {
77 self.warnings.get(self.selected)
78 }
79
80 pub fn move_selected(&mut self, delta: isize) -> bool {
82 let last = self.warnings.len().saturating_sub(1);
83 let before = self.selected;
84 let next = if delta.is_negative() {
85 self.selected.saturating_sub(delta.unsigned_abs())
86 } else {
87 self.selected.saturating_add(delta as usize)
88 };
89 self.selected = next.min(last);
90 self.selected != before
91 }
92
93 pub fn remove_current(&mut self) {
99 if self.selected < self.warnings.len() {
100 self.warnings.remove(self.selected);
101 }
102 if self.selected >= self.warnings.len() {
103 self.selected = self.warnings.len().saturating_sub(1);
104 }
105 }
106}
107
108fn warning_lines(warning: &DueWarning, selected: bool) -> Vec<Line<'static>> {
110 let marker = if selected { "> " } else { " " };
111 let title_style = if selected {
112 Style::default().add_modifier(Modifier::BOLD)
113 } else {
114 Style::default()
115 };
116 vec![
117 Line::from(vec![
118 Span::raw(marker),
119 Span::styled(
120 format!("{} · {}", warning.series_name, warning.due_date),
121 title_style,
122 ),
123 ]),
124 Line::from(Span::styled(
125 format!(" {} · {}", warning.occurrence_key, warning.rule_summary),
126 Style::default().fg(Color::DarkGray),
127 )),
128 ]
129}
130
131pub fn render(app: &App, f: &mut Frame, area: Rect) {
135 let Some(warning) = app.recurring_warning.as_ref() else {
136 return;
137 };
138 let title = format!("Due occurrences ({}) — {}", warning.len(), WARNING_KEYS);
139 let block = Block::default().borders(Borders::ALL).title(title);
140 let inner = block.inner(area);
141 f.render_widget(block, area);
142 if warning.is_empty() {
143 f.render_widget(
144 Paragraph::new("Nothing is due any more. Press Esc to close.")
145 .style(Style::default().fg(Color::DarkGray)),
146 inner,
147 );
148 return;
149 }
150
151 let panes = Layout::default()
154 .direction(Direction::Horizontal)
155 .constraints([Constraint::Percentage(55), Constraint::Min(24)])
156 .split(inner);
157 let mut lines: Vec<Line<'static>> = Vec::new();
158 for (index, item) in warning.warnings.iter().enumerate() {
159 lines.extend(warning_lines(item, index == warning.selected));
160 }
161 f.render_widget(Paragraph::new(lines), panes[0]);
162
163 if let Some(current) = warning.current() {
164 let detail = vec![
165 Line::from(vec![
166 Span::styled("series: ", Style::default().fg(Color::DarkGray)),
167 Span::raw(current.series_id.as_str().to_string()),
168 ]),
169 Line::from(vec![
170 Span::styled("occurrence: ", Style::default().fg(Color::DarkGray)),
171 Span::raw(current.occurrence_key.as_str().to_string()),
172 ]),
173 Line::from(vec![
174 Span::styled("due: ", Style::default().fg(Color::DarkGray)),
175 Span::raw(current.due_date.as_str().to_string()),
176 ]),
177 Line::from(vec![
178 Span::styled("rule: ", Style::default().fg(Color::DarkGray)),
179 Span::raw(current.rule_summary.clone()),
180 ]),
181 Line::from(vec![
182 Span::styled("actions: ", Style::default().fg(Color::DarkGray)),
183 Span::raw(
184 current
185 .available_actions
186 .iter()
187 .map(|action| action.as_str())
188 .collect::<Vec<_>>()
189 .join(", "),
190 ),
191 ]),
192 ];
193 f.render_widget(Paragraph::new(detail).wrap(Wrap { trim: false }), panes[1]);
194 }
195}
196
197#[cfg(test)]
198mod tests {
199 #[allow(clippy::wildcard_imports)]
200 use super::*;
201 use crate::recurrence::detect::WarningAction;
202 use crate::recurrence::rule::DateKey;
203 use crate::recurrence::series::{OccurrenceKey, SeriesId};
204
205 fn warning(number: u32) -> DueWarning {
206 let id = SeriesId::parse(&format!("s-{number}")).expect("a valid id");
207 let date = DateKey::from_date(
208 time::Date::from_calendar_date(2026, time::Month::January, number as u8)
209 .expect("a real date"),
210 );
211 DueWarning {
212 series_id: id.clone(),
213 series_name: format!("Series {number}"),
214 occurrence_key: OccurrenceKey::new(&id, &date),
215 due_date: date,
216 rule_summary: "daily".to_string(),
217 available_actions: WarningAction::ALL.to_vec(),
218 }
219 }
220
221 #[test]
222 fn the_overlay_starts_on_the_first_row_and_clamps() {
223 let mut overlay = RecurringWarning::new(vec![warning(1), warning(2)]);
224 assert_eq!(overlay.len(), 2);
225 assert!(!overlay.is_empty());
226 assert_eq!(overlay.current().map(|w| w.series_id.as_str()), Some("s-1"));
227 assert!(overlay.move_selected(1));
228 assert_eq!(overlay.current().map(|w| w.series_id.as_str()), Some("s-2"));
229 assert!(!overlay.move_selected(1), "the last row is the last row");
230 assert!(overlay.move_selected(-5), "clamped to the first row");
231 assert_eq!(overlay.current().map(|w| w.series_id.as_str()), Some("s-1"));
232 }
233
234 #[test]
235 fn acting_on_a_row_drops_it_and_keeps_the_cursor_in_range() {
236 let mut overlay = RecurringWarning::new(vec![warning(1), warning(2), warning(3)]);
237 overlay.move_selected(2);
238 assert_eq!(overlay.current().map(|w| w.series_id.as_str()), Some("s-3"));
239 overlay.remove_current();
240 assert_eq!(overlay.len(), 2);
241 assert_eq!(
242 overlay.current().map(|w| w.series_id.as_str()),
243 Some("s-2"),
244 "the cursor stays on the last available row"
245 );
246 overlay.remove_current();
247 overlay.remove_current();
248 assert!(overlay.is_empty());
249 assert!(overlay.current().is_none());
250 overlay.remove_current();
251 assert!(overlay.is_empty(), "removing past the end is inert");
252 }
253
254 #[test]
255 fn the_advertised_keys_are_the_four_documented_answers() {
256 for expected in ["g: generate", "d: defer", "i: ignore", "close"] {
257 assert!(
258 WARNING_KEYS.contains(expected),
259 "the overlay advertises `{expected}`: {WARNING_KEYS}"
260 );
261 }
262 }
263}