Rust · 137078 bytes Raw Blame History
1 use std::{
2 collections::HashMap,
3 fmt,
4 time::{Duration, Instant},
5 };
6
7 use crossterm::event::{
8 KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
9 };
10 use time::{Month, Time, Weekday};
11
12 use crate::{
13 agenda::{
14 AgendaSource, CreateEventDraft, CreateEventTiming, DayAgenda, Event, EventDateTime,
15 EventTiming, EventWriteTarget, EventWriteTargetId, OccurrenceAnchor, RecurrenceEnd,
16 RecurrenceFrequency, RecurrenceMonthlyRule, RecurrenceRule, RecurrenceYearlyRule, Reminder,
17 recurrence_ordinal_for_date,
18 },
19 calendar::{CalendarDate, CalendarMonth, DAYS_PER_WEEK},
20 };
21
22 const MOUSE_DOUBLE_CLICK_TIMEOUT: Duration = Duration::from_millis(500);
23
24 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
25 pub enum ViewMode {
26 Month,
27 Day,
28 }
29
30 #[derive(Debug, Clone, PartialEq, Eq)]
31 pub struct AppState {
32 selected_date: CalendarDate,
33 today: CalendarDate,
34 view_mode: ViewMode,
35 create_form: Option<CreateEventForm>,
36 recurrence_choice: Option<RecurrenceEditChoice>,
37 delete_choice: Option<EventDeleteChoice>,
38 copy_choice: Option<EventCopyChoice>,
39 help_open: bool,
40 selected_day_event_id: Option<String>,
41 should_quit: bool,
42 }
43
44 impl AppState {
45 pub const fn new(start_date: CalendarDate) -> Self {
46 Self::from_dates(start_date, start_date)
47 }
48
49 pub const fn from_dates(selected_date: CalendarDate, today: CalendarDate) -> Self {
50 Self {
51 selected_date,
52 today,
53 view_mode: ViewMode::Month,
54 create_form: None,
55 recurrence_choice: None,
56 delete_choice: None,
57 copy_choice: None,
58 help_open: false,
59 selected_day_event_id: None,
60 should_quit: false,
61 }
62 }
63
64 pub const fn selected_date(&self) -> CalendarDate {
65 self.selected_date
66 }
67
68 pub const fn today(&self) -> CalendarDate {
69 self.today
70 }
71
72 pub const fn view_mode(&self) -> ViewMode {
73 self.view_mode
74 }
75
76 pub const fn should_quit(&self) -> bool {
77 self.should_quit
78 }
79
80 pub const fn create_form(&self) -> Option<&CreateEventForm> {
81 self.create_form.as_ref()
82 }
83
84 pub const fn recurrence_choice(&self) -> Option<&RecurrenceEditChoice> {
85 self.recurrence_choice.as_ref()
86 }
87
88 pub const fn delete_choice(&self) -> Option<&EventDeleteChoice> {
89 self.delete_choice.as_ref()
90 }
91
92 pub const fn copy_choice(&self) -> Option<&EventCopyChoice> {
93 self.copy_choice.as_ref()
94 }
95
96 pub fn selected_day_event_id(&self) -> Option<&str> {
97 self.selected_day_event_id.as_deref()
98 }
99
100 pub const fn is_creating_event(&self) -> bool {
101 self.create_form.is_some()
102 }
103
104 pub const fn is_choosing_recurring_edit(&self) -> bool {
105 self.recurrence_choice.is_some()
106 }
107
108 pub const fn is_confirming_delete(&self) -> bool {
109 self.delete_choice.is_some()
110 }
111
112 pub const fn is_copying_event(&self) -> bool {
113 self.copy_choice.is_some()
114 }
115
116 pub const fn is_showing_help(&self) -> bool {
117 self.help_open
118 }
119
120 pub fn close_create_form(&mut self) {
121 self.create_form = None;
122 }
123
124 pub fn close_recurrence_choice(&mut self) {
125 self.recurrence_choice = None;
126 }
127
128 pub fn close_delete_choice(&mut self) {
129 self.delete_choice = None;
130 }
131
132 pub fn close_copy_choice(&mut self) {
133 self.copy_choice = None;
134 }
135
136 pub fn close_help(&mut self) {
137 self.help_open = false;
138 }
139
140 pub fn set_delete_error(&mut self, message: impl Into<String>) {
141 if let Some(choice) = &mut self.delete_choice {
142 choice.error = Some(message.into());
143 }
144 }
145
146 pub fn set_copy_error(&mut self, message: impl Into<String>) {
147 if let Some(choice) = &mut self.copy_choice {
148 choice.error = Some(message.into());
149 }
150 }
151
152 pub fn set_create_form_error(&mut self, message: impl Into<String>) {
153 if let Some(form) = &mut self.create_form {
154 form.error = Some(message.into());
155 }
156 }
157
158 pub fn handle_create_key(&mut self, key: KeyEvent) -> CreateEventInputResult {
159 let Some(form) = &mut self.create_form else {
160 return CreateEventInputResult::Continue;
161 };
162
163 form.handle_key(key)
164 }
165
166 pub fn handle_recurrence_choice_key(
167 &mut self,
168 key: KeyEvent,
169 source: &dyn AgendaSource,
170 ) -> RecurrenceChoiceInputResult {
171 if key.kind == KeyEventKind::Release {
172 return RecurrenceChoiceInputResult::Continue;
173 }
174
175 let Some(choice) = &mut self.recurrence_choice else {
176 return RecurrenceChoiceInputResult::Continue;
177 };
178
179 match key.code {
180 KeyCode::Esc => RecurrenceChoiceInputResult::Cancel,
181 KeyCode::Up => {
182 choice.select_previous();
183 RecurrenceChoiceInputResult::Continue
184 }
185 KeyCode::Down => {
186 choice.select_next();
187 RecurrenceChoiceInputResult::Continue
188 }
189 KeyCode::Enter => match choice.selected_action() {
190 RecurrenceEditChoiceAction::ThisOccurrence => {
191 let series_id = choice.series_id.clone();
192 let anchor = choice.anchor;
193 self.recurrence_choice = None;
194 if let Some(event) = selectable_day_events(self.selected_date, source)
195 .into_iter()
196 .find(|event| {
197 event
198 .occurrence()
199 .map(|occurrence| {
200 occurrence.series_id == series_id && occurrence.anchor == anchor
201 })
202 .unwrap_or(false)
203 })
204 {
205 self.create_form = Some(CreateEventForm::edit_occurrence_with_targets(
206 &event,
207 source.event_write_targets(),
208 ));
209 }
210 RecurrenceChoiceInputResult::Continue
211 }
212 RecurrenceEditChoiceAction::Series => {
213 let series_id = choice.series_id.clone();
214 self.recurrence_choice = None;
215 if let Some(event) = source.editable_event_by_id(&series_id) {
216 self.create_form = Some(CreateEventForm::edit_with_targets(
217 &event,
218 source.event_write_targets(),
219 ));
220 }
221 RecurrenceChoiceInputResult::Continue
222 }
223 RecurrenceEditChoiceAction::Cancel => RecurrenceChoiceInputResult::Cancel,
224 },
225 _ => RecurrenceChoiceInputResult::Continue,
226 }
227 }
228
229 pub fn handle_delete_choice_key(&mut self, key: KeyEvent) -> EventDeleteInputResult {
230 if key.kind == KeyEventKind::Release {
231 return EventDeleteInputResult::Continue;
232 }
233
234 let Some(choice) = &mut self.delete_choice else {
235 return EventDeleteInputResult::Continue;
236 };
237
238 match key.code {
239 KeyCode::Esc => EventDeleteInputResult::Cancel,
240 KeyCode::Up => {
241 choice.select_previous();
242 EventDeleteInputResult::Continue
243 }
244 KeyCode::Down => {
245 choice.select_next();
246 EventDeleteInputResult::Continue
247 }
248 KeyCode::Enter => match choice.selected_action() {
249 EventDeleteChoiceAction::Cancel => EventDeleteInputResult::Cancel,
250 EventDeleteChoiceAction::DeleteEvent => {
251 EventDeleteInputResult::Submit(EventDeleteSubmission::Event {
252 event_id: choice.event_id().to_string(),
253 })
254 }
255 EventDeleteChoiceAction::DeleteThisOccurrence => {
256 let EventDeleteTarget::Occurrence { series_id, anchor } = &choice.target else {
257 return EventDeleteInputResult::Continue;
258 };
259 EventDeleteInputResult::Submit(EventDeleteSubmission::Occurrence {
260 series_id: series_id.clone(),
261 anchor: *anchor,
262 })
263 }
264 EventDeleteChoiceAction::DeleteSeries => {
265 let EventDeleteTarget::Occurrence { series_id, .. } = &choice.target else {
266 return EventDeleteInputResult::Continue;
267 };
268 EventDeleteInputResult::Submit(EventDeleteSubmission::Series {
269 series_id: series_id.clone(),
270 })
271 }
272 },
273 _ => EventDeleteInputResult::Continue,
274 }
275 }
276
277 pub fn handle_copy_choice_key(&mut self, key: KeyEvent) -> EventCopyInputResult {
278 if key.kind == KeyEventKind::Release {
279 return EventCopyInputResult::Continue;
280 }
281
282 let Some(choice) = &mut self.copy_choice else {
283 return EventCopyInputResult::Continue;
284 };
285
286 match key.code {
287 KeyCode::Esc => EventCopyInputResult::Cancel,
288 KeyCode::Up => {
289 choice.select_previous();
290 EventCopyInputResult::Continue
291 }
292 KeyCode::Down => {
293 choice.select_next();
294 EventCopyInputResult::Continue
295 }
296 KeyCode::Enter => match choice.selected_action() {
297 EventCopyChoiceAction::Cancel => EventCopyInputResult::Cancel,
298 EventCopyChoiceAction::CopyEvent => {
299 EventCopyInputResult::Submit(EventCopySubmission::Event {
300 event_id: choice.event_id().to_string(),
301 })
302 }
303 EventCopyChoiceAction::CopyThisOccurrence => {
304 let EventCopyTarget::Occurrence { series_id, anchor } = &choice.target else {
305 return EventCopyInputResult::Continue;
306 };
307 EventCopyInputResult::Submit(EventCopySubmission::Occurrence {
308 series_id: series_id.clone(),
309 anchor: *anchor,
310 })
311 }
312 EventCopyChoiceAction::CopySeries => {
313 let EventCopyTarget::Occurrence { series_id, .. } = &choice.target else {
314 return EventCopyInputResult::Continue;
315 };
316 EventCopyInputResult::Submit(EventCopySubmission::Series {
317 series_id: series_id.clone(),
318 })
319 }
320 },
321 _ => EventCopyInputResult::Continue,
322 }
323 }
324
325 pub fn handle_help_key(&mut self, key: KeyEvent) -> HelpInputResult {
326 if key.kind == KeyEventKind::Release {
327 return HelpInputResult::Continue;
328 }
329
330 match key.code {
331 KeyCode::Esc | KeyCode::Enter | KeyCode::Char('?') => {
332 self.close_help();
333 HelpInputResult::Close
334 }
335 KeyCode::Char(value) if value.eq_ignore_ascii_case(&'q') => {
336 self.should_quit = true;
337 HelpInputResult::Continue
338 }
339 KeyCode::Char(value) if ctrl_c(value, key.modifiers) => {
340 self.should_quit = true;
341 HelpInputResult::Continue
342 }
343 _ => HelpInputResult::Continue,
344 }
345 }
346
347 pub fn calendar_month(&self) -> CalendarMonth {
348 CalendarMonth::from_dates(self.selected_date, self.today)
349 }
350
351 pub fn day_agenda<S>(&self, source: &S) -> DayAgenda
352 where
353 S: AgendaSource + ?Sized,
354 {
355 DayAgenda::from_source(self.selected_date, source)
356 }
357
358 pub fn reconcile_day_event_selection(&mut self, source: &dyn AgendaSource) {
359 if self.view_mode != ViewMode::Day {
360 self.selected_day_event_id = None;
361 return;
362 }
363
364 let events = selectable_day_events(self.selected_date, source);
365 if let Some(selected_id) = &self.selected_day_event_id
366 && events.iter().any(|event| &event.id == selected_id)
367 {
368 return;
369 }
370
371 self.selected_day_event_id = events.first().map(|event| event.id.clone());
372 }
373
374 pub fn apply(&mut self, action: AppAction) {
375 self.apply_resolved(action, None);
376 }
377
378 pub fn apply_with_agenda_source(&mut self, action: AppAction, source: &dyn AgendaSource) {
379 self.apply_resolved(action, Some(source));
380 }
381
382 fn apply_resolved(&mut self, action: AppAction, source: Option<&dyn AgendaSource>) {
383 match action {
384 AppAction::Noop => {}
385 AppAction::Quit => self.should_quit = true,
386 AppAction::OpenHelp
387 if self.create_form.is_none()
388 && self.recurrence_choice.is_none()
389 && self.delete_choice.is_none()
390 && self.copy_choice.is_none() =>
391 {
392 self.help_open = true;
393 }
394 _ if self.help_open => {}
395 AppAction::OpenDay if self.view_mode == ViewMode::Day => {
396 if let Some(source) = source {
397 self.open_selected_event_for_edit(source);
398 }
399 }
400 AppAction::OpenDay => {
401 self.view_mode = ViewMode::Day;
402 if let Some(source) = source {
403 self.reconcile_day_event_selection(source);
404 }
405 }
406 AppAction::CloseDay => {
407 self.view_mode = ViewMode::Month;
408 self.selected_day_event_id = None;
409 self.recurrence_choice = None;
410 self.delete_choice = None;
411 self.copy_choice = None;
412 self.help_open = false;
413 }
414 AppAction::OpenCreate => {
415 if self.create_form.is_none()
416 && self.recurrence_choice.is_none()
417 && self.delete_choice.is_none()
418 && self.copy_choice.is_none()
419 && !self.help_open
420 {
421 let context = match self.view_mode {
422 ViewMode::Month => CreateEventContext::EditableDate,
423 ViewMode::Day => CreateEventContext::FixedDate,
424 };
425 let targets = source
426 .map(AgendaSource::event_write_targets)
427 .unwrap_or_else(|| vec![EventWriteTarget::local()]);
428 let selected_target = source
429 .map(AgendaSource::default_event_write_target)
430 .unwrap_or(EventWriteTargetId::Local);
431 self.create_form = Some(CreateEventForm::new_with_targets(
432 self.selected_date,
433 context,
434 targets,
435 selected_target,
436 ));
437 }
438 }
439 AppAction::OpenDelete if self.view_mode == ViewMode::Day => {
440 if self.create_form.is_none()
441 && self.recurrence_choice.is_none()
442 && self.delete_choice.is_none()
443 && self.copy_choice.is_none()
444 && !self.help_open
445 && let Some(source) = source
446 {
447 self.open_selected_event_for_delete(source);
448 }
449 }
450 AppAction::OpenCopy if self.view_mode == ViewMode::Day => {
451 if self.create_form.is_none()
452 && self.recurrence_choice.is_none()
453 && self.delete_choice.is_none()
454 && self.copy_choice.is_none()
455 && !self.help_open
456 && let Some(source) = source
457 {
458 self.open_selected_event_for_copy(source);
459 }
460 }
461 AppAction::MoveDays(days) if self.view_mode == ViewMode::Month => {
462 self.selected_date = self.selected_date.add_days(days);
463 }
464 AppAction::MoveDays(days)
465 if self.view_mode == ViewMode::Day && matches!(days, -1 | 1) =>
466 {
467 self.selected_date = self.selected_date.add_days(days);
468 if let Some(source) = source {
469 self.reconcile_day_event_selection(source);
470 } else {
471 self.selected_day_event_id = None;
472 }
473 }
474 AppAction::MoveDays(-7) if self.view_mode == ViewMode::Day => {
475 if let Some(source) = source {
476 self.move_day_event_selection(source, -1);
477 }
478 }
479 AppAction::MoveDays(7) if self.view_mode == ViewMode::Day => {
480 if let Some(source) = source {
481 self.move_day_event_selection(source, 1);
482 }
483 }
484 AppAction::SelectDate(date) if self.view_mode == ViewMode::Month => {
485 self.selected_date = date;
486 }
487 AppAction::JumpToDay(day) if self.view_mode == ViewMode::Month => {
488 if let Some(date) = self.calendar_month().current.date(day) {
489 self.selected_date = date;
490 }
491 }
492 AppAction::JumpToWeekday(weekday) if self.view_mode == ViewMode::Month => {
493 if let Some(date) = self.weekday_in_selected_week(weekday) {
494 self.selected_date = date;
495 }
496 }
497 AppAction::MoveDays(_)
498 | AppAction::SelectDate(_)
499 | AppAction::JumpToDay(_)
500 | AppAction::JumpToWeekday(_)
501 | AppAction::OpenDelete
502 | AppAction::OpenCopy
503 | AppAction::OpenHelp => {}
504 }
505 }
506
507 fn move_day_event_selection(&mut self, source: &dyn AgendaSource, delta: i32) {
508 let events = selectable_day_events(self.selected_date, source);
509 if events.is_empty() {
510 self.selected_day_event_id = None;
511 return;
512 }
513
514 let current_index = self
515 .selected_day_event_id
516 .as_ref()
517 .and_then(|id| events.iter().position(|event| &event.id == id))
518 .unwrap_or(0);
519 let len = events.len();
520 let next_index = if delta < 0 {
521 (current_index + len - 1) % len
522 } else {
523 (current_index + 1) % len
524 };
525 self.selected_day_event_id = Some(events[next_index].id.clone());
526 }
527
528 fn open_selected_event_for_edit(&mut self, source: &dyn AgendaSource) {
529 self.reconcile_day_event_selection(source);
530 let Some(selected_id) = self.selected_day_event_id.as_deref() else {
531 return;
532 };
533 if let Some(event) = selectable_day_events(self.selected_date, source)
534 .into_iter()
535 .find(|event| event.id == selected_id)
536 {
537 if let Some(occurrence) = event.occurrence() {
538 self.recurrence_choice = Some(RecurrenceEditChoice::new(
539 occurrence.series_id.clone(),
540 occurrence.anchor,
541 ));
542 } else {
543 self.create_form = Some(CreateEventForm::edit_with_targets(
544 &event,
545 source.event_write_targets(),
546 ));
547 }
548 }
549 }
550
551 fn open_selected_event_for_delete(&mut self, source: &dyn AgendaSource) {
552 self.reconcile_day_event_selection(source);
553 let Some(selected_id) = self.selected_day_event_id.as_deref() else {
554 return;
555 };
556 if let Some(event) = selectable_day_events(self.selected_date, source)
557 .into_iter()
558 .find(|event| event.id == selected_id)
559 {
560 self.delete_choice = Some(EventDeleteChoice::for_event(&event));
561 }
562 }
563
564 fn open_selected_event_for_copy(&mut self, source: &dyn AgendaSource) {
565 self.reconcile_day_event_selection(source);
566 let Some(selected_id) = self.selected_day_event_id.as_deref() else {
567 return;
568 };
569 if let Some(event) = selectable_day_events(self.selected_date, source)
570 .into_iter()
571 .find(|event| event.id == selected_id)
572 {
573 self.copy_choice = Some(EventCopyChoice::for_event(&event));
574 }
575 }
576
577 pub fn select_day_event_id(&mut self, event_id: impl Into<String>) {
578 if self.view_mode == ViewMode::Day {
579 self.selected_day_event_id = Some(event_id.into());
580 }
581 }
582
583 fn weekday_in_selected_week(&self, weekday: Weekday) -> Option<CalendarDate> {
584 let month = self.calendar_month();
585 let selected = month.selected_cell()?;
586 let weekday_index = usize::from(weekday.number_days_from_sunday());
587
588 if weekday_index >= DAYS_PER_WEEK {
589 return None;
590 }
591
592 Some(month.weeks[selected.week_index].cells[weekday_index].date)
593 }
594 }
595
596 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
597 pub enum AppAction {
598 Noop,
599 MoveDays(i32),
600 SelectDate(CalendarDate),
601 JumpToDay(u8),
602 JumpToWeekday(Weekday),
603 OpenDay,
604 CloseDay,
605 OpenCreate,
606 OpenDelete,
607 OpenCopy,
608 OpenHelp,
609 Quit,
610 }
611
612 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
613 pub enum CreateEventContext {
614 EditableDate,
615 FixedDate,
616 }
617
618 #[derive(Debug, Clone, PartialEq, Eq)]
619 pub enum EventFormMode {
620 Create,
621 Edit {
622 event_id: String,
623 },
624 EditOccurrence {
625 series_id: String,
626 anchor: OccurrenceAnchor,
627 },
628 }
629
630 #[derive(Debug, Clone, PartialEq, Eq)]
631 pub struct RecurrenceEditChoice {
632 series_id: String,
633 anchor: OccurrenceAnchor,
634 selected: usize,
635 }
636
637 impl RecurrenceEditChoice {
638 const OPTIONS: [RecurrenceEditChoiceAction; 3] = [
639 RecurrenceEditChoiceAction::ThisOccurrence,
640 RecurrenceEditChoiceAction::Series,
641 RecurrenceEditChoiceAction::Cancel,
642 ];
643
644 fn new(series_id: String, anchor: OccurrenceAnchor) -> Self {
645 Self {
646 series_id,
647 anchor,
648 selected: 0,
649 }
650 }
651
652 pub fn rows(&self) -> Vec<RecurrenceEditChoiceRow> {
653 Self::OPTIONS
654 .iter()
655 .enumerate()
656 .map(|(index, action)| RecurrenceEditChoiceRow {
657 label: action.label(),
658 selected: index == self.selected,
659 })
660 .collect()
661 }
662
663 fn selected_action(&self) -> RecurrenceEditChoiceAction {
664 Self::OPTIONS[self.selected]
665 }
666
667 fn select_next(&mut self) {
668 self.selected = (self.selected + 1) % Self::OPTIONS.len();
669 }
670
671 fn select_previous(&mut self) {
672 self.selected = if self.selected == 0 {
673 Self::OPTIONS.len() - 1
674 } else {
675 self.selected - 1
676 };
677 }
678 }
679
680 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
681 pub struct RecurrenceEditChoiceRow {
682 pub label: &'static str,
683 pub selected: bool,
684 }
685
686 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
687 enum RecurrenceEditChoiceAction {
688 ThisOccurrence,
689 Series,
690 Cancel,
691 }
692
693 impl RecurrenceEditChoiceAction {
694 const fn label(self) -> &'static str {
695 match self {
696 Self::ThisOccurrence => "Edit this occurrence",
697 Self::Series => "Edit series",
698 Self::Cancel => "Cancel",
699 }
700 }
701 }
702
703 #[derive(Debug, Clone, PartialEq, Eq)]
704 pub enum RecurrenceChoiceInputResult {
705 Continue,
706 Cancel,
707 }
708
709 #[derive(Debug, Clone, PartialEq, Eq)]
710 pub struct EventDeleteChoice {
711 target: EventDeleteTarget,
712 selected: usize,
713 error: Option<String>,
714 }
715
716 impl EventDeleteChoice {
717 fn for_event(event: &Event) -> Self {
718 let target = if let Some(occurrence) = event.occurrence() {
719 EventDeleteTarget::Occurrence {
720 series_id: occurrence.series_id.clone(),
721 anchor: occurrence.anchor,
722 }
723 } else {
724 EventDeleteTarget::Event {
725 event_id: event.id.clone(),
726 }
727 };
728
729 Self {
730 target,
731 selected: 0,
732 error: None,
733 }
734 }
735
736 pub fn heading(&self) -> &'static str {
737 "Delete"
738 }
739
740 pub fn error(&self) -> Option<&str> {
741 self.error.as_deref()
742 }
743
744 pub fn rows(&self) -> Vec<EventDeleteChoiceRow> {
745 self.actions()
746 .into_iter()
747 .enumerate()
748 .map(|(index, action)| EventDeleteChoiceRow {
749 label: action.label(),
750 selected: index == self.selected,
751 dangerous: action.is_dangerous(),
752 })
753 .collect()
754 }
755
756 fn actions(&self) -> Vec<EventDeleteChoiceAction> {
757 match self.target {
758 EventDeleteTarget::Event { .. } => vec![
759 EventDeleteChoiceAction::DeleteEvent,
760 EventDeleteChoiceAction::Cancel,
761 ],
762 EventDeleteTarget::Occurrence { .. } => vec![
763 EventDeleteChoiceAction::DeleteThisOccurrence,
764 EventDeleteChoiceAction::DeleteSeries,
765 EventDeleteChoiceAction::Cancel,
766 ],
767 }
768 }
769
770 fn selected_action(&self) -> EventDeleteChoiceAction {
771 self.actions()[self.selected]
772 }
773
774 fn select_next(&mut self) {
775 let len = self.actions().len();
776 self.selected = (self.selected + 1) % len;
777 self.error = None;
778 }
779
780 fn select_previous(&mut self) {
781 let len = self.actions().len();
782 self.selected = if self.selected == 0 {
783 len - 1
784 } else {
785 self.selected - 1
786 };
787 self.error = None;
788 }
789
790 fn event_id(&self) -> &str {
791 match &self.target {
792 EventDeleteTarget::Event { event_id } => event_id,
793 EventDeleteTarget::Occurrence { series_id, .. } => series_id,
794 }
795 }
796 }
797
798 #[derive(Debug, Clone, PartialEq, Eq)]
799 enum EventDeleteTarget {
800 Event {
801 event_id: String,
802 },
803 Occurrence {
804 series_id: String,
805 anchor: OccurrenceAnchor,
806 },
807 }
808
809 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
810 pub struct EventDeleteChoiceRow {
811 pub label: &'static str,
812 pub selected: bool,
813 pub dangerous: bool,
814 }
815
816 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
817 enum EventDeleteChoiceAction {
818 DeleteEvent,
819 DeleteThisOccurrence,
820 DeleteSeries,
821 Cancel,
822 }
823
824 impl EventDeleteChoiceAction {
825 const fn label(self) -> &'static str {
826 match self {
827 Self::DeleteEvent => "Delete event",
828 Self::DeleteThisOccurrence => "Delete this occurrence",
829 Self::DeleteSeries => "Delete series",
830 Self::Cancel => "Cancel",
831 }
832 }
833
834 const fn is_dangerous(self) -> bool {
835 !matches!(self, Self::Cancel)
836 }
837 }
838
839 #[derive(Debug, Clone, PartialEq, Eq)]
840 pub enum EventDeleteSubmission {
841 Event {
842 event_id: String,
843 },
844 Occurrence {
845 series_id: String,
846 anchor: OccurrenceAnchor,
847 },
848 Series {
849 series_id: String,
850 },
851 }
852
853 #[derive(Debug, Clone, PartialEq, Eq)]
854 pub enum EventDeleteInputResult {
855 Continue,
856 Cancel,
857 Submit(EventDeleteSubmission),
858 }
859
860 #[derive(Debug, Clone, PartialEq, Eq)]
861 pub struct EventCopyChoice {
862 target: EventCopyTarget,
863 selected: usize,
864 error: Option<String>,
865 }
866
867 impl EventCopyChoice {
868 fn for_event(event: &Event) -> Self {
869 let target = if let Some(occurrence) = event.occurrence() {
870 EventCopyTarget::Occurrence {
871 series_id: occurrence.series_id.clone(),
872 anchor: occurrence.anchor,
873 }
874 } else {
875 EventCopyTarget::Event {
876 event_id: event.id.clone(),
877 }
878 };
879
880 Self {
881 target,
882 selected: 0,
883 error: None,
884 }
885 }
886
887 pub fn heading(&self) -> &'static str {
888 "Copy"
889 }
890
891 pub fn error(&self) -> Option<&str> {
892 self.error.as_deref()
893 }
894
895 pub fn rows(&self) -> Vec<EventCopyChoiceRow> {
896 self.actions()
897 .into_iter()
898 .enumerate()
899 .map(|(index, action)| EventCopyChoiceRow {
900 label: action.label(),
901 selected: index == self.selected,
902 })
903 .collect()
904 }
905
906 fn actions(&self) -> Vec<EventCopyChoiceAction> {
907 match self.target {
908 EventCopyTarget::Event { .. } => {
909 vec![
910 EventCopyChoiceAction::CopyEvent,
911 EventCopyChoiceAction::Cancel,
912 ]
913 }
914 EventCopyTarget::Occurrence { .. } => vec![
915 EventCopyChoiceAction::CopyThisOccurrence,
916 EventCopyChoiceAction::CopySeries,
917 EventCopyChoiceAction::Cancel,
918 ],
919 }
920 }
921
922 fn selected_action(&self) -> EventCopyChoiceAction {
923 self.actions()[self.selected]
924 }
925
926 fn select_next(&mut self) {
927 let len = self.actions().len();
928 self.selected = (self.selected + 1) % len;
929 self.error = None;
930 }
931
932 fn select_previous(&mut self) {
933 let len = self.actions().len();
934 self.selected = if self.selected == 0 {
935 len - 1
936 } else {
937 self.selected - 1
938 };
939 self.error = None;
940 }
941
942 fn event_id(&self) -> &str {
943 match &self.target {
944 EventCopyTarget::Event { event_id } => event_id,
945 EventCopyTarget::Occurrence { series_id, .. } => series_id,
946 }
947 }
948 }
949
950 #[derive(Debug, Clone, PartialEq, Eq)]
951 enum EventCopyTarget {
952 Event {
953 event_id: String,
954 },
955 Occurrence {
956 series_id: String,
957 anchor: OccurrenceAnchor,
958 },
959 }
960
961 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
962 pub struct EventCopyChoiceRow {
963 pub label: &'static str,
964 pub selected: bool,
965 }
966
967 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
968 enum EventCopyChoiceAction {
969 CopyEvent,
970 CopyThisOccurrence,
971 CopySeries,
972 Cancel,
973 }
974
975 impl EventCopyChoiceAction {
976 const fn label(self) -> &'static str {
977 match self {
978 Self::CopyEvent => "Copy event",
979 Self::CopyThisOccurrence => "Copy this occurrence",
980 Self::CopySeries => "Copy series",
981 Self::Cancel => "Cancel",
982 }
983 }
984 }
985
986 #[derive(Debug, Clone, PartialEq, Eq)]
987 pub enum EventCopySubmission {
988 Event {
989 event_id: String,
990 },
991 Occurrence {
992 series_id: String,
993 anchor: OccurrenceAnchor,
994 },
995 Series {
996 series_id: String,
997 },
998 }
999
1000 #[derive(Debug, Clone, PartialEq, Eq)]
1001 pub enum EventCopyInputResult {
1002 Continue,
1003 Cancel,
1004 Submit(EventCopySubmission),
1005 }
1006
1007 #[derive(Debug, Clone, PartialEq, Eq)]
1008 pub enum HelpInputResult {
1009 Continue,
1010 Close,
1011 }
1012
1013 #[derive(Debug, Clone, PartialEq, Eq)]
1014 pub struct CreateEventForm {
1015 mode: EventFormMode,
1016 context: CreateEventContext,
1017 targets: Vec<EventWriteTarget>,
1018 selected_target: usize,
1019 selected_date: CalendarDate,
1020 title: String,
1021 all_day: bool,
1022 start_date: String,
1023 start_time: String,
1024 end_date: String,
1025 end_time: String,
1026 location: String,
1027 notes: String,
1028 reminders: [bool; REMINDER_PRESETS.len()],
1029 repeat: RepeatFrequency,
1030 recurrence_interval: String,
1031 weekly_days: [bool; DAYS_PER_WEEK],
1032 monthly_mode: RecurrenceMonthlyFormMode,
1033 yearly_mode: RecurrenceYearlyFormMode,
1034 recurrence_end: RecurrenceEndFormMode,
1035 recurrence_until_date: String,
1036 recurrence_count: String,
1037 focused: usize,
1038 error: Option<String>,
1039 }
1040
1041 fn normalize_write_targets(
1042 targets: Vec<EventWriteTarget>,
1043 current: Option<EventWriteTarget>,
1044 ) -> Vec<EventWriteTarget> {
1045 let mut normalized = Vec::new();
1046 for target in targets.into_iter().chain(current) {
1047 if !normalized
1048 .iter()
1049 .any(|existing: &EventWriteTarget| existing.id == target.id)
1050 {
1051 normalized.push(target);
1052 }
1053 }
1054 if normalized.is_empty() {
1055 normalized.push(EventWriteTarget::local());
1056 }
1057 normalized
1058 }
1059
1060 fn selected_target_index(targets: &[EventWriteTarget], selected: &EventWriteTargetId) -> usize {
1061 targets
1062 .iter()
1063 .position(|target| &target.id == selected)
1064 .unwrap_or_default()
1065 }
1066
1067 impl CreateEventForm {
1068 pub fn new(selected_date: CalendarDate, context: CreateEventContext) -> Self {
1069 Self::new_with_targets(
1070 selected_date,
1071 context,
1072 vec![EventWriteTarget::local()],
1073 EventWriteTargetId::Local,
1074 )
1075 }
1076
1077 pub fn new_with_targets(
1078 selected_date: CalendarDate,
1079 context: CreateEventContext,
1080 targets: Vec<EventWriteTarget>,
1081 selected_target: EventWriteTargetId,
1082 ) -> Self {
1083 let targets = normalize_write_targets(targets, None);
1084 let selected_target = selected_target_index(&targets, &selected_target);
1085 Self {
1086 mode: EventFormMode::Create,
1087 context,
1088 targets,
1089 selected_target,
1090 selected_date,
1091 title: String::new(),
1092 all_day: false,
1093 start_date: selected_date.to_string(),
1094 start_time: "09:00".to_string(),
1095 end_date: selected_date.to_string(),
1096 end_time: "10:00".to_string(),
1097 location: String::new(),
1098 notes: String::new(),
1099 reminders: [false; REMINDER_PRESETS.len()],
1100 repeat: RepeatFrequency::None,
1101 recurrence_interval: "1".to_string(),
1102 weekly_days: weekly_days_for(selected_date.weekday()),
1103 monthly_mode: RecurrenceMonthlyFormMode::DayOfMonth,
1104 yearly_mode: RecurrenceYearlyFormMode::Date,
1105 recurrence_end: RecurrenceEndFormMode::Never,
1106 recurrence_until_date: selected_date.to_string(),
1107 recurrence_count: "10".to_string(),
1108 focused: 0,
1109 error: None,
1110 }
1111 }
1112
1113 pub fn edit(event: &Event) -> Self {
1114 Self::edit_with_targets(event, vec![EventWriteTarget::local()])
1115 }
1116
1117 pub fn edit_with_targets(event: &Event, targets: Vec<EventWriteTarget>) -> Self {
1118 let (all_day, start_date, start_time, end_date, end_time, selected_date) =
1119 match event.timing {
1120 EventTiming::AllDay { date } => (
1121 true,
1122 date.to_string(),
1123 "09:00".to_string(),
1124 date.to_string(),
1125 "10:00".to_string(),
1126 date,
1127 ),
1128 EventTiming::Timed { start, end } => (
1129 false,
1130 start.date.to_string(),
1131 format_time_field(start.time),
1132 end.date.to_string(),
1133 format_time_field(end.time),
1134 start.date,
1135 ),
1136 };
1137 let mut reminders = [false; REMINDER_PRESETS.len()];
1138 for reminder in &event.reminders {
1139 if let Some(index) = REMINDER_PRESETS
1140 .iter()
1141 .position(|preset| preset.minutes == reminder.minutes_before)
1142 {
1143 reminders[index] = true;
1144 }
1145 }
1146 let current_target = EventWriteTargetId::from_event(event);
1147 let targets = normalize_write_targets(
1148 targets,
1149 current_target.as_ref().map(|target| EventWriteTarget {
1150 id: target.clone(),
1151 label: event.source.source_name.clone(),
1152 }),
1153 );
1154 let selected_target = current_target
1155 .as_ref()
1156 .map(|target| selected_target_index(&targets, target))
1157 .unwrap_or_default();
1158
1159 let mut form = Self {
1160 mode: EventFormMode::Edit {
1161 event_id: event.id.clone(),
1162 },
1163 context: CreateEventContext::EditableDate,
1164 targets,
1165 selected_target,
1166 selected_date,
1167 title: event.title.clone(),
1168 all_day,
1169 start_date,
1170 start_time,
1171 end_date,
1172 end_time,
1173 location: event.location.clone().unwrap_or_default(),
1174 notes: event.notes.clone().unwrap_or_default(),
1175 reminders,
1176 repeat: RepeatFrequency::None,
1177 recurrence_interval: "1".to_string(),
1178 weekly_days: weekly_days_for(selected_date.weekday()),
1179 monthly_mode: RecurrenceMonthlyFormMode::DayOfMonth,
1180 yearly_mode: RecurrenceYearlyFormMode::Date,
1181 recurrence_end: RecurrenceEndFormMode::Never,
1182 recurrence_until_date: selected_date.to_string(),
1183 recurrence_count: "10".to_string(),
1184 focused: 0,
1185 error: None,
1186 };
1187 form.load_recurrence(event.recurrence.as_ref());
1188 form
1189 }
1190
1191 pub fn edit_occurrence(event: &Event) -> Self {
1192 Self::edit_occurrence_with_targets(event, vec![EventWriteTarget::local()])
1193 }
1194
1195 pub fn edit_occurrence_with_targets(event: &Event, targets: Vec<EventWriteTarget>) -> Self {
1196 let Some(occurrence) = event.occurrence() else {
1197 return Self::edit_with_targets(event, targets);
1198 };
1199 let mut form = Self::edit_with_targets(event, targets);
1200 form.mode = EventFormMode::EditOccurrence {
1201 series_id: occurrence.series_id.clone(),
1202 anchor: occurrence.anchor,
1203 };
1204 form.repeat = RepeatFrequency::None;
1205 form
1206 }
1207
1208 pub fn mode(&self) -> &EventFormMode {
1209 &self.mode
1210 }
1211
1212 pub fn heading(&self) -> &'static str {
1213 match &self.mode {
1214 EventFormMode::Create => "Create",
1215 EventFormMode::Edit { .. } | EventFormMode::EditOccurrence { .. } => "Edit",
1216 }
1217 }
1218
1219 pub const fn context(&self) -> CreateEventContext {
1220 self.context
1221 }
1222
1223 pub fn error(&self) -> Option<&str> {
1224 self.error.as_deref()
1225 }
1226
1227 pub fn rows(&self) -> Vec<CreateEventFormRow> {
1228 self.visible_fields()
1229 .into_iter()
1230 .enumerate()
1231 .map(|(index, field)| CreateEventFormRow {
1232 label: field.label(),
1233 value: self.field_value(field),
1234 focused: index == self.focused,
1235 kind: field.kind(),
1236 })
1237 .collect()
1238 }
1239
1240 pub fn handle_key(&mut self, key: KeyEvent) -> CreateEventInputResult {
1241 if key.kind == KeyEventKind::Release {
1242 return CreateEventInputResult::Continue;
1243 }
1244
1245 if ctrl_s(key) {
1246 return match self.submit() {
1247 Ok(draft) => CreateEventInputResult::Submit(Box::new(EventFormSubmission {
1248 mode: self.mode.clone(),
1249 draft,
1250 target: self.selected_target_id(),
1251 })),
1252 Err(err) => {
1253 self.error = Some(err.to_string());
1254 CreateEventInputResult::Continue
1255 }
1256 };
1257 }
1258
1259 match key.code {
1260 KeyCode::Esc => CreateEventInputResult::Cancel,
1261 KeyCode::Tab => {
1262 self.focus_next();
1263 CreateEventInputResult::Continue
1264 }
1265 KeyCode::BackTab => {
1266 self.focus_previous();
1267 CreateEventInputResult::Continue
1268 }
1269 KeyCode::Up => {
1270 self.focus_previous();
1271 CreateEventInputResult::Continue
1272 }
1273 KeyCode::Down => {
1274 self.focus_next();
1275 CreateEventInputResult::Continue
1276 }
1277 KeyCode::Left => {
1278 self.cycle_focused_field(-1);
1279 CreateEventInputResult::Continue
1280 }
1281 KeyCode::Right => {
1282 self.cycle_focused_field(1);
1283 CreateEventInputResult::Continue
1284 }
1285 KeyCode::Backspace => {
1286 self.edit_text_field(|value| {
1287 value.pop();
1288 });
1289 CreateEventInputResult::Continue
1290 }
1291 KeyCode::Enter => {
1292 self.activate_focused_field();
1293 CreateEventInputResult::Continue
1294 }
1295 KeyCode::Char(value)
1296 if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT =>
1297 {
1298 self.edit_text_field(|field| field.push(value));
1299 CreateEventInputResult::Continue
1300 }
1301 _ => CreateEventInputResult::Continue,
1302 }
1303 }
1304
1305 pub fn submit(&self) -> Result<CreateEventDraft, CreateEventFormError> {
1306 let title = normalize_required(&self.title, "title")?;
1307 let location = normalize_optional(&self.location);
1308 let notes = normalize_optional(&self.notes);
1309 let reminders = self
1310 .reminders
1311 .iter()
1312 .zip(REMINDER_PRESETS)
1313 .filter_map(|(enabled, preset)| {
1314 enabled.then_some(Reminder::minutes_before(preset.minutes))
1315 })
1316 .collect::<Vec<_>>();
1317
1318 if self.all_day {
1319 let date = self.start_date()?;
1320 let recurrence = self.recurrence_rule(date)?;
1321 return Ok(CreateEventDraft {
1322 title,
1323 timing: CreateEventTiming::AllDay { date },
1324 location,
1325 notes,
1326 reminders,
1327 recurrence,
1328 });
1329 }
1330
1331 let start_date = self.start_date()?;
1332 let start_time = parse_time_field(&self.start_time, "start time")?;
1333 let end_time = parse_time_field(&self.end_time, "end time")?;
1334 let end_date = match self.context {
1335 CreateEventContext::EditableDate => self.end_date()?,
1336 CreateEventContext::FixedDate if end_time <= start_time => start_date.add_days(1),
1337 CreateEventContext::FixedDate => start_date,
1338 };
1339 let start = EventDateTime::new(start_date, start_time);
1340 let end = EventDateTime::new(end_date, end_time);
1341 if start >= end {
1342 return Err(CreateEventFormError::InvalidRange);
1343 }
1344 let recurrence = self.recurrence_rule(start_date)?;
1345
1346 Ok(CreateEventDraft {
1347 title,
1348 timing: CreateEventTiming::Timed { start, end },
1349 location,
1350 notes,
1351 reminders,
1352 recurrence,
1353 })
1354 }
1355
1356 fn start_date(&self) -> Result<CalendarDate, CreateEventFormError> {
1357 match self.context {
1358 CreateEventContext::EditableDate => parse_date_field(&self.start_date, "start date"),
1359 CreateEventContext::FixedDate => Ok(self.selected_date),
1360 }
1361 }
1362
1363 fn end_date(&self) -> Result<CalendarDate, CreateEventFormError> {
1364 parse_date_field(&self.end_date, "end date")
1365 }
1366
1367 fn load_recurrence(&mut self, recurrence: Option<&RecurrenceRule>) {
1368 let Some(recurrence) = recurrence else {
1369 return;
1370 };
1371 self.repeat = RepeatFrequency::from_rule(recurrence.frequency);
1372 self.recurrence_interval = recurrence.interval().to_string();
1373 if !recurrence.weekdays.is_empty() {
1374 self.weekly_days = [false; DAYS_PER_WEEK];
1375 for weekday in &recurrence.weekdays {
1376 self.weekly_days[usize::from(weekday.number_days_from_sunday())] = true;
1377 }
1378 }
1379 self.recurrence_end = match recurrence.end {
1380 RecurrenceEnd::Never => RecurrenceEndFormMode::Never,
1381 RecurrenceEnd::Until(date) => {
1382 self.recurrence_until_date = date.to_string();
1383 RecurrenceEndFormMode::Until
1384 }
1385 RecurrenceEnd::Count(count) => {
1386 self.recurrence_count = count.to_string();
1387 RecurrenceEndFormMode::Count
1388 }
1389 };
1390 if let Some(monthly) = recurrence.monthly {
1391 self.monthly_mode = match monthly {
1392 RecurrenceMonthlyRule::DayOfMonth(_) => RecurrenceMonthlyFormMode::DayOfMonth,
1393 RecurrenceMonthlyRule::WeekdayOrdinal { .. } => {
1394 RecurrenceMonthlyFormMode::WeekdayOrdinal
1395 }
1396 };
1397 }
1398 if let Some(yearly) = recurrence.yearly {
1399 self.yearly_mode = match yearly {
1400 RecurrenceYearlyRule::Date { .. } => RecurrenceYearlyFormMode::Date,
1401 RecurrenceYearlyRule::WeekdayOrdinal { .. } => {
1402 RecurrenceYearlyFormMode::WeekdayOrdinal
1403 }
1404 };
1405 }
1406 }
1407
1408 fn recurrence_rule(
1409 &self,
1410 start_date: CalendarDate,
1411 ) -> Result<Option<RecurrenceRule>, CreateEventFormError> {
1412 if matches!(self.mode, EventFormMode::EditOccurrence { .. })
1413 || self.repeat == RepeatFrequency::None
1414 {
1415 return Ok(None);
1416 }
1417
1418 let interval = parse_positive_u16(&self.recurrence_interval, "interval")?;
1419 let end = match self.recurrence_end {
1420 RecurrenceEndFormMode::Never => RecurrenceEnd::Never,
1421 RecurrenceEndFormMode::Until => {
1422 RecurrenceEnd::Until(parse_date_field(&self.recurrence_until_date, "until date")?)
1423 }
1424 RecurrenceEndFormMode::Count => {
1425 RecurrenceEnd::Count(parse_positive_u32(&self.recurrence_count, "count")?)
1426 }
1427 };
1428 let frequency = self.repeat.frequency().expect("repeat is not none");
1429 let mut rule = RecurrenceRule::new(frequency).with_interval(interval);
1430 rule.end = end;
1431
1432 match self.repeat {
1433 RepeatFrequency::Weekly => {
1434 rule.weekdays = self
1435 .weekly_days
1436 .iter()
1437 .enumerate()
1438 .filter_map(|(index, enabled)| enabled.then_some(weekday_at(index)))
1439 .collect();
1440 if rule.weekdays.is_empty() {
1441 rule.weekdays.push(start_date.weekday());
1442 }
1443 }
1444 RepeatFrequency::Monthly => {
1445 rule.monthly = Some(match self.monthly_mode {
1446 RecurrenceMonthlyFormMode::DayOfMonth => {
1447 RecurrenceMonthlyRule::DayOfMonth(start_date.day())
1448 }
1449 RecurrenceMonthlyFormMode::WeekdayOrdinal => {
1450 RecurrenceMonthlyRule::WeekdayOrdinal {
1451 ordinal: recurrence_ordinal_for_date(start_date),
1452 weekday: start_date.weekday(),
1453 }
1454 }
1455 });
1456 }
1457 RepeatFrequency::Yearly => {
1458 rule.yearly = Some(match self.yearly_mode {
1459 RecurrenceYearlyFormMode::Date => RecurrenceYearlyRule::Date {
1460 month: start_date.month(),
1461 day: start_date.day(),
1462 },
1463 RecurrenceYearlyFormMode::WeekdayOrdinal => {
1464 RecurrenceYearlyRule::WeekdayOrdinal {
1465 month: start_date.month(),
1466 ordinal: recurrence_ordinal_for_date(start_date),
1467 weekday: start_date.weekday(),
1468 }
1469 }
1470 });
1471 }
1472 RepeatFrequency::Daily | RepeatFrequency::None => {}
1473 }
1474
1475 Ok(Some(rule))
1476 }
1477
1478 fn visible_fields(&self) -> Vec<CreateEventField> {
1479 let mut fields = vec![CreateEventField::Title];
1480 if !matches!(self.mode, EventFormMode::EditOccurrence { .. }) {
1481 fields.push(CreateEventField::Calendar);
1482 }
1483 fields.push(CreateEventField::AllDay);
1484 if self.context == CreateEventContext::EditableDate {
1485 fields.push(CreateEventField::StartDate);
1486 }
1487 fields.push(CreateEventField::StartTime);
1488 if self.context == CreateEventContext::EditableDate {
1489 fields.push(CreateEventField::EndDate);
1490 }
1491 fields.extend([
1492 CreateEventField::EndTime,
1493 CreateEventField::Location,
1494 CreateEventField::Notes,
1495 ]);
1496 fields.extend((0..REMINDER_PRESETS.len()).map(CreateEventField::Reminder));
1497 if !matches!(self.mode, EventFormMode::EditOccurrence { .. }) {
1498 fields.push(CreateEventField::Repeat);
1499 if self.repeat != RepeatFrequency::None {
1500 fields.push(CreateEventField::RecurrenceInterval);
1501 match self.repeat {
1502 RepeatFrequency::Weekly => {
1503 fields.extend((0..DAYS_PER_WEEK).map(CreateEventField::WeeklyDay));
1504 }
1505 RepeatFrequency::Monthly => fields.push(CreateEventField::MonthlyMode),
1506 RepeatFrequency::Yearly => fields.push(CreateEventField::YearlyMode),
1507 RepeatFrequency::Daily | RepeatFrequency::None => {}
1508 }
1509 fields.push(CreateEventField::RecurrenceEnd);
1510 match self.recurrence_end {
1511 RecurrenceEndFormMode::Until => fields.push(CreateEventField::UntilDate),
1512 RecurrenceEndFormMode::Count => fields.push(CreateEventField::OccurrenceCount),
1513 RecurrenceEndFormMode::Never => {}
1514 }
1515 }
1516 }
1517 fields
1518 }
1519
1520 fn field_value(&self, field: CreateEventField) -> String {
1521 match field {
1522 CreateEventField::Title => self.title.clone(),
1523 CreateEventField::Calendar => self.targets[self.selected_target].label.clone(),
1524 CreateEventField::AllDay => checkbox(self.all_day).to_string(),
1525 CreateEventField::StartDate => self.start_date.clone(),
1526 CreateEventField::StartTime => self.start_time.clone(),
1527 CreateEventField::EndDate => self.end_date.clone(),
1528 CreateEventField::EndTime => self.end_time.clone(),
1529 CreateEventField::Location => self.location.clone(),
1530 CreateEventField::Notes => self.notes.clone(),
1531 CreateEventField::Reminder(index) => {
1532 let preset = REMINDER_PRESETS[index];
1533 format!("{} {}", checkbox(self.reminders[index]), preset.label)
1534 }
1535 CreateEventField::Repeat => self.repeat.label().to_string(),
1536 CreateEventField::RecurrenceInterval => self.recurrence_interval.clone(),
1537 CreateEventField::WeeklyDay(index) => {
1538 format!(
1539 "{} {}",
1540 checkbox(self.weekly_days[index]),
1541 weekday_short_label(weekday_at(index))
1542 )
1543 }
1544 CreateEventField::MonthlyMode => self.monthly_mode.label().to_string(),
1545 CreateEventField::YearlyMode => self.yearly_mode.label().to_string(),
1546 CreateEventField::RecurrenceEnd => self.recurrence_end.label().to_string(),
1547 CreateEventField::UntilDate => self.recurrence_until_date.clone(),
1548 CreateEventField::OccurrenceCount => self.recurrence_count.clone(),
1549 }
1550 }
1551
1552 fn focus_next(&mut self) {
1553 let field_count = self.visible_fields().len();
1554 self.focused = (self.focused + 1) % field_count;
1555 self.error = None;
1556 }
1557
1558 fn focus_previous(&mut self) {
1559 let field_count = self.visible_fields().len();
1560 self.focused = if self.focused == 0 {
1561 field_count - 1
1562 } else {
1563 self.focused - 1
1564 };
1565 self.error = None;
1566 }
1567
1568 fn focused_field(&self) -> CreateEventField {
1569 self.visible_fields()[self.focused]
1570 }
1571
1572 fn activate_focused_field(&mut self) {
1573 match self.focused_field() {
1574 CreateEventField::AllDay => self.all_day = !self.all_day,
1575 CreateEventField::Calendar => self.cycle_target(1),
1576 CreateEventField::Reminder(index) => self.reminders[index] = !self.reminders[index],
1577 CreateEventField::Notes => self.notes.push('\n'),
1578 CreateEventField::Repeat => self.repeat = self.repeat.next(),
1579 CreateEventField::WeeklyDay(index) => {
1580 self.weekly_days[index] = !self.weekly_days[index]
1581 }
1582 CreateEventField::MonthlyMode => self.monthly_mode = self.monthly_mode.next(),
1583 CreateEventField::YearlyMode => self.yearly_mode = self.yearly_mode.next(),
1584 CreateEventField::RecurrenceEnd => self.recurrence_end = self.recurrence_end.next(),
1585 _ => {}
1586 }
1587 self.error = None;
1588 }
1589
1590 fn cycle_focused_field(&mut self, delta: i32) {
1591 match self.focused_field() {
1592 CreateEventField::Calendar => self.cycle_target(delta),
1593 CreateEventField::Repeat => {
1594 self.repeat = if delta < 0 {
1595 self.repeat.previous()
1596 } else {
1597 self.repeat.next()
1598 };
1599 }
1600 CreateEventField::MonthlyMode => {
1601 self.monthly_mode = if delta < 0 {
1602 self.monthly_mode.previous()
1603 } else {
1604 self.monthly_mode.next()
1605 };
1606 }
1607 CreateEventField::YearlyMode => {
1608 self.yearly_mode = if delta < 0 {
1609 self.yearly_mode.previous()
1610 } else {
1611 self.yearly_mode.next()
1612 };
1613 }
1614 CreateEventField::RecurrenceEnd => {
1615 self.recurrence_end = if delta < 0 {
1616 self.recurrence_end.previous()
1617 } else {
1618 self.recurrence_end.next()
1619 };
1620 }
1621 _ => {}
1622 }
1623 self.error = None;
1624 }
1625
1626 fn cycle_target(&mut self, delta: i32) {
1627 let len = self.targets.len();
1628 if len <= 1 {
1629 return;
1630 }
1631 self.selected_target = if delta < 0 {
1632 (self.selected_target + len - 1) % len
1633 } else {
1634 (self.selected_target + 1) % len
1635 };
1636 }
1637
1638 fn selected_target_id(&self) -> EventWriteTargetId {
1639 self.targets
1640 .get(self.selected_target)
1641 .map(|target| target.id.clone())
1642 .unwrap_or(EventWriteTargetId::Local)
1643 }
1644
1645 fn edit_text_field(&mut self, edit: impl FnOnce(&mut String)) {
1646 let field = self.focused_field();
1647 let target = match field {
1648 CreateEventField::Title => Some(&mut self.title),
1649 CreateEventField::StartDate => Some(&mut self.start_date),
1650 CreateEventField::StartTime => Some(&mut self.start_time),
1651 CreateEventField::EndDate => Some(&mut self.end_date),
1652 CreateEventField::EndTime => Some(&mut self.end_time),
1653 CreateEventField::Location => Some(&mut self.location),
1654 CreateEventField::Notes => Some(&mut self.notes),
1655 CreateEventField::RecurrenceInterval => Some(&mut self.recurrence_interval),
1656 CreateEventField::UntilDate => Some(&mut self.recurrence_until_date),
1657 CreateEventField::OccurrenceCount => Some(&mut self.recurrence_count),
1658 CreateEventField::AllDay
1659 | CreateEventField::Calendar
1660 | CreateEventField::Reminder(_)
1661 | CreateEventField::Repeat
1662 | CreateEventField::WeeklyDay(_)
1663 | CreateEventField::MonthlyMode
1664 | CreateEventField::YearlyMode
1665 | CreateEventField::RecurrenceEnd => None,
1666 };
1667
1668 if let Some(target) = target {
1669 edit(target);
1670 self.error = None;
1671 }
1672 }
1673 }
1674
1675 #[derive(Debug, Clone, PartialEq, Eq)]
1676 pub struct CreateEventFormRow {
1677 pub label: &'static str,
1678 pub value: String,
1679 pub focused: bool,
1680 pub kind: CreateEventFormRowKind,
1681 }
1682
1683 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1684 pub enum CreateEventFormRowKind {
1685 Text,
1686 Multiline,
1687 Toggle,
1688 Selector,
1689 }
1690
1691 #[derive(Debug, Clone, PartialEq, Eq)]
1692 pub struct EventFormSubmission {
1693 pub mode: EventFormMode,
1694 pub draft: CreateEventDraft,
1695 pub target: EventWriteTargetId,
1696 }
1697
1698 #[derive(Debug, Clone, PartialEq, Eq)]
1699 pub enum CreateEventInputResult {
1700 Continue,
1701 Cancel,
1702 Submit(Box<EventFormSubmission>),
1703 }
1704
1705 #[derive(Debug, Clone, PartialEq, Eq)]
1706 pub enum CreateEventFormError {
1707 RequiredField(&'static str),
1708 InvalidDate { field: &'static str, value: String },
1709 InvalidTime { field: &'static str, value: String },
1710 InvalidNumber { field: &'static str, value: String },
1711 InvalidRange,
1712 }
1713
1714 impl std::fmt::Display for CreateEventFormError {
1715 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1716 match self {
1717 Self::RequiredField(field) => write!(f, "{field} is required"),
1718 Self::InvalidDate { field, value } => write!(f, "{field} '{value}' must be YYYY-MM-DD"),
1719 Self::InvalidTime { field, value } => write!(f, "{field} '{value}' must be HH:MM"),
1720 Self::InvalidNumber { field, value } => {
1721 write!(f, "{field} '{value}' must be a positive number")
1722 }
1723 Self::InvalidRange => write!(f, "end must be after start"),
1724 }
1725 }
1726 }
1727
1728 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1729 struct ReminderPreset {
1730 label: &'static str,
1731 minutes: u16,
1732 }
1733
1734 const REMINDER_PRESETS: [ReminderPreset; 6] = [
1735 ReminderPreset {
1736 label: "5m",
1737 minutes: 5,
1738 },
1739 ReminderPreset {
1740 label: "10m",
1741 minutes: 10,
1742 },
1743 ReminderPreset {
1744 label: "15m",
1745 minutes: 15,
1746 },
1747 ReminderPreset {
1748 label: "30m",
1749 minutes: 30,
1750 },
1751 ReminderPreset {
1752 label: "1h",
1753 minutes: 60,
1754 },
1755 ReminderPreset {
1756 label: "1d",
1757 minutes: 24 * 60,
1758 },
1759 ];
1760
1761 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1762 enum RepeatFrequency {
1763 None,
1764 Daily,
1765 Weekly,
1766 Monthly,
1767 Yearly,
1768 }
1769
1770 impl RepeatFrequency {
1771 const fn label(self) -> &'static str {
1772 match self {
1773 Self::None => "None",
1774 Self::Daily => "Daily",
1775 Self::Weekly => "Weekly",
1776 Self::Monthly => "Monthly",
1777 Self::Yearly => "Yearly",
1778 }
1779 }
1780
1781 const fn next(self) -> Self {
1782 match self {
1783 Self::None => Self::Daily,
1784 Self::Daily => Self::Weekly,
1785 Self::Weekly => Self::Monthly,
1786 Self::Monthly => Self::Yearly,
1787 Self::Yearly => Self::None,
1788 }
1789 }
1790
1791 const fn previous(self) -> Self {
1792 match self {
1793 Self::None => Self::Yearly,
1794 Self::Daily => Self::None,
1795 Self::Weekly => Self::Daily,
1796 Self::Monthly => Self::Weekly,
1797 Self::Yearly => Self::Monthly,
1798 }
1799 }
1800
1801 const fn frequency(self) -> Option<RecurrenceFrequency> {
1802 match self {
1803 Self::None => None,
1804 Self::Daily => Some(RecurrenceFrequency::Daily),
1805 Self::Weekly => Some(RecurrenceFrequency::Weekly),
1806 Self::Monthly => Some(RecurrenceFrequency::Monthly),
1807 Self::Yearly => Some(RecurrenceFrequency::Yearly),
1808 }
1809 }
1810
1811 const fn from_rule(frequency: RecurrenceFrequency) -> Self {
1812 match frequency {
1813 RecurrenceFrequency::Daily => Self::Daily,
1814 RecurrenceFrequency::Weekly => Self::Weekly,
1815 RecurrenceFrequency::Monthly => Self::Monthly,
1816 RecurrenceFrequency::Yearly => Self::Yearly,
1817 }
1818 }
1819 }
1820
1821 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1822 enum RecurrenceMonthlyFormMode {
1823 DayOfMonth,
1824 WeekdayOrdinal,
1825 }
1826
1827 impl RecurrenceMonthlyFormMode {
1828 const fn label(self) -> &'static str {
1829 match self {
1830 Self::DayOfMonth => "Day of month",
1831 Self::WeekdayOrdinal => "Nth weekday",
1832 }
1833 }
1834
1835 const fn next(self) -> Self {
1836 match self {
1837 Self::DayOfMonth => Self::WeekdayOrdinal,
1838 Self::WeekdayOrdinal => Self::DayOfMonth,
1839 }
1840 }
1841
1842 const fn previous(self) -> Self {
1843 self.next()
1844 }
1845 }
1846
1847 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1848 enum RecurrenceYearlyFormMode {
1849 Date,
1850 WeekdayOrdinal,
1851 }
1852
1853 impl RecurrenceYearlyFormMode {
1854 const fn label(self) -> &'static str {
1855 match self {
1856 Self::Date => "Date",
1857 Self::WeekdayOrdinal => "Nth weekday",
1858 }
1859 }
1860
1861 const fn next(self) -> Self {
1862 match self {
1863 Self::Date => Self::WeekdayOrdinal,
1864 Self::WeekdayOrdinal => Self::Date,
1865 }
1866 }
1867
1868 const fn previous(self) -> Self {
1869 self.next()
1870 }
1871 }
1872
1873 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1874 enum RecurrenceEndFormMode {
1875 Never,
1876 Until,
1877 Count,
1878 }
1879
1880 impl RecurrenceEndFormMode {
1881 const fn label(self) -> &'static str {
1882 match self {
1883 Self::Never => "Never",
1884 Self::Until => "Until date",
1885 Self::Count => "Count",
1886 }
1887 }
1888
1889 const fn next(self) -> Self {
1890 match self {
1891 Self::Never => Self::Until,
1892 Self::Until => Self::Count,
1893 Self::Count => Self::Never,
1894 }
1895 }
1896
1897 const fn previous(self) -> Self {
1898 match self {
1899 Self::Never => Self::Count,
1900 Self::Until => Self::Never,
1901 Self::Count => Self::Until,
1902 }
1903 }
1904 }
1905
1906 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1907 enum CreateEventField {
1908 Title,
1909 Calendar,
1910 AllDay,
1911 StartDate,
1912 StartTime,
1913 EndDate,
1914 EndTime,
1915 Location,
1916 Notes,
1917 Reminder(usize),
1918 Repeat,
1919 RecurrenceInterval,
1920 WeeklyDay(usize),
1921 MonthlyMode,
1922 YearlyMode,
1923 RecurrenceEnd,
1924 UntilDate,
1925 OccurrenceCount,
1926 }
1927
1928 impl CreateEventField {
1929 const fn label(self) -> &'static str {
1930 match self {
1931 Self::Title => "Title",
1932 Self::Calendar => "Calendar",
1933 Self::AllDay => "All day",
1934 Self::StartDate => "Start date",
1935 Self::StartTime => "Start time",
1936 Self::EndDate => "End date",
1937 Self::EndTime => "End time",
1938 Self::Location => "Location",
1939 Self::Notes => "Notes",
1940 Self::Reminder(_) => "Reminder",
1941 Self::Repeat => "Repeat",
1942 Self::RecurrenceInterval => "Interval",
1943 Self::WeeklyDay(_) => "Weekly",
1944 Self::MonthlyMode => "Monthly",
1945 Self::YearlyMode => "Yearly",
1946 Self::RecurrenceEnd => "Ends",
1947 Self::UntilDate => "Until",
1948 Self::OccurrenceCount => "Count",
1949 }
1950 }
1951
1952 const fn kind(self) -> CreateEventFormRowKind {
1953 match self {
1954 Self::Notes => CreateEventFormRowKind::Multiline,
1955 Self::AllDay | Self::Reminder(_) | Self::WeeklyDay(_) => CreateEventFormRowKind::Toggle,
1956 Self::Calendar
1957 | Self::Repeat
1958 | Self::MonthlyMode
1959 | Self::YearlyMode
1960 | Self::RecurrenceEnd => CreateEventFormRowKind::Selector,
1961 _ => CreateEventFormRowKind::Text,
1962 }
1963 }
1964 }
1965
1966 fn checkbox(enabled: bool) -> &'static str {
1967 if enabled { "[x]" } else { "[ ]" }
1968 }
1969
1970 fn weekly_days_for(weekday: Weekday) -> [bool; DAYS_PER_WEEK] {
1971 let mut days = [false; DAYS_PER_WEEK];
1972 days[usize::from(weekday.number_days_from_sunday())] = true;
1973 days
1974 }
1975
1976 fn weekday_at(index: usize) -> Weekday {
1977 match index {
1978 0 => Weekday::Sunday,
1979 1 => Weekday::Monday,
1980 2 => Weekday::Tuesday,
1981 3 => Weekday::Wednesday,
1982 4 => Weekday::Thursday,
1983 5 => Weekday::Friday,
1984 6 => Weekday::Saturday,
1985 _ => unreachable!("weekday index stays in range"),
1986 }
1987 }
1988
1989 fn weekday_short_label(weekday: Weekday) -> &'static str {
1990 match weekday {
1991 Weekday::Sunday => "Sun",
1992 Weekday::Monday => "Mon",
1993 Weekday::Tuesday => "Tue",
1994 Weekday::Wednesday => "Wed",
1995 Weekday::Thursday => "Thu",
1996 Weekday::Friday => "Fri",
1997 Weekday::Saturday => "Sat",
1998 }
1999 }
2000
2001 fn normalize_required(value: &str, field: &'static str) -> Result<String, CreateEventFormError> {
2002 let trimmed = value.trim();
2003 if trimmed.is_empty() {
2004 Err(CreateEventFormError::RequiredField(field))
2005 } else {
2006 Ok(trimmed.to_string())
2007 }
2008 }
2009
2010 fn normalize_optional(value: &str) -> Option<String> {
2011 let trimmed = value.trim();
2012 (!trimmed.is_empty()).then(|| trimmed.to_string())
2013 }
2014
2015 fn parse_positive_u16(value: &str, field: &'static str) -> Result<u16, CreateEventFormError> {
2016 let parsed = value.trim().parse::<u16>().ok().filter(|value| *value > 0);
2017 parsed.ok_or_else(|| CreateEventFormError::InvalidNumber {
2018 field,
2019 value: value.to_string(),
2020 })
2021 }
2022
2023 fn parse_positive_u32(value: &str, field: &'static str) -> Result<u32, CreateEventFormError> {
2024 let parsed = value.trim().parse::<u32>().ok().filter(|value| *value > 0);
2025 parsed.ok_or_else(|| CreateEventFormError::InvalidNumber {
2026 field,
2027 value: value.to_string(),
2028 })
2029 }
2030
2031 fn parse_date_field(
2032 value: &str,
2033 field: &'static str,
2034 ) -> Result<CalendarDate, CreateEventFormError> {
2035 let mut parts = value.trim().split('-');
2036 let year = parts.next().and_then(|value| value.parse::<i32>().ok());
2037 let month = parts.next().and_then(|value| value.parse::<u8>().ok());
2038 let day = parts.next().and_then(|value| value.parse::<u8>().ok());
2039 if parts.next().is_some() {
2040 return Err(CreateEventFormError::InvalidDate {
2041 field,
2042 value: value.to_string(),
2043 });
2044 }
2045
2046 let Some((year, month, day)) = year
2047 .zip(month)
2048 .zip(day)
2049 .map(|((year, month), day)| (year, month, day))
2050 else {
2051 return Err(CreateEventFormError::InvalidDate {
2052 field,
2053 value: value.to_string(),
2054 });
2055 };
2056
2057 CalendarDate::from_ymd(
2058 year,
2059 Month::try_from(month).map_err(|_| CreateEventFormError::InvalidDate {
2060 field,
2061 value: value.to_string(),
2062 })?,
2063 day,
2064 )
2065 .map_err(|_| CreateEventFormError::InvalidDate {
2066 field,
2067 value: value.to_string(),
2068 })
2069 }
2070
2071 fn parse_time_field(value: &str, field: &'static str) -> Result<Time, CreateEventFormError> {
2072 let mut parts = value.trim().split(':');
2073 let hour = parts.next().and_then(|value| value.parse::<u8>().ok());
2074 let minute = parts.next().and_then(|value| value.parse::<u8>().ok());
2075 if parts.next().is_some() {
2076 return Err(CreateEventFormError::InvalidTime {
2077 field,
2078 value: value.to_string(),
2079 });
2080 }
2081
2082 let Some((hour, minute)) = hour.zip(minute) else {
2083 return Err(CreateEventFormError::InvalidTime {
2084 field,
2085 value: value.to_string(),
2086 });
2087 };
2088
2089 Time::from_hms(hour, minute, 0).map_err(|_| CreateEventFormError::InvalidTime {
2090 field,
2091 value: value.to_string(),
2092 })
2093 }
2094
2095 fn format_time_field(time: Time) -> String {
2096 format!("{:02}:{:02}", time.hour(), time.minute())
2097 }
2098
2099 fn selectable_day_events(date: CalendarDate, source: &dyn AgendaSource) -> Vec<Event> {
2100 let agenda = DayAgenda::from_source(date, source);
2101 agenda
2102 .all_day_events
2103 .into_iter()
2104 .chain(
2105 agenda
2106 .timed_events
2107 .into_iter()
2108 .map(|agenda_event| agenda_event.event),
2109 )
2110 .filter(Event::is_editable)
2111 .collect()
2112 }
2113
2114 #[derive(Debug, Clone, PartialEq, Eq)]
2115 pub struct KeyboardInput {
2116 pending: PendingKey,
2117 bindings: KeyBindings,
2118 }
2119
2120 impl KeyboardInput {
2121 pub fn new(bindings: KeyBindings) -> Self {
2122 Self {
2123 pending: PendingKey::None,
2124 bindings,
2125 }
2126 }
2127
2128 pub fn translate(&mut self, key: KeyEvent) -> AppAction {
2129 if key.kind == KeyEventKind::Release {
2130 return AppAction::Noop;
2131 }
2132
2133 if let Some(value) = digit_from_key(key) {
2134 return self.translate_digit(value);
2135 }
2136
2137 let Some(gesture) = KeyGesture::from_key_event(key) else {
2138 self.clear();
2139 return AppAction::Noop;
2140 };
2141
2142 self.translate_gesture(gesture)
2143 }
2144
2145 pub fn clear(&mut self) {
2146 self.pending = PendingKey::None;
2147 }
2148
2149 pub const fn is_waiting_for_digit(&self) -> bool {
2150 matches!(self.pending, PendingKey::Digit(_))
2151 }
2152
2153 pub fn clear_digit(&mut self) {
2154 if self.is_waiting_for_digit() {
2155 self.clear();
2156 }
2157 }
2158
2159 fn translate_gesture(&mut self, gesture: KeyGesture) -> AppAction {
2160 match self.pending {
2161 PendingKey::Digit(_) => {
2162 self.clear();
2163 self.translate_gesture(gesture)
2164 }
2165 PendingKey::Sequence(first) => {
2166 self.clear();
2167 if let Some(command) = self
2168 .bindings
2169 .command_for_sequence(&KeySequence::two(first, gesture))
2170 {
2171 return command.app_action();
2172 }
2173
2174 self.translate_gesture(gesture)
2175 }
2176 PendingKey::None => {
2177 let sequence = KeySequence::one(gesture);
2178 if self.bindings.is_prefix(&sequence) {
2179 self.pending = PendingKey::Sequence(gesture);
2180 AppAction::Noop
2181 } else if let Some(command) = self.bindings.command_for_sequence(&sequence) {
2182 self.clear();
2183 command.app_action()
2184 } else {
2185 self.clear();
2186 AppAction::Noop
2187 }
2188 }
2189 }
2190 }
2191
2192 fn translate_digit(&mut self, value: char) -> AppAction {
2193 let digit = value
2194 .to_digit(10)
2195 .expect("ASCII digit converts to a base-10 digit") as u8;
2196
2197 match self.pending {
2198 PendingKey::Digit(first) => {
2199 self.clear();
2200 AppAction::JumpToDay(first * 10 + digit)
2201 }
2202 _ if digit == 0 => {
2203 self.pending = PendingKey::Digit(digit);
2204 AppAction::Noop
2205 }
2206 _ if digit <= 3 => {
2207 self.pending = PendingKey::Digit(digit);
2208 AppAction::JumpToDay(digit)
2209 }
2210 _ => {
2211 self.clear();
2212 AppAction::JumpToDay(digit)
2213 }
2214 }
2215 }
2216 }
2217
2218 impl Default for KeyboardInput {
2219 fn default() -> Self {
2220 Self::new(KeyBindings::default())
2221 }
2222 }
2223
2224 #[derive(Debug, Clone, PartialEq, Eq)]
2225 pub struct KeyBindings {
2226 entries: Vec<KeyBinding>,
2227 }
2228
2229 impl KeyBindings {
2230 pub fn from_lists(lists: KeyBindingLists) -> Result<Self, KeyBindingError> {
2231 let entries = lists.into_entries()?;
2232 validate_key_bindings(&entries)?;
2233 Ok(Self { entries })
2234 }
2235
2236 pub fn default_lists() -> KeyBindingLists {
2237 KeyBindingLists::default()
2238 }
2239
2240 pub fn with_overrides(overrides: KeyBindingOverrides) -> Result<Self, KeyBindingError> {
2241 let mut lists = KeyBindingLists::default();
2242 overrides.apply_to(&mut lists);
2243 Self::from_lists(lists)
2244 }
2245
2246 fn command_for_sequence(&self, sequence: &KeySequence) -> Option<KeyCommand> {
2247 self.entries
2248 .iter()
2249 .find_map(|entry| (entry.sequence == *sequence).then_some(entry.command))
2250 }
2251
2252 fn is_prefix(&self, sequence: &KeySequence) -> bool {
2253 self.entries
2254 .iter()
2255 .any(|entry| entry.sequence.starts_with(sequence) && entry.sequence != *sequence)
2256 }
2257
2258 pub fn display_for(&self, command: KeyCommand) -> String {
2259 let labels = self
2260 .entries
2261 .iter()
2262 .filter(|entry| entry.command == command)
2263 .map(|entry| entry.sequence.label())
2264 .collect::<Vec<_>>();
2265
2266 if labels.is_empty() {
2267 command.default_label().to_string()
2268 } else {
2269 labels.join(" / ")
2270 }
2271 }
2272 }
2273
2274 impl Default for KeyBindings {
2275 fn default() -> Self {
2276 Self::from_lists(KeyBindingLists::default()).expect("default keybindings are valid")
2277 }
2278 }
2279
2280 #[derive(Debug, Clone, PartialEq, Eq)]
2281 pub struct KeyBindingLists {
2282 pub move_left: Vec<String>,
2283 pub move_right: Vec<String>,
2284 pub move_up: Vec<String>,
2285 pub move_down: Vec<String>,
2286 pub open_day_or_edit: Vec<String>,
2287 pub close_day: Vec<String>,
2288 pub create_event: Vec<String>,
2289 pub delete_event: Vec<String>,
2290 pub copy_event: Vec<String>,
2291 pub help: Vec<String>,
2292 pub quit: Vec<String>,
2293 pub jump_monday: Vec<String>,
2294 pub jump_tuesday: Vec<String>,
2295 pub jump_wednesday: Vec<String>,
2296 pub jump_thursday: Vec<String>,
2297 pub jump_friday: Vec<String>,
2298 pub jump_saturday: Vec<String>,
2299 pub jump_sunday: Vec<String>,
2300 }
2301
2302 impl Default for KeyBindingLists {
2303 fn default() -> Self {
2304 Self {
2305 move_left: vec!["left".to_string()],
2306 move_right: vec!["right".to_string()],
2307 move_up: vec!["up".to_string()],
2308 move_down: vec!["down".to_string()],
2309 open_day_or_edit: vec!["enter".to_string()],
2310 close_day: vec!["esc".to_string()],
2311 create_event: vec!["+".to_string()],
2312 delete_event: vec!["d".to_string()],
2313 copy_event: vec!["c".to_string()],
2314 help: vec!["?".to_string()],
2315 quit: vec!["q".to_string(), "ctrl-c".to_string()],
2316 jump_monday: vec!["m".to_string()],
2317 jump_tuesday: vec!["tu".to_string()],
2318 jump_wednesday: vec!["w".to_string()],
2319 jump_thursday: vec!["th".to_string()],
2320 jump_friday: vec!["f".to_string()],
2321 jump_saturday: vec!["sa".to_string()],
2322 jump_sunday: vec!["su".to_string()],
2323 }
2324 }
2325 }
2326
2327 impl KeyBindingLists {
2328 fn into_entries(self) -> Result<Vec<KeyBinding>, KeyBindingError> {
2329 let mut entries = Vec::new();
2330 push_entries(&mut entries, KeyCommand::MoveLeft, self.move_left)?;
2331 push_entries(&mut entries, KeyCommand::MoveRight, self.move_right)?;
2332 push_entries(&mut entries, KeyCommand::MoveUp, self.move_up)?;
2333 push_entries(&mut entries, KeyCommand::MoveDown, self.move_down)?;
2334 push_entries(
2335 &mut entries,
2336 KeyCommand::OpenDayOrEdit,
2337 self.open_day_or_edit,
2338 )?;
2339 push_entries(&mut entries, KeyCommand::CloseDay, self.close_day)?;
2340 push_entries(&mut entries, KeyCommand::CreateEvent, self.create_event)?;
2341 push_entries(&mut entries, KeyCommand::DeleteEvent, self.delete_event)?;
2342 push_entries(&mut entries, KeyCommand::CopyEvent, self.copy_event)?;
2343 push_entries(&mut entries, KeyCommand::Help, self.help)?;
2344 push_entries(&mut entries, KeyCommand::Quit, self.quit)?;
2345 push_entries(&mut entries, KeyCommand::JumpMonday, self.jump_monday)?;
2346 push_entries(&mut entries, KeyCommand::JumpTuesday, self.jump_tuesday)?;
2347 push_entries(&mut entries, KeyCommand::JumpWednesday, self.jump_wednesday)?;
2348 push_entries(&mut entries, KeyCommand::JumpThursday, self.jump_thursday)?;
2349 push_entries(&mut entries, KeyCommand::JumpFriday, self.jump_friday)?;
2350 push_entries(&mut entries, KeyCommand::JumpSaturday, self.jump_saturday)?;
2351 push_entries(&mut entries, KeyCommand::JumpSunday, self.jump_sunday)?;
2352 Ok(entries)
2353 }
2354 }
2355
2356 #[derive(Debug, Default, Clone, PartialEq, Eq)]
2357 pub struct KeyBindingOverrides {
2358 pub move_left: Option<Vec<String>>,
2359 pub move_right: Option<Vec<String>>,
2360 pub move_up: Option<Vec<String>>,
2361 pub move_down: Option<Vec<String>>,
2362 pub open_day_or_edit: Option<Vec<String>>,
2363 pub close_day: Option<Vec<String>>,
2364 pub create_event: Option<Vec<String>>,
2365 pub delete_event: Option<Vec<String>>,
2366 pub copy_event: Option<Vec<String>>,
2367 pub help: Option<Vec<String>>,
2368 pub quit: Option<Vec<String>>,
2369 pub jump_monday: Option<Vec<String>>,
2370 pub jump_tuesday: Option<Vec<String>>,
2371 pub jump_wednesday: Option<Vec<String>>,
2372 pub jump_thursday: Option<Vec<String>>,
2373 pub jump_friday: Option<Vec<String>>,
2374 pub jump_saturday: Option<Vec<String>>,
2375 pub jump_sunday: Option<Vec<String>>,
2376 }
2377
2378 impl KeyBindingOverrides {
2379 fn apply_to(self, lists: &mut KeyBindingLists) {
2380 if let Some(value) = self.move_left {
2381 lists.move_left = value;
2382 }
2383 if let Some(value) = self.move_right {
2384 lists.move_right = value;
2385 }
2386 if let Some(value) = self.move_up {
2387 lists.move_up = value;
2388 }
2389 if let Some(value) = self.move_down {
2390 lists.move_down = value;
2391 }
2392 if let Some(value) = self.open_day_or_edit {
2393 lists.open_day_or_edit = value;
2394 }
2395 if let Some(value) = self.close_day {
2396 lists.close_day = value;
2397 }
2398 if let Some(value) = self.create_event {
2399 lists.create_event = value;
2400 }
2401 if let Some(value) = self.delete_event {
2402 lists.delete_event = value;
2403 }
2404 if let Some(value) = self.copy_event {
2405 lists.copy_event = value;
2406 }
2407 if let Some(value) = self.help {
2408 lists.help = value;
2409 }
2410 if let Some(value) = self.quit {
2411 lists.quit = value;
2412 }
2413 if let Some(value) = self.jump_monday {
2414 lists.jump_monday = value;
2415 }
2416 if let Some(value) = self.jump_tuesday {
2417 lists.jump_tuesday = value;
2418 }
2419 if let Some(value) = self.jump_wednesday {
2420 lists.jump_wednesday = value;
2421 }
2422 if let Some(value) = self.jump_thursday {
2423 lists.jump_thursday = value;
2424 }
2425 if let Some(value) = self.jump_friday {
2426 lists.jump_friday = value;
2427 }
2428 if let Some(value) = self.jump_saturday {
2429 lists.jump_saturday = value;
2430 }
2431 if let Some(value) = self.jump_sunday {
2432 lists.jump_sunday = value;
2433 }
2434 }
2435 }
2436
2437 fn push_entries(
2438 entries: &mut Vec<KeyBinding>,
2439 command: KeyCommand,
2440 keys: Vec<String>,
2441 ) -> Result<(), KeyBindingError> {
2442 if keys.is_empty() {
2443 return Err(KeyBindingError::new(format!(
2444 "{} must define at least one key",
2445 command.config_name()
2446 )));
2447 }
2448
2449 for key in keys {
2450 entries.push(KeyBinding {
2451 command,
2452 sequence: KeySequence::parse(&key)?,
2453 });
2454 }
2455
2456 Ok(())
2457 }
2458
2459 fn validate_key_bindings(entries: &[KeyBinding]) -> Result<(), KeyBindingError> {
2460 let mut seen = HashMap::new();
2461 for entry in entries {
2462 if let Some(existing) = seen.insert(entry.sequence.clone(), entry.command) {
2463 return Err(KeyBindingError::new(format!(
2464 "key '{}' is bound to both {} and {}",
2465 entry.sequence.label(),
2466 existing.config_name(),
2467 entry.command.config_name()
2468 )));
2469 }
2470 }
2471
2472 for left in entries {
2473 for right in entries {
2474 if left.sequence != right.sequence && right.sequence.starts_with(&left.sequence) {
2475 return Err(KeyBindingError::new(format!(
2476 "key '{}' conflicts with longer key '{}'",
2477 left.sequence.label(),
2478 right.sequence.label()
2479 )));
2480 }
2481 }
2482 }
2483
2484 Ok(())
2485 }
2486
2487 #[derive(Debug, Clone, PartialEq, Eq)]
2488 struct KeyBinding {
2489 command: KeyCommand,
2490 sequence: KeySequence,
2491 }
2492
2493 #[derive(Debug, Clone, PartialEq, Eq, Hash)]
2494 struct KeySequence(Vec<KeyGesture>);
2495
2496 impl KeySequence {
2497 fn one(gesture: KeyGesture) -> Self {
2498 Self(vec![gesture])
2499 }
2500
2501 fn two(first: KeyGesture, second: KeyGesture) -> Self {
2502 Self(vec![first, second])
2503 }
2504
2505 fn parse(value: &str) -> Result<Self, KeyBindingError> {
2506 if value.is_empty() {
2507 return Err(KeyBindingError::new("key binding may not be empty"));
2508 }
2509
2510 let normalized = value.to_ascii_lowercase();
2511 if let Some(gesture) = KeyGesture::parse_named(&normalized)? {
2512 return Ok(Self::one(gesture));
2513 }
2514
2515 let chars = normalized.chars().collect::<Vec<_>>();
2516 match chars.as_slice() {
2517 [single] => Ok(Self::one(KeyGesture::parse_printable(*single)?)),
2518 [first, second] => Ok(Self::two(
2519 KeyGesture::parse_printable(*first)?,
2520 KeyGesture::parse_printable(*second)?,
2521 )),
2522 _ => Err(KeyBindingError::new(format!(
2523 "unsupported key binding '{value}'"
2524 ))),
2525 }
2526 }
2527
2528 fn starts_with(&self, prefix: &Self) -> bool {
2529 self.0.starts_with(&prefix.0)
2530 }
2531
2532 fn label(&self) -> String {
2533 if self.0.len() == 2
2534 && self
2535 .0
2536 .iter()
2537 .all(|gesture| matches!(gesture, KeyGesture::Char(_)))
2538 {
2539 self.0
2540 .iter()
2541 .map(|gesture| gesture.label())
2542 .collect::<String>()
2543 } else {
2544 self.0
2545 .iter()
2546 .map(|gesture| gesture.label())
2547 .collect::<Vec<_>>()
2548 .join(" ")
2549 }
2550 }
2551 }
2552
2553 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
2554 enum KeyGesture {
2555 Named(NamedKey),
2556 Char(char),
2557 Ctrl(char),
2558 }
2559
2560 impl KeyGesture {
2561 fn parse_named(value: &str) -> Result<Option<Self>, KeyBindingError> {
2562 let gesture = match value {
2563 "left" => Self::Named(NamedKey::Left),
2564 "right" => Self::Named(NamedKey::Right),
2565 "up" => Self::Named(NamedKey::Up),
2566 "down" => Self::Named(NamedKey::Down),
2567 "enter" => Self::Named(NamedKey::Enter),
2568 "esc" | "escape" => Self::Named(NamedKey::Esc),
2569 value if value.starts_with("ctrl-") => {
2570 let key = value.trim_start_matches("ctrl-");
2571 let mut chars = key.chars();
2572 let Some(value) = chars.next() else {
2573 return Err(KeyBindingError::new("ctrl binding requires a key"));
2574 };
2575 if chars.next().is_some() || !value.is_ascii_alphabetic() {
2576 return Err(KeyBindingError::new(format!(
2577 "unsupported control binding 'ctrl-{key}'"
2578 )));
2579 }
2580 Self::Ctrl(value.to_ascii_lowercase())
2581 }
2582 _ => return Ok(None),
2583 };
2584
2585 Ok(Some(gesture))
2586 }
2587
2588 fn parse_printable(value: char) -> Result<Self, KeyBindingError> {
2589 if value.is_ascii_digit() {
2590 return Err(KeyBindingError::new(format!(
2591 "digit key '{value}' is reserved for day jumps"
2592 )));
2593 }
2594 if value.is_control() {
2595 return Err(KeyBindingError::new("control characters are unsupported"));
2596 }
2597
2598 Ok(Self::Char(value.to_ascii_lowercase()))
2599 }
2600
2601 fn from_key_event(key: KeyEvent) -> Option<Self> {
2602 if key.modifiers.intersects(KeyModifiers::ALT) {
2603 return None;
2604 }
2605
2606 match key.code {
2607 KeyCode::Left if key.modifiers.is_empty() => Some(Self::Named(NamedKey::Left)),
2608 KeyCode::Right if key.modifiers.is_empty() => Some(Self::Named(NamedKey::Right)),
2609 KeyCode::Up if key.modifiers.is_empty() => Some(Self::Named(NamedKey::Up)),
2610 KeyCode::Down if key.modifiers.is_empty() => Some(Self::Named(NamedKey::Down)),
2611 KeyCode::Enter if key.modifiers.is_empty() => Some(Self::Named(NamedKey::Enter)),
2612 KeyCode::Esc if key.modifiers.is_empty() => Some(Self::Named(NamedKey::Esc)),
2613 KeyCode::Char(value) if key.modifiers.contains(KeyModifiers::CONTROL) => {
2614 Some(Self::Ctrl(value.to_ascii_lowercase()))
2615 }
2616 KeyCode::Char(value)
2617 if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT =>
2618 {
2619 Some(Self::Char(value.to_ascii_lowercase()))
2620 }
2621 _ => None,
2622 }
2623 }
2624
2625 fn label(self) -> String {
2626 match self {
2627 Self::Named(named) => named.label().to_string(),
2628 Self::Char(value) => value.to_string(),
2629 Self::Ctrl(value) => format!("Ctrl-{}", value.to_ascii_uppercase()),
2630 }
2631 }
2632 }
2633
2634 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
2635 enum NamedKey {
2636 Left,
2637 Right,
2638 Up,
2639 Down,
2640 Enter,
2641 Esc,
2642 }
2643
2644 impl NamedKey {
2645 const fn label(self) -> &'static str {
2646 match self {
2647 Self::Left => "Left",
2648 Self::Right => "Right",
2649 Self::Up => "Up",
2650 Self::Down => "Down",
2651 Self::Enter => "Enter",
2652 Self::Esc => "Esc",
2653 }
2654 }
2655 }
2656
2657 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
2658 pub enum KeyCommand {
2659 MoveLeft,
2660 MoveRight,
2661 MoveUp,
2662 MoveDown,
2663 OpenDayOrEdit,
2664 CloseDay,
2665 CreateEvent,
2666 DeleteEvent,
2667 CopyEvent,
2668 Help,
2669 Quit,
2670 JumpMonday,
2671 JumpTuesday,
2672 JumpWednesday,
2673 JumpThursday,
2674 JumpFriday,
2675 JumpSaturday,
2676 JumpSunday,
2677 }
2678
2679 impl KeyCommand {
2680 const fn app_action(self) -> AppAction {
2681 match self {
2682 Self::MoveLeft => AppAction::MoveDays(-1),
2683 Self::MoveRight => AppAction::MoveDays(1),
2684 Self::MoveUp => AppAction::MoveDays(-7),
2685 Self::MoveDown => AppAction::MoveDays(7),
2686 Self::OpenDayOrEdit => AppAction::OpenDay,
2687 Self::CloseDay => AppAction::CloseDay,
2688 Self::CreateEvent => AppAction::OpenCreate,
2689 Self::DeleteEvent => AppAction::OpenDelete,
2690 Self::CopyEvent => AppAction::OpenCopy,
2691 Self::Help => AppAction::OpenHelp,
2692 Self::Quit => AppAction::Quit,
2693 Self::JumpMonday => AppAction::JumpToWeekday(Weekday::Monday),
2694 Self::JumpTuesday => AppAction::JumpToWeekday(Weekday::Tuesday),
2695 Self::JumpWednesday => AppAction::JumpToWeekday(Weekday::Wednesday),
2696 Self::JumpThursday => AppAction::JumpToWeekday(Weekday::Thursday),
2697 Self::JumpFriday => AppAction::JumpToWeekday(Weekday::Friday),
2698 Self::JumpSaturday => AppAction::JumpToWeekday(Weekday::Saturday),
2699 Self::JumpSunday => AppAction::JumpToWeekday(Weekday::Sunday),
2700 }
2701 }
2702
2703 const fn config_name(self) -> &'static str {
2704 match self {
2705 Self::MoveLeft => "move_left",
2706 Self::MoveRight => "move_right",
2707 Self::MoveUp => "move_up",
2708 Self::MoveDown => "move_down",
2709 Self::OpenDayOrEdit => "open_day_or_edit",
2710 Self::CloseDay => "close_day",
2711 Self::CreateEvent => "create_event",
2712 Self::DeleteEvent => "delete_event",
2713 Self::CopyEvent => "copy_event",
2714 Self::Help => "help",
2715 Self::Quit => "quit",
2716 Self::JumpMonday => "jump_monday",
2717 Self::JumpTuesday => "jump_tuesday",
2718 Self::JumpWednesday => "jump_wednesday",
2719 Self::JumpThursday => "jump_thursday",
2720 Self::JumpFriday => "jump_friday",
2721 Self::JumpSaturday => "jump_saturday",
2722 Self::JumpSunday => "jump_sunday",
2723 }
2724 }
2725
2726 const fn default_label(self) -> &'static str {
2727 match self {
2728 Self::MoveLeft => "Left",
2729 Self::MoveRight => "Right",
2730 Self::MoveUp => "Up",
2731 Self::MoveDown => "Down",
2732 Self::OpenDayOrEdit => "Enter",
2733 Self::CloseDay => "Esc",
2734 Self::CreateEvent => "+",
2735 Self::DeleteEvent => "d",
2736 Self::CopyEvent => "c",
2737 Self::Help => "?",
2738 Self::Quit => "q / Ctrl-C",
2739 Self::JumpMonday => "m",
2740 Self::JumpTuesday => "tu",
2741 Self::JumpWednesday => "w",
2742 Self::JumpThursday => "th",
2743 Self::JumpFriday => "f",
2744 Self::JumpSaturday => "sa",
2745 Self::JumpSunday => "su",
2746 }
2747 }
2748 }
2749
2750 #[derive(Debug, Clone, PartialEq, Eq)]
2751 pub struct KeyBindingError {
2752 reason: String,
2753 }
2754
2755 impl KeyBindingError {
2756 fn new(reason: impl Into<String>) -> Self {
2757 Self {
2758 reason: reason.into(),
2759 }
2760 }
2761 }
2762
2763 impl fmt::Display for KeyBindingError {
2764 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2765 write!(f, "{}", self.reason)
2766 }
2767 }
2768
2769 impl std::error::Error for KeyBindingError {}
2770
2771 fn digit_from_key(key: KeyEvent) -> Option<char> {
2772 match key.code {
2773 KeyCode::Char(value)
2774 if value.is_ascii_digit()
2775 && !key
2776 .modifiers
2777 .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
2778 {
2779 Some(value)
2780 }
2781 _ => None,
2782 }
2783 }
2784
2785 #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
2786 pub struct MouseInput {
2787 last_left_click: Option<MouseClick>,
2788 }
2789
2790 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
2791 struct MouseClick {
2792 date: CalendarDate,
2793 at: Instant,
2794 }
2795
2796 impl MouseInput {
2797 pub fn translate(
2798 &mut self,
2799 mouse: MouseEvent,
2800 target_date: Option<CalendarDate>,
2801 selected_date: CalendarDate,
2802 ) -> AppAction {
2803 self.translate_at(mouse, target_date, selected_date, Instant::now())
2804 }
2805
2806 fn translate_at(
2807 &mut self,
2808 mouse: MouseEvent,
2809 target_date: Option<CalendarDate>,
2810 selected_date: CalendarDate,
2811 now: Instant,
2812 ) -> AppAction {
2813 match mouse.kind {
2814 MouseEventKind::Down(MouseButton::Left) => {
2815 let Some(target_date) = target_date else {
2816 self.clear();
2817 return AppAction::Noop;
2818 };
2819
2820 let is_double_click = self
2821 .last_left_click
2822 .map(|click| {
2823 click.date == target_date
2824 && now.saturating_duration_since(click.at) <= MOUSE_DOUBLE_CLICK_TIMEOUT
2825 })
2826 .unwrap_or(false);
2827
2828 self.last_left_click = Some(MouseClick {
2829 date: target_date,
2830 at: now,
2831 });
2832
2833 if is_double_click && selected_date == target_date {
2834 self.clear();
2835 AppAction::OpenDay
2836 } else {
2837 AppAction::SelectDate(target_date)
2838 }
2839 }
2840 MouseEventKind::Up(_) => AppAction::Noop,
2841 _ => {
2842 self.clear();
2843 AppAction::Noop
2844 }
2845 }
2846 }
2847
2848 pub fn clear(&mut self) {
2849 self.last_left_click = None;
2850 }
2851 }
2852
2853 #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
2854 enum PendingKey {
2855 #[default]
2856 None,
2857 Digit(u8),
2858 Sequence(KeyGesture),
2859 }
2860
2861 fn ctrl_c(value: char, modifiers: KeyModifiers) -> bool {
2862 value.eq_ignore_ascii_case(&'c') && modifiers.contains(KeyModifiers::CONTROL)
2863 }
2864
2865 fn ctrl_s(key: KeyEvent) -> bool {
2866 matches!(key.code, KeyCode::Char(value) if value.eq_ignore_ascii_case(&'s'))
2867 && key.modifiers.contains(KeyModifiers::CONTROL)
2868 }
2869
2870 #[cfg(test)]
2871 mod tests {
2872 use super::*;
2873 use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind};
2874 use time::{Month, Time};
2875
2876 use crate::agenda::{
2877 DateRange, Holiday, InMemoryAgendaSource, RecurrenceOrdinal, SourceMetadata,
2878 };
2879
2880 fn date(year: i32, month: Month, day: u8) -> CalendarDate {
2881 CalendarDate::from_ymd(year, month, day).expect("valid test date")
2882 }
2883
2884 fn at(date: CalendarDate, hour: u8, minute: u8) -> EventDateTime {
2885 EventDateTime::new(
2886 date,
2887 Time::from_hms(hour, minute, 0).expect("valid test time"),
2888 )
2889 }
2890
2891 fn local_timed_event(id: &str, title: &str, start: EventDateTime, end: EventDateTime) -> Event {
2892 Event::timed(id, title, start, end, SourceMetadata::local())
2893 .expect("valid local timed event")
2894 }
2895
2896 fn local_all_day_event(id: &str, title: &str, date: CalendarDate) -> Event {
2897 Event::all_day(id, title, date, SourceMetadata::local())
2898 }
2899
2900 fn fixture_timed_event(
2901 id: &str,
2902 title: &str,
2903 start: EventDateTime,
2904 end: EventDateTime,
2905 ) -> Event {
2906 Event::timed(id, title, start, end, SourceMetadata::fixture())
2907 .expect("valid fixture timed event")
2908 }
2909
2910 fn key(code: KeyCode) -> KeyEvent {
2911 KeyEvent::new(code, KeyModifiers::empty())
2912 }
2913
2914 fn char_key(value: char) -> KeyEvent {
2915 key(KeyCode::Char(value))
2916 }
2917
2918 fn ctrl_char_key(value: char) -> KeyEvent {
2919 KeyEvent::new(KeyCode::Char(value), KeyModifiers::CONTROL)
2920 }
2921
2922 fn mouse_event(kind: MouseEventKind, column: u16, row: u16) -> MouseEvent {
2923 MouseEvent {
2924 kind,
2925 column,
2926 row,
2927 modifiers: KeyModifiers::empty(),
2928 }
2929 }
2930
2931 fn mouse_down(column: u16, row: u16) -> MouseEvent {
2932 mouse_event(MouseEventKind::Down(MouseButton::Left), column, row)
2933 }
2934
2935 fn apply_keys(
2936 app: &mut AppState,
2937 input: &mut KeyboardInput,
2938 keys: impl IntoIterator<Item = KeyEvent>,
2939 ) {
2940 for key in keys {
2941 let action = input.translate(key);
2942 app.apply(action);
2943 }
2944 }
2945
2946 fn apply_keys_with_source(
2947 app: &mut AppState,
2948 input: &mut KeyboardInput,
2949 source: &dyn AgendaSource,
2950 keys: impl IntoIterator<Item = KeyEvent>,
2951 ) {
2952 for key in keys {
2953 let action = input.translate(key);
2954 app.apply_with_agenda_source(action, source);
2955 }
2956 }
2957
2958 struct WriteTargetSource {
2959 targets: Vec<EventWriteTarget>,
2960 default: EventWriteTargetId,
2961 }
2962
2963 impl AgendaSource for WriteTargetSource {
2964 fn events_intersecting(&self, _range: DateRange) -> Vec<Event> {
2965 Vec::new()
2966 }
2967
2968 fn holidays_in(&self, _range: DateRange) -> Vec<Holiday> {
2969 Vec::new()
2970 }
2971
2972 fn event_write_targets(&self) -> Vec<EventWriteTarget> {
2973 self.targets.clone()
2974 }
2975
2976 fn default_event_write_target(&self) -> EventWriteTargetId {
2977 self.default.clone()
2978 }
2979 }
2980
2981 fn form_value(form: &CreateEventForm, label: &str) -> String {
2982 form.rows()
2983 .into_iter()
2984 .find(|row| row.label == label)
2985 .map(|row| row.value)
2986 .expect("form row exists")
2987 }
2988
2989 #[test]
2990 fn configured_keybindings_replace_default_normal_mode_commands() {
2991 let bindings = KeyBindings::with_overrides(KeyBindingOverrides {
2992 create_event: Some(vec!["n".to_string()]),
2993 help: Some(vec!["h".to_string()]),
2994 quit: Some(vec!["x".to_string()]),
2995 ..KeyBindingOverrides::default()
2996 })
2997 .expect("custom bindings are valid");
2998 let mut input = KeyboardInput::new(bindings);
2999
3000 assert_eq!(input.translate(char_key('+')), AppAction::Noop);
3001 assert_eq!(input.translate(char_key('n')), AppAction::OpenCreate);
3002 assert_eq!(input.translate(char_key('?')), AppAction::Noop);
3003 assert_eq!(input.translate(char_key('h')), AppAction::OpenHelp);
3004 assert_eq!(input.translate(char_key('q')), AppAction::Noop);
3005 assert_eq!(input.translate(char_key('x')), AppAction::Quit);
3006 }
3007
3008 #[test]
3009 fn configured_two_key_weekday_sequence_waits_for_second_key() {
3010 let bindings = KeyBindings::with_overrides(KeyBindingOverrides {
3011 jump_tuesday: Some(vec!["xy".to_string()]),
3012 ..KeyBindingOverrides::default()
3013 })
3014 .expect("custom bindings are valid");
3015 let mut input = KeyboardInput::new(bindings);
3016
3017 assert_eq!(input.translate(char_key('x')), AppAction::Noop);
3018 assert_eq!(
3019 input.translate(char_key('y')),
3020 AppAction::JumpToWeekday(Weekday::Tuesday)
3021 );
3022 }
3023
3024 #[test]
3025 fn keybinding_validation_rejects_digits_duplicates_and_prefixes() {
3026 let digit_err = KeyBindings::with_overrides(KeyBindingOverrides {
3027 create_event: Some(vec!["1".to_string()]),
3028 ..KeyBindingOverrides::default()
3029 })
3030 .expect_err("digits are reserved");
3031 assert!(digit_err.to_string().contains("reserved for day jumps"));
3032
3033 let duplicate_err = KeyBindings::with_overrides(KeyBindingOverrides {
3034 create_event: Some(vec!["x".to_string()]),
3035 quit: Some(vec!["x".to_string()]),
3036 ..KeyBindingOverrides::default()
3037 })
3038 .expect_err("duplicate binding fails");
3039 assert!(duplicate_err.to_string().contains("bound to both"));
3040
3041 let prefix_err = KeyBindings::with_overrides(KeyBindingOverrides {
3042 create_event: Some(vec!["t".to_string()]),
3043 ..KeyBindingOverrides::default()
3044 })
3045 .expect_err("prefix binding fails");
3046 assert!(prefix_err.to_string().contains("conflicts with longer key"));
3047 }
3048
3049 #[test]
3050 fn arrow_keys_move_within_month() {
3051 let mut app = AppState::new(date(2026, Month::April, 23));
3052 let mut input = KeyboardInput::default();
3053
3054 apply_keys(
3055 &mut app,
3056 &mut input,
3057 [
3058 key(KeyCode::Left),
3059 key(KeyCode::Right),
3060 key(KeyCode::Up),
3061 key(KeyCode::Down),
3062 ],
3063 );
3064
3065 assert_eq!(app.selected_date(), date(2026, Month::April, 23));
3066 }
3067
3068 #[test]
3069 fn arrow_keys_cross_month_and_year_boundaries() {
3070 let mut app = AppState::new(date(2026, Month::December, 31));
3071 let mut input = KeyboardInput::default();
3072
3073 apply_keys(&mut app, &mut input, [key(KeyCode::Right)]);
3074 assert_eq!(app.selected_date(), date(2027, Month::January, 1));
3075
3076 apply_keys(&mut app, &mut input, [key(KeyCode::Left)]);
3077 assert_eq!(app.selected_date(), date(2026, Month::December, 31));
3078
3079 app = AppState::new(date(2026, Month::January, 1));
3080 apply_keys(&mut app, &mut input, [key(KeyCode::Left)]);
3081 assert_eq!(app.selected_date(), date(2025, Month::December, 31));
3082 }
3083
3084 #[test]
3085 fn numeric_jump_selects_valid_days() {
3086 let mut app = AppState::new(date(2026, Month::April, 23));
3087 let mut input = KeyboardInput::default();
3088
3089 let action = input.translate(char_key('1'));
3090 app.apply(action);
3091 assert_eq!(app.selected_date(), date(2026, Month::April, 1));
3092 assert!(input.is_waiting_for_digit());
3093
3094 let action = input.translate(char_key('8'));
3095 app.apply(action);
3096 assert_eq!(app.selected_date(), date(2026, Month::April, 18));
3097 assert!(!input.is_waiting_for_digit());
3098
3099 apply_keys(&mut app, &mut input, [char_key('0'), char_key('4')]);
3100 assert_eq!(app.selected_date(), date(2026, Month::April, 4));
3101
3102 apply_keys(&mut app, &mut input, [char_key('9')]);
3103 assert_eq!(app.selected_date(), date(2026, Month::April, 9));
3104 }
3105
3106 #[test]
3107 fn single_digit_jumps_cover_visible_day_digits() {
3108 for day in 1..=9 {
3109 let mut app = AppState::new(date(2026, Month::April, 23));
3110 let mut input = KeyboardInput::default();
3111 let digit = char::from_digit(day.into(), 10).expect("single digit");
3112
3113 let action = input.translate(char_key(digit));
3114 app.apply(action);
3115
3116 assert_eq!(app.selected_date(), date(2026, Month::April, day));
3117 }
3118 }
3119
3120 #[test]
3121 fn zero_prefix_can_still_jump_to_single_digit_days() {
3122 let mut app = AppState::new(date(2026, Month::April, 23));
3123 let mut input = KeyboardInput::default();
3124
3125 let action = input.translate(char_key('0'));
3126 app.apply(action);
3127 assert_eq!(app.selected_date(), date(2026, Month::April, 23));
3128 assert!(input.is_waiting_for_digit());
3129
3130 let action = input.translate(char_key('7'));
3131 app.apply(action);
3132 assert_eq!(app.selected_date(), date(2026, Month::April, 7));
3133 }
3134
3135 #[test]
3136 fn numeric_jump_timeout_keeps_single_digit_selection() {
3137 let mut app = AppState::new(date(2026, Month::April, 23));
3138 let mut input = KeyboardInput::default();
3139
3140 let action = input.translate(char_key('1'));
3141 app.apply(action);
3142 assert_eq!(app.selected_date(), date(2026, Month::April, 1));
3143
3144 input.clear_digit();
3145
3146 let action = input.translate(char_key('6'));
3147 app.apply(action);
3148 assert_eq!(app.selected_date(), date(2026, Month::April, 6));
3149 }
3150
3151 #[test]
3152 fn invalid_numeric_jump_leaves_selection_unchanged_and_clears_buffer() {
3153 let mut app = AppState::new(date(2026, Month::February, 10));
3154 let mut input = KeyboardInput::default();
3155
3156 apply_keys(&mut app, &mut input, [char_key('3'), char_key('0')]);
3157 assert_eq!(app.selected_date(), date(2026, Month::February, 3));
3158
3159 apply_keys(&mut app, &mut input, [char_key('1'), char_key('5')]);
3160 assert_eq!(app.selected_date(), date(2026, Month::February, 15));
3161 }
3162
3163 #[test]
3164 fn weekday_jumps_target_selected_week_from_each_position() {
3165 let jump_cases = [
3166 ([char_key('S'), char_key('u')], 19),
3167 ([char_key('M'), char_key(' ')], 20),
3168 ([char_key('T'), char_key('u')], 21),
3169 ([char_key('W'), char_key(' ')], 22),
3170 ([char_key('T'), char_key('h')], 23),
3171 ([char_key('F'), char_key(' ')], 24),
3172 ([char_key('S'), char_key('a')], 25),
3173 ];
3174
3175 for start_day in 19..=25 {
3176 for (keys, expected_day) in jump_cases {
3177 let mut app = AppState::new(date(2026, Month::April, start_day));
3178 let mut input = KeyboardInput::default();
3179
3180 apply_keys(&mut app, &mut input, keys);
3181
3182 assert_eq!(
3183 app.selected_date(),
3184 date(2026, Month::April, expected_day),
3185 "start day {start_day} should jump to weekday date {expected_day}"
3186 );
3187 }
3188 }
3189 }
3190
3191 #[test]
3192 fn enter_opens_day_view_and_escape_returns_to_month() {
3193 let mut app = AppState::new(date(2026, Month::April, 23));
3194 let mut input = KeyboardInput::default();
3195
3196 apply_keys(&mut app, &mut input, [key(KeyCode::Enter)]);
3197 assert_eq!(app.view_mode(), ViewMode::Day);
3198 assert_eq!(app.selected_date(), date(2026, Month::April, 23));
3199
3200 apply_keys(&mut app, &mut input, [key(KeyCode::Esc)]);
3201 assert_eq!(app.view_mode(), ViewMode::Month);
3202 assert_eq!(app.selected_date(), date(2026, Month::April, 23));
3203 }
3204
3205 #[test]
3206 fn day_view_left_and_right_move_between_days() {
3207 let mut app = AppState::new(date(2026, Month::April, 23));
3208 let mut input = KeyboardInput::default();
3209
3210 apply_keys(&mut app, &mut input, [key(KeyCode::Enter)]);
3211 apply_keys(&mut app, &mut input, [key(KeyCode::Left)]);
3212
3213 assert_eq!(app.view_mode(), ViewMode::Day);
3214 assert_eq!(app.selected_date(), date(2026, Month::April, 22));
3215
3216 apply_keys(
3217 &mut app,
3218 &mut input,
3219 [key(KeyCode::Right), key(KeyCode::Right)],
3220 );
3221
3222 assert_eq!(app.view_mode(), ViewMode::Day);
3223 assert_eq!(app.selected_date(), date(2026, Month::April, 24));
3224 }
3225
3226 #[test]
3227 fn day_view_up_and_down_do_not_change_days() {
3228 let mut app = AppState::new(date(2026, Month::April, 23));
3229 let mut input = KeyboardInput::default();
3230
3231 apply_keys(&mut app, &mut input, [key(KeyCode::Enter)]);
3232 apply_keys(&mut app, &mut input, [key(KeyCode::Up), key(KeyCode::Down)]);
3233
3234 assert_eq!(app.view_mode(), ViewMode::Day);
3235 assert_eq!(app.selected_date(), date(2026, Month::April, 23));
3236 }
3237
3238 #[test]
3239 fn day_view_selects_first_local_event_and_skips_non_local_items() {
3240 let day = date(2026, Month::April, 23);
3241 let source = InMemoryAgendaSource::with_events_and_holidays(
3242 vec![
3243 Event::all_day("fixture-all", "A Fixture", day, SourceMetadata::fixture()),
3244 local_all_day_event("local-all", "Release", day),
3245 fixture_timed_event("fixture-time", "Fixture", at(day, 8, 0), at(day, 9, 0)),
3246 local_timed_event("local-time", "Standup", at(day, 9, 0), at(day, 9, 30)),
3247 ],
3248 vec![Holiday::new(
3249 "holiday",
3250 "Holiday",
3251 day,
3252 SourceMetadata::fixture(),
3253 )],
3254 );
3255 let mut app = AppState::new(day);
3256 let mut input = KeyboardInput::default();
3257
3258 apply_keys_with_source(&mut app, &mut input, &source, [key(KeyCode::Enter)]);
3259
3260 assert_eq!(app.view_mode(), ViewMode::Day);
3261 assert_eq!(app.selected_day_event_id(), Some("local-all"));
3262 }
3263
3264 #[test]
3265 fn day_view_up_and_down_cycle_local_event_selection() {
3266 let day = date(2026, Month::April, 23);
3267 let source = InMemoryAgendaSource::with_events_and_holidays(
3268 vec![
3269 local_all_day_event("local-all", "Release", day),
3270 local_timed_event("local-time", "Standup", at(day, 9, 0), at(day, 9, 30)),
3271 ],
3272 Vec::new(),
3273 );
3274 let mut app = AppState::new(day);
3275 let mut input = KeyboardInput::default();
3276
3277 apply_keys_with_source(
3278 &mut app,
3279 &mut input,
3280 &source,
3281 [key(KeyCode::Enter), key(KeyCode::Down)],
3282 );
3283
3284 assert_eq!(app.selected_date(), day);
3285 assert_eq!(app.selected_day_event_id(), Some("local-time"));
3286
3287 apply_keys_with_source(&mut app, &mut input, &source, [key(KeyCode::Down)]);
3288 assert_eq!(app.selected_day_event_id(), Some("local-all"));
3289
3290 apply_keys_with_source(&mut app, &mut input, &source, [key(KeyCode::Up)]);
3291 assert_eq!(app.selected_day_event_id(), Some("local-time"));
3292 }
3293
3294 #[test]
3295 fn day_view_enter_opens_edit_for_selected_local_event() {
3296 let day = date(2026, Month::April, 23);
3297 let source = InMemoryAgendaSource::with_events_and_holidays(
3298 vec![local_timed_event(
3299 "local-time",
3300 "Standup",
3301 at(day, 9, 0),
3302 at(day, 9, 30),
3303 )],
3304 Vec::new(),
3305 );
3306 let mut app = AppState::new(day);
3307 let mut input = KeyboardInput::default();
3308
3309 apply_keys_with_source(
3310 &mut app,
3311 &mut input,
3312 &source,
3313 [key(KeyCode::Enter), key(KeyCode::Enter)],
3314 );
3315
3316 let form = app.create_form().expect("edit form opens");
3317 assert_eq!(
3318 form.mode(),
3319 &EventFormMode::Edit {
3320 event_id: "local-time".to_string()
3321 }
3322 );
3323 assert_eq!(form.rows()[0].value, "Standup");
3324 }
3325
3326 #[test]
3327 fn day_view_enter_on_recurring_event_opens_edit_choice() {
3328 let day = date(2026, Month::April, 23);
3329 let event = local_timed_event("series", "Standup", at(day, 9, 0), at(day, 9, 30))
3330 .with_recurrence(RecurrenceRule {
3331 frequency: RecurrenceFrequency::Daily,
3332 interval: 1,
3333 end: RecurrenceEnd::Count(2),
3334 weekdays: Vec::new(),
3335 monthly: None,
3336 yearly: None,
3337 });
3338 let source = InMemoryAgendaSource::with_events_and_holidays(vec![event], Vec::new());
3339 let mut app = AppState::new(day);
3340 let mut input = KeyboardInput::default();
3341
3342 apply_keys_with_source(
3343 &mut app,
3344 &mut input,
3345 &source,
3346 [key(KeyCode::Enter), key(KeyCode::Enter)],
3347 );
3348
3349 let choice = app.recurrence_choice().expect("choice modal opens");
3350 assert_eq!(choice.rows()[0].label, "Edit this occurrence");
3351 assert_eq!(app.selected_day_event_id(), Some("series#2026-04-23T09:00"));
3352 }
3353
3354 #[test]
3355 fn recurring_edit_choice_can_open_occurrence_or_series_edit() {
3356 let day = date(2026, Month::April, 23);
3357 let event = local_timed_event("series", "Standup", at(day, 9, 0), at(day, 9, 30))
3358 .with_recurrence(RecurrenceRule {
3359 frequency: RecurrenceFrequency::Daily,
3360 interval: 1,
3361 end: RecurrenceEnd::Count(2),
3362 weekdays: Vec::new(),
3363 monthly: None,
3364 yearly: None,
3365 });
3366 let source = InMemoryAgendaSource::with_events_and_holidays(vec![event], Vec::new());
3367
3368 let mut occurrence_app = AppState::new(day);
3369 occurrence_app.apply_with_agenda_source(AppAction::OpenDay, &source);
3370 occurrence_app.apply_with_agenda_source(AppAction::OpenDay, &source);
3371 assert_eq!(
3372 occurrence_app.handle_recurrence_choice_key(key(KeyCode::Enter), &source),
3373 RecurrenceChoiceInputResult::Continue
3374 );
3375 assert_eq!(
3376 occurrence_app.create_form().expect("form opens").mode(),
3377 &EventFormMode::EditOccurrence {
3378 series_id: "series".to_string(),
3379 anchor: OccurrenceAnchor::Timed {
3380 start: at(day, 9, 0)
3381 },
3382 }
3383 );
3384
3385 let mut series_app = AppState::new(day);
3386 series_app.apply_with_agenda_source(AppAction::OpenDay, &source);
3387 series_app.apply_with_agenda_source(AppAction::OpenDay, &source);
3388 assert_eq!(
3389 series_app.handle_recurrence_choice_key(key(KeyCode::Down), &source),
3390 RecurrenceChoiceInputResult::Continue
3391 );
3392 assert_eq!(
3393 series_app.handle_recurrence_choice_key(key(KeyCode::Enter), &source),
3394 RecurrenceChoiceInputResult::Continue
3395 );
3396 assert_eq!(
3397 series_app.create_form().expect("series form opens").mode(),
3398 &EventFormMode::Edit {
3399 event_id: "series".to_string()
3400 }
3401 );
3402 }
3403
3404 #[test]
3405 fn day_view_d_opens_delete_choice_for_selected_local_event() {
3406 let day = date(2026, Month::April, 23);
3407 let source = InMemoryAgendaSource::with_events_and_holidays(
3408 vec![local_timed_event(
3409 "local-time",
3410 "Standup",
3411 at(day, 9, 0),
3412 at(day, 9, 30),
3413 )],
3414 Vec::new(),
3415 );
3416 let mut app = AppState::new(day);
3417 let mut input = KeyboardInput::default();
3418
3419 apply_keys_with_source(
3420 &mut app,
3421 &mut input,
3422 &source,
3423 [key(KeyCode::Enter), char_key('d')],
3424 );
3425
3426 let choice = app.delete_choice().expect("delete modal opens");
3427 assert_eq!(choice.rows()[0].label, "Delete event");
3428 assert_eq!(
3429 app.handle_delete_choice_key(key(KeyCode::Enter)),
3430 EventDeleteInputResult::Submit(EventDeleteSubmission::Event {
3431 event_id: "local-time".to_string()
3432 })
3433 );
3434 }
3435
3436 #[test]
3437 fn day_view_d_opens_recurring_delete_choices() {
3438 let day = date(2026, Month::April, 23);
3439 let event = local_timed_event("series", "Standup", at(day, 9, 0), at(day, 9, 30))
3440 .with_recurrence(RecurrenceRule {
3441 frequency: RecurrenceFrequency::Daily,
3442 interval: 1,
3443 end: RecurrenceEnd::Count(2),
3444 weekdays: Vec::new(),
3445 monthly: None,
3446 yearly: None,
3447 });
3448 let source = InMemoryAgendaSource::with_events_and_holidays(vec![event], Vec::new());
3449 let mut app = AppState::new(day);
3450 let mut input = KeyboardInput::default();
3451
3452 apply_keys_with_source(
3453 &mut app,
3454 &mut input,
3455 &source,
3456 [key(KeyCode::Enter), char_key('d')],
3457 );
3458
3459 let choice = app.delete_choice().expect("delete modal opens");
3460 let rows = choice.rows();
3461 assert_eq!(rows[0].label, "Delete this occurrence");
3462 assert_eq!(rows[1].label, "Delete series");
3463 assert_eq!(
3464 app.handle_delete_choice_key(key(KeyCode::Enter)),
3465 EventDeleteInputResult::Submit(EventDeleteSubmission::Occurrence {
3466 series_id: "series".to_string(),
3467 anchor: OccurrenceAnchor::Timed {
3468 start: at(day, 9, 0)
3469 },
3470 })
3471 );
3472 }
3473
3474 #[test]
3475 fn day_view_c_opens_copy_choice_for_selected_local_event() {
3476 let day = date(2026, Month::April, 23);
3477 let source = InMemoryAgendaSource::with_events_and_holidays(
3478 vec![local_timed_event(
3479 "local-time",
3480 "Standup",
3481 at(day, 9, 0),
3482 at(day, 9, 30),
3483 )],
3484 Vec::new(),
3485 );
3486 let mut app = AppState::new(day);
3487 let mut input = KeyboardInput::default();
3488
3489 apply_keys_with_source(
3490 &mut app,
3491 &mut input,
3492 &source,
3493 [key(KeyCode::Enter), char_key('c')],
3494 );
3495
3496 let choice = app.copy_choice().expect("copy modal opens");
3497 assert_eq!(choice.rows()[0].label, "Copy event");
3498 assert_eq!(
3499 app.handle_copy_choice_key(key(KeyCode::Enter)),
3500 EventCopyInputResult::Submit(EventCopySubmission::Event {
3501 event_id: "local-time".to_string()
3502 })
3503 );
3504 }
3505
3506 #[test]
3507 fn day_view_c_opens_recurring_copy_choices() {
3508 let day = date(2026, Month::April, 23);
3509 let event = local_timed_event("series", "Standup", at(day, 9, 0), at(day, 9, 30))
3510 .with_recurrence(RecurrenceRule {
3511 frequency: RecurrenceFrequency::Daily,
3512 interval: 1,
3513 end: RecurrenceEnd::Count(2),
3514 weekdays: Vec::new(),
3515 monthly: None,
3516 yearly: None,
3517 });
3518 let source = InMemoryAgendaSource::with_events_and_holidays(vec![event], Vec::new());
3519 let mut app = AppState::new(day);
3520 let mut input = KeyboardInput::default();
3521
3522 apply_keys_with_source(
3523 &mut app,
3524 &mut input,
3525 &source,
3526 [key(KeyCode::Enter), char_key('c')],
3527 );
3528
3529 let choice = app.copy_choice().expect("copy modal opens");
3530 let rows = choice.rows();
3531 assert_eq!(rows[0].label, "Copy this occurrence");
3532 assert_eq!(rows[1].label, "Copy series");
3533 assert_eq!(
3534 app.handle_copy_choice_key(key(KeyCode::Enter)),
3535 EventCopyInputResult::Submit(EventCopySubmission::Occurrence {
3536 series_id: "series".to_string(),
3537 anchor: OccurrenceAnchor::Timed {
3538 start: at(day, 9, 0)
3539 },
3540 })
3541 );
3542 assert_eq!(
3543 app.handle_copy_choice_key(key(KeyCode::Down)),
3544 EventCopyInputResult::Continue
3545 );
3546 assert_eq!(
3547 app.handle_copy_choice_key(key(KeyCode::Enter)),
3548 EventCopyInputResult::Submit(EventCopySubmission::Series {
3549 series_id: "series".to_string()
3550 })
3551 );
3552 }
3553
3554 #[test]
3555 fn delete_choice_up_and_down_do_not_change_day_event_selection() {
3556 let day = date(2026, Month::April, 23);
3557 let source = InMemoryAgendaSource::with_events_and_holidays(
3558 vec![local_timed_event(
3559 "local-time",
3560 "Standup",
3561 at(day, 9, 0),
3562 at(day, 9, 30),
3563 )],
3564 Vec::new(),
3565 );
3566 let mut app = AppState::new(day);
3567 let mut input = KeyboardInput::default();
3568 apply_keys_with_source(
3569 &mut app,
3570 &mut input,
3571 &source,
3572 [key(KeyCode::Enter), char_key('d')],
3573 );
3574
3575 assert_eq!(
3576 app.handle_delete_choice_key(key(KeyCode::Down)),
3577 EventDeleteInputResult::Continue
3578 );
3579 assert_eq!(
3580 app.handle_delete_choice_key(key(KeyCode::Up)),
3581 EventDeleteInputResult::Continue
3582 );
3583
3584 assert_eq!(app.selected_day_event_id(), Some("local-time"));
3585 assert!(app.delete_choice().expect("choice stays open").rows()[0].selected);
3586 }
3587
3588 #[test]
3589 fn day_view_reconciles_selection_when_event_leaves_day() {
3590 let day = date(2026, Month::April, 23);
3591 let next_day = date(2026, Month::April, 24);
3592 let source = InMemoryAgendaSource::with_events_and_holidays(
3593 vec![
3594 local_timed_event("move", "Move", at(day, 8, 0), at(day, 9, 0)),
3595 local_timed_event("stay", "Stay", at(day, 10, 0), at(day, 11, 0)),
3596 ],
3597 Vec::new(),
3598 );
3599 let moved_source = InMemoryAgendaSource::with_events_and_holidays(
3600 vec![
3601 local_timed_event("move", "Move", at(next_day, 8, 0), at(next_day, 9, 0)),
3602 local_timed_event("stay", "Stay", at(day, 10, 0), at(day, 11, 0)),
3603 ],
3604 Vec::new(),
3605 );
3606 let mut app = AppState::new(day);
3607 app.apply_with_agenda_source(AppAction::OpenDay, &source);
3608
3609 assert_eq!(app.selected_day_event_id(), Some("move"));
3610
3611 app.reconcile_day_event_selection(&moved_source);
3612
3613 assert_eq!(app.selected_date(), day);
3614 assert_eq!(app.selected_day_event_id(), Some("stay"));
3615 }
3616
3617 #[test]
3618 fn edit_form_up_and_down_keep_day_event_selection() {
3619 let day = date(2026, Month::April, 23);
3620 let source = InMemoryAgendaSource::with_events_and_holidays(
3621 vec![local_timed_event(
3622 "local-time",
3623 "Standup",
3624 at(day, 9, 0),
3625 at(day, 9, 30),
3626 )],
3627 Vec::new(),
3628 );
3629 let mut app = AppState::new(day);
3630 let mut input = KeyboardInput::default();
3631 apply_keys_with_source(
3632 &mut app,
3633 &mut input,
3634 &source,
3635 [key(KeyCode::Enter), key(KeyCode::Enter)],
3636 );
3637
3638 assert_eq!(
3639 app.handle_create_key(key(KeyCode::Down)),
3640 CreateEventInputResult::Continue
3641 );
3642 assert_eq!(app.selected_day_event_id(), Some("local-time"));
3643 assert!(app.create_form().expect("form stays open").rows()[1].focused);
3644 }
3645
3646 #[test]
3647 fn quit_action_marks_app_done() {
3648 let mut app = AppState::new(date(2026, Month::April, 23));
3649 let mut input = KeyboardInput::default();
3650
3651 apply_keys(&mut app, &mut input, [char_key('q')]);
3652
3653 assert!(app.should_quit());
3654 }
3655
3656 #[test]
3657 fn plus_opens_create_form_with_contextual_dates() {
3658 let day = date(2026, Month::April, 23);
3659 let mut app = AppState::new(day);
3660 let mut input = KeyboardInput::default();
3661
3662 app.apply(input.translate(char_key('+')));
3663
3664 assert_eq!(
3665 app.create_form().expect("form opens").context(),
3666 CreateEventContext::EditableDate
3667 );
3668
3669 app.close_create_form();
3670 app.apply(AppAction::OpenDay);
3671 app.apply(input.translate(char_key('+')));
3672
3673 assert_eq!(
3674 app.create_form().expect("form opens").context(),
3675 CreateEventContext::FixedDate
3676 );
3677 }
3678
3679 #[test]
3680 fn plus_opens_create_form_with_agenda_source_default_calendar_target() {
3681 let day = date(2026, Month::April, 23);
3682 let target = EventWriteTarget::microsoft("work", "cal", "Microsoft work: Calendar");
3683 let source = WriteTargetSource {
3684 targets: vec![EventWriteTarget::local(), target.clone()],
3685 default: target.id.clone(),
3686 };
3687 let mut app = AppState::new(day);
3688 let mut input = KeyboardInput::default();
3689
3690 app.apply_with_agenda_source(input.translate(char_key('+')), &source);
3691
3692 let form = app.create_form().expect("form opens");
3693 assert_eq!(form.context(), CreateEventContext::EditableDate);
3694 assert_eq!(form_value(form, "Calendar"), "Microsoft work: Calendar");
3695 }
3696
3697 #[test]
3698 fn question_mark_opens_help_and_esc_closes_it() {
3699 let day = date(2026, Month::April, 23);
3700 let mut app = AppState::new(day);
3701 let mut input = KeyboardInput::default();
3702
3703 app.apply(input.translate(char_key('?')));
3704
3705 assert!(app.is_showing_help());
3706 assert_eq!(
3707 app.handle_help_key(key(KeyCode::Esc)),
3708 HelpInputResult::Close
3709 );
3710 assert!(!app.is_showing_help());
3711 assert_eq!(app.selected_date(), day);
3712 }
3713
3714 #[test]
3715 fn help_modal_blocks_calendar_navigation_until_closed() {
3716 let day = date(2026, Month::April, 23);
3717 let mut app = AppState::new(day);
3718 let mut input = KeyboardInput::default();
3719
3720 app.apply(input.translate(char_key('?')));
3721 app.apply(input.translate(key(KeyCode::Right)));
3722
3723 assert!(app.is_showing_help());
3724 assert_eq!(app.selected_date(), day);
3725
3726 assert_eq!(app.handle_help_key(char_key('?')), HelpInputResult::Close);
3727 app.apply(input.translate(key(KeyCode::Right)));
3728 assert_eq!(app.selected_date(), date(2026, Month::April, 24));
3729 }
3730
3731 #[test]
3732 fn create_form_text_input_does_not_move_selection() {
3733 let day = date(2026, Month::April, 23);
3734 let mut app = AppState::new(day);
3735 app.apply(AppAction::OpenCreate);
3736
3737 assert_eq!(
3738 app.handle_create_key(char_key('1')),
3739 CreateEventInputResult::Continue
3740 );
3741
3742 assert_eq!(app.selected_date(), day);
3743 assert_eq!(
3744 app.create_form().expect("form stays open").rows()[0].value,
3745 "1"
3746 );
3747 }
3748
3749 #[test]
3750 fn create_form_up_and_down_move_between_fields() {
3751 let day = date(2026, Month::April, 23);
3752 let mut app = AppState::new(day);
3753 app.apply(AppAction::OpenCreate);
3754
3755 assert!(app.create_form().expect("form opens").rows()[0].focused);
3756
3757 assert_eq!(
3758 app.handle_create_key(key(KeyCode::Down)),
3759 CreateEventInputResult::Continue
3760 );
3761 let rows = app.create_form().expect("form stays open").rows();
3762 assert!(rows[1].focused);
3763 assert_eq!(app.selected_date(), day);
3764
3765 assert_eq!(
3766 app.handle_create_key(key(KeyCode::Up)),
3767 CreateEventInputResult::Continue
3768 );
3769 assert!(app.create_form().expect("form stays open").rows()[0].focused);
3770 assert_eq!(app.selected_date(), day);
3771 }
3772
3773 #[test]
3774 fn create_form_calendar_selector_cycles_and_submits_target() {
3775 let day = date(2026, Month::April, 23);
3776 let calendar = EventWriteTarget::microsoft("work", "cal", "Microsoft work: Calendar");
3777 let personal = EventWriteTarget::microsoft("work", "personal", "Microsoft work: Personal");
3778 let mut form = CreateEventForm::new_with_targets(
3779 day,
3780 CreateEventContext::EditableDate,
3781 vec![
3782 EventWriteTarget::local(),
3783 calendar.clone(),
3784 personal.clone(),
3785 ],
3786 calendar.id.clone(),
3787 );
3788
3789 form.title = "Planning".to_string();
3790 let _ = form.handle_key(key(KeyCode::Tab));
3791 assert_eq!(form_value(&form, "Calendar"), "Microsoft work: Calendar");
3792
3793 let _ = form.handle_key(key(KeyCode::Right));
3794 assert_eq!(form_value(&form, "Calendar"), "Microsoft work: Personal");
3795 let _ = form.handle_key(key(KeyCode::Left));
3796 assert_eq!(form_value(&form, "Calendar"), "Microsoft work: Calendar");
3797 let _ = form.handle_key(key(KeyCode::Left));
3798 assert_eq!(form_value(&form, "Calendar"), "Local");
3799 let _ = form.handle_key(key(KeyCode::Right));
3800 let _ = form.handle_key(key(KeyCode::Right));
3801
3802 let result = form.handle_key(ctrl_char_key('s'));
3803
3804 let CreateEventInputResult::Submit(submission) = result else {
3805 panic!("form should submit");
3806 };
3807 assert_eq!(submission.target, personal.id);
3808 }
3809
3810 #[test]
3811 fn create_form_validates_required_title() {
3812 let mut form = CreateEventForm::new(
3813 date(2026, Month::April, 23),
3814 CreateEventContext::EditableDate,
3815 );
3816
3817 let result = form.handle_key(ctrl_char_key('s'));
3818
3819 assert_eq!(result, CreateEventInputResult::Continue);
3820 assert_eq!(form.error(), Some("title is required"));
3821 }
3822
3823 #[test]
3824 fn edit_form_preloads_timed_event_fields() {
3825 let day = date(2026, Month::April, 23);
3826 let event = local_timed_event("local-time", "Standup", at(day, 9, 0), at(day, 9, 30))
3827 .with_location("Room 1")
3828 .with_notes("Bring notes")
3829 .with_reminders(vec![
3830 Reminder::minutes_before(10),
3831 Reminder::minutes_before(60),
3832 ]);
3833
3834 let form = CreateEventForm::edit(&event);
3835
3836 assert_eq!(
3837 form.mode(),
3838 &EventFormMode::Edit {
3839 event_id: "local-time".to_string()
3840 }
3841 );
3842 assert_eq!(form.title, "Standup");
3843 assert!(!form.all_day);
3844 assert_eq!(form.start_date, "2026-04-23");
3845 assert_eq!(form.start_time, "09:00");
3846 assert_eq!(form.end_date, "2026-04-23");
3847 assert_eq!(form.end_time, "09:30");
3848 assert_eq!(form.location, "Room 1");
3849 assert_eq!(form.notes, "Bring notes");
3850 assert!(form.reminders[1]);
3851 assert!(form.reminders[4]);
3852 }
3853
3854 #[test]
3855 fn edit_form_preloads_provider_calendar_target() {
3856 let day = date(2026, Month::April, 23);
3857 let target = EventWriteTarget::microsoft("work", "cal", "Microsoft work: Calendar");
3858 let event = Event::timed(
3859 "microsoft:work:cal:remote-1",
3860 "Sync",
3861 at(day, 9, 0),
3862 at(day, 9, 30),
3863 SourceMetadata::new("microsoft:work:cal", "Microsoft work: Calendar"),
3864 )
3865 .expect("valid provider event");
3866
3867 let form =
3868 CreateEventForm::edit_with_targets(&event, vec![EventWriteTarget::local(), target]);
3869
3870 assert_eq!(form_value(&form, "Calendar"), "Microsoft work: Calendar");
3871 }
3872
3873 #[test]
3874 fn edit_form_preloads_all_day_event_and_can_switch_to_timed() {
3875 let day = date(2026, Month::April, 23);
3876 let event = local_all_day_event("local-all", "Release", day);
3877 let mut form = CreateEventForm::edit(&event);
3878
3879 assert!(form.all_day);
3880 assert_eq!(form.start_date, "2026-04-23");
3881 assert_eq!(form.start_time, "09:00");
3882 assert_eq!(form.end_time, "10:00");
3883
3884 form.all_day = false;
3885 let draft = form.submit().expect("all-day edit can become timed");
3886
3887 assert_eq!(
3888 draft.timing,
3889 CreateEventTiming::Timed {
3890 start: EventDateTime::new(day, Time::from_hms(9, 0, 0).expect("valid time")),
3891 end: EventDateTime::new(day, Time::from_hms(10, 0, 0).expect("valid time")),
3892 }
3893 );
3894 }
3895
3896 #[test]
3897 fn edit_form_can_switch_timed_event_to_all_day() {
3898 let day = date(2026, Month::April, 23);
3899 let event = local_timed_event("local-time", "Standup", at(day, 9, 0), at(day, 9, 30));
3900 let mut form = CreateEventForm::edit(&event);
3901
3902 form.all_day = true;
3903 let draft = form.submit().expect("timed edit can become all day");
3904
3905 assert_eq!(draft.timing, CreateEventTiming::AllDay { date: day });
3906 }
3907
3908 #[test]
3909 fn create_form_submits_day_view_cross_midnight_event() {
3910 let day = date(2026, Month::April, 23);
3911 let mut form = CreateEventForm::new(day, CreateEventContext::FixedDate);
3912 form.title = "Late work".to_string();
3913 form.start_time = "23:00".to_string();
3914 form.end_time = "01:00".to_string();
3915 form.location = "Terminal".to_string();
3916 form.notes = "Keep an eye on deploy".to_string();
3917 form.reminders[1] = true;
3918 form.reminders[4] = true;
3919
3920 let draft = form.submit().expect("form submits");
3921
3922 assert_eq!(draft.title, "Late work");
3923 assert_eq!(draft.location.as_deref(), Some("Terminal"));
3924 assert_eq!(draft.notes.as_deref(), Some("Keep an eye on deploy"));
3925 assert_eq!(
3926 draft
3927 .reminders
3928 .iter()
3929 .map(|reminder| reminder.minutes_before)
3930 .collect::<Vec<_>>(),
3931 [10, 60]
3932 );
3933 assert_eq!(
3934 draft.timing,
3935 CreateEventTiming::Timed {
3936 start: EventDateTime::new(day, Time::from_hms(23, 0, 0).expect("valid time")),
3937 end: EventDateTime::new(
3938 day.add_days(1),
3939 Time::from_hms(1, 0, 0).expect("valid time")
3940 ),
3941 }
3942 );
3943 }
3944
3945 #[test]
3946 fn create_form_submits_all_day_event() {
3947 let day = date(2026, Month::April, 23);
3948 let mut form = CreateEventForm::new(day, CreateEventContext::EditableDate);
3949 form.title = "Conference".to_string();
3950 form.all_day = true;
3951 form.reminders[5] = true;
3952
3953 let draft = form.submit().expect("form submits");
3954
3955 assert_eq!(draft.timing, CreateEventTiming::AllDay { date: day });
3956 assert_eq!(draft.reminders[0].minutes_before, 24 * 60);
3957 }
3958
3959 #[test]
3960 fn create_form_submits_weekly_recurrence() {
3961 let day = date(2026, Month::April, 23);
3962 let mut form = CreateEventForm::new(day, CreateEventContext::EditableDate);
3963 form.title = "Practice".to_string();
3964 form.repeat = RepeatFrequency::Weekly;
3965 form.recurrence_interval = "2".to_string();
3966 form.weekly_days = [false; DAYS_PER_WEEK];
3967 form.weekly_days[usize::from(Weekday::Tuesday.number_days_from_sunday())] = true;
3968 form.weekly_days[usize::from(Weekday::Thursday.number_days_from_sunday())] = true;
3969 form.recurrence_end = RecurrenceEndFormMode::Count;
3970 form.recurrence_count = "4".to_string();
3971
3972 let draft = form.submit().expect("form submits");
3973 let recurrence = draft.recurrence.expect("recurrence submitted");
3974
3975 assert_eq!(recurrence.frequency, RecurrenceFrequency::Weekly);
3976 assert_eq!(recurrence.interval, 2);
3977 assert_eq!(recurrence.weekdays, [Weekday::Tuesday, Weekday::Thursday]);
3978 assert_eq!(recurrence.end, RecurrenceEnd::Count(4));
3979 }
3980
3981 #[test]
3982 fn edit_series_form_preloads_recurrence_and_occurrence_form_hides_it() {
3983 let day = date(2026, Month::April, 23);
3984 let event = local_timed_event("series", "Standup", at(day, 9, 0), at(day, 9, 30))
3985 .with_recurrence(RecurrenceRule {
3986 frequency: RecurrenceFrequency::Monthly,
3987 interval: 1,
3988 end: RecurrenceEnd::Until(day.add_days(60)),
3989 weekdays: Vec::new(),
3990 monthly: Some(RecurrenceMonthlyRule::WeekdayOrdinal {
3991 ordinal: RecurrenceOrdinal::Last,
3992 weekday: day.weekday(),
3993 }),
3994 yearly: None,
3995 });
3996
3997 let form = CreateEventForm::edit(&event);
3998
3999 assert_eq!(form.repeat, RepeatFrequency::Monthly);
4000 assert_eq!(form.monthly_mode, RecurrenceMonthlyFormMode::WeekdayOrdinal);
4001 assert_eq!(form.recurrence_end, RecurrenceEndFormMode::Until);
4002
4003 let mut occurrence = event.clone();
4004 occurrence.id = "series#2026-04-23T09:00".to_string();
4005 occurrence.occurrence = Some(crate::agenda::OccurrenceMetadata {
4006 series_id: "series".to_string(),
4007 anchor: OccurrenceAnchor::Timed {
4008 start: at(day, 9, 0),
4009 },
4010 });
4011 let occurrence_form = CreateEventForm::edit_occurrence(&occurrence);
4012
4013 assert!(
4014 !occurrence_form
4015 .rows()
4016 .iter()
4017 .any(|row| row.label == "Repeat")
4018 );
4019 }
4020
4021 #[test]
4022 fn select_date_action_can_pick_adjacent_month_cells() {
4023 let mut app = AppState::new(date(2026, Month::April, 23));
4024
4025 app.apply(AppAction::SelectDate(date(2026, Month::May, 1)));
4026
4027 assert_eq!(app.selected_date(), date(2026, Month::May, 1));
4028 }
4029
4030 #[test]
4031 fn mouse_double_click_selects_then_opens_day() {
4032 let target = date(2026, Month::April, 18);
4033 let mut app = AppState::new(date(2026, Month::April, 23));
4034 let mut input = MouseInput::default();
4035 let start = Instant::now();
4036
4037 let action =
4038 input.translate_at(mouse_down(10, 10), Some(target), app.selected_date(), start);
4039 app.apply(action);
4040
4041 assert_eq!(app.selected_date(), target);
4042 assert_eq!(app.view_mode(), ViewMode::Month);
4043
4044 let action = input.translate_at(
4045 mouse_event(MouseEventKind::Up(MouseButton::Left), 10, 10),
4046 Some(target),
4047 app.selected_date(),
4048 start + Duration::from_millis(40),
4049 );
4050 app.apply(action);
4051 assert_eq!(app.view_mode(), ViewMode::Month);
4052
4053 let action = input.translate_at(
4054 mouse_down(10, 10),
4055 Some(target),
4056 app.selected_date(),
4057 start + Duration::from_millis(120),
4058 );
4059 app.apply(action);
4060
4061 assert_eq!(app.view_mode(), ViewMode::Day);
4062 }
4063
4064 #[test]
4065 fn slow_second_mouse_click_only_reselects_date() {
4066 let target = date(2026, Month::April, 18);
4067 let mut app = AppState::new(date(2026, Month::April, 23));
4068 let mut input = MouseInput::default();
4069 let start = Instant::now();
4070
4071 let action =
4072 input.translate_at(mouse_down(10, 10), Some(target), app.selected_date(), start);
4073 app.apply(action);
4074 let action = input.translate_at(
4075 mouse_down(10, 10),
4076 Some(target),
4077 app.selected_date(),
4078 start + MOUSE_DOUBLE_CLICK_TIMEOUT + Duration::from_millis(1),
4079 );
4080 app.apply(action);
4081
4082 assert_eq!(app.selected_date(), target);
4083 assert_eq!(app.view_mode(), ViewMode::Month);
4084 }
4085
4086 #[test]
4087 fn mouse_clicks_without_a_date_target_are_ignored() {
4088 let mut input = MouseInput::default();
4089 let selected = date(2026, Month::April, 23);
4090
4091 let action = input.translate(mouse_down(0, 0), None, selected);
4092
4093 assert_eq!(action, AppAction::Noop);
4094 }
4095
4096 #[test]
4097 fn non_left_mouse_actions_clear_pending_open() {
4098 let target = date(2026, Month::April, 18);
4099 let mut input = MouseInput::default();
4100
4101 assert_eq!(
4102 input.translate(
4103 mouse_down(10, 10),
4104 Some(target),
4105 date(2026, Month::April, 23),
4106 ),
4107 AppAction::SelectDate(target)
4108 );
4109 assert_eq!(
4110 input.translate(
4111 mouse_event(MouseEventKind::Down(MouseButton::Right), 10, 10),
4112 Some(target),
4113 target,
4114 ),
4115 AppAction::Noop
4116 );
4117 assert_eq!(
4118 input.translate(mouse_down(10, 10), Some(target), target),
4119 AppAction::SelectDate(target)
4120 );
4121 }
4122
4123 #[test]
4124 fn selected_day_agenda_uses_selected_date() {
4125 let app = AppState::from_dates(date(2026, Month::April, 23), date(2026, Month::April, 18));
4126 let source = InMemoryAgendaSource::with_events_and_holidays(
4127 Vec::new(),
4128 vec![
4129 Holiday::new(
4130 "selected",
4131 "Selected Day",
4132 date(2026, Month::April, 23),
4133 SourceMetadata::fixture(),
4134 ),
4135 Holiday::new(
4136 "today",
4137 "Today",
4138 date(2026, Month::April, 18),
4139 SourceMetadata::fixture(),
4140 ),
4141 ],
4142 );
4143
4144 let agenda = app.day_agenda(&source);
4145
4146 assert_eq!(agenda.date, date(2026, Month::April, 23));
4147 assert_eq!(agenda.holidays.len(), 1);
4148 assert_eq!(agenda.holidays[0].name, "Selected Day");
4149 }
4150 }
4151