1use std::time::Instant;
5
6use ratatui::layout::Rect;
7
8use crate::body::BodyEditor;
9use crate::due;
10use crate::duepicker::DuePicker;
11use crate::image::{GifLoad, GifPlayback};
12use crate::model::{Block, Category, MAX_TITLE_LEN, Task};
13use crate::text_input::TextInput;
14use crate::undo::{EditKind, History};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum Field {
18 Title,
19 Category,
20 Due,
21 Importance,
22 Body,
23}
24
25impl Field {
26 pub fn next(self) -> Self {
28 match self {
29 Self::Title => Self::Category,
30 Self::Category => Self::Due,
31 Self::Due => Self::Importance,
32 Self::Importance => Self::Body,
33 Self::Body => Self::Title,
34 }
35 }
36
37 pub fn prev(self) -> Self {
38 match self {
39 Self::Title => Self::Body,
40 Self::Category => Self::Title,
41 Self::Due => Self::Category,
42 Self::Importance => Self::Due,
43 Self::Body => Self::Importance,
44 }
45 }
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
50struct CategorySnap {
51 name: TextInput,
52 description: BodyEditor,
53 on_description: bool,
54}
55
56pub struct CategoryForm {
59 pub name: TextInput,
60 pub description: BodyEditor,
61 pub on_description: bool,
62 pub error: Option<String>,
63 pub editing: Option<String>,
65 pub name_area: Rect,
66 pub description_area: Rect,
67 history: History<CategorySnap>,
68 initial_name: String,
69 initial_description: String,
70}
71
72impl CategoryForm {
73 pub fn new() -> Self {
74 Self {
75 name: TextInput::new("", crate::model::MAX_CATEGORY_NAME_LEN),
76 description: BodyEditor::plain(""),
77 on_description: false,
78 error: None,
79 editing: None,
80 name_area: Rect::ZERO,
81 description_area: Rect::ZERO,
82 history: History::new(),
83 initial_name: String::new(),
84 initial_description: String::new(),
85 }
86 }
87
88 pub fn edit(category: &crate::model::Category) -> Self {
89 Self {
90 name: TextInput::new(&category.name, crate::model::MAX_CATEGORY_NAME_LEN),
91 description: BodyEditor::plain(&category.description),
92 editing: Some(category.id.clone()),
93 initial_name: category.name.clone(),
94 initial_description: category.description.clone(),
95 ..Self::new()
96 }
97 }
98
99 pub fn title_text(&self) -> &'static str {
100 if self.editing.is_some() {
101 "Edit category"
102 } else {
103 "New category"
104 }
105 }
106
107 pub fn toggle_field(&mut self) {
108 self.set_description_focus(!self.on_description);
109 }
110
111 pub fn set_description_focus(&mut self, description: bool) {
113 if self.on_description != description {
114 self.history.break_coalesce();
115 self.on_description = description;
116 }
117 }
118
119 pub fn is_dirty(&self) -> bool {
120 self.name.value() != self.initial_name
121 || self.description.plain_value() != self.initial_description
122 }
123
124 fn snap(&self) -> CategorySnap {
125 CategorySnap {
126 name: self.name.clone(),
127 description: self.description.clone(),
128 on_description: self.on_description,
129 }
130 }
131
132 fn restore(&mut self, s: CategorySnap) {
133 self.name = s.name;
134 self.description = s.description;
135 self.on_description = s.on_description;
136 self.error = None;
137 }
138
139 pub fn before_edit(&mut self, kind: EditKind) {
141 let Self {
142 name,
143 description,
144 on_description,
145 history,
146 ..
147 } = self;
148 history.before_edit_with(kind, || CategorySnap {
149 name: name.clone(),
150 description: description.clone(),
151 on_description: *on_description,
152 });
153 }
154
155 pub fn break_coalesce(&mut self) {
156 self.history.break_coalesce();
157 }
158
159 pub fn undo(&mut self) -> bool {
160 let Some(prev) = self.history.undo(self.snap()) else {
161 return false;
162 };
163 self.restore(prev);
164 true
165 }
166
167 pub fn redo(&mut self) -> bool {
168 let Some(next) = self.history.redo(self.snap()) else {
169 return false;
170 };
171 self.restore(next);
172 true
173 }
174
175 pub fn submit(&mut self) -> Option<(String, String)> {
177 self.submit_with(|_, _| Ok(()))
178 }
179
180 pub fn submit_with<F>(&mut self, validate_name: F) -> Option<(String, String)>
183 where
184 F: FnOnce(&str, Option<&str>) -> Result<(), String>,
185 {
186 let name = self.name.value().trim().to_string();
187 if name.is_empty() {
188 self.error = Some("A name is required".to_string());
189 self.on_description = false;
190 return None;
191 }
192 if let Err(error) = validate_name(&name, self.editing.as_deref()) {
193 self.error = Some(error);
194 self.on_description = false;
195 return None;
196 }
197 self.error = None;
198 Some((name, self.description.plain_value()))
199 }
200}
201
202impl Default for CategoryForm {
203 fn default() -> Self {
204 Self::new()
205 }
206}
207
208#[derive(Debug, Default, Clone, Copy)]
211pub struct FieldAreas {
212 pub title: Rect,
213 pub category: Rect,
214 pub due: Rect,
215 pub importance: Rect,
216 pub body: Rect,
217}
218
219impl FieldAreas {
220 pub fn field_at(&self, x: u16, y: u16) -> Option<Field> {
221 let pos = ratatui::layout::Position { x, y };
222 if self.title.contains(pos) {
223 Some(Field::Title)
224 } else if self.category.contains(pos) {
225 Some(Field::Category)
226 } else if self.due.contains(pos) {
227 Some(Field::Due)
228 } else if self.importance.contains(pos) {
229 Some(Field::Importance)
230 } else if self.body.contains(pos) {
231 Some(Field::Body)
232 } else {
233 None
234 }
235 }
236
237 pub fn rect(&self, field: Field) -> Rect {
238 match field {
239 Field::Title => self.title,
240 Field::Category => self.category,
241 Field::Due => self.due,
242 Field::Importance => self.importance,
243 Field::Body => self.body,
244 }
245 }
246}
247
248#[derive(Debug, Clone, Default, PartialEq)]
250pub struct TaskDraft {
251 pub title: String,
252 pub category_id: Option<String>,
254 pub due: String,
255 pub importance: u8,
256 pub body: Vec<Block>,
257}
258
259impl TaskDraft {
260 pub fn new(title: &str) -> Self {
261 Self {
262 title: title.to_string(),
263 ..Self::default()
264 }
265 }
266}
267
268#[derive(Debug, Clone, PartialEq, Eq)]
270struct TaskSnap {
271 title: TextInput,
272 category_id: Option<String>,
273 due: TextInput,
274 importance: u8,
275 body: BodyEditor,
276 field: Field,
277}
278
279#[derive(Debug, Clone, PartialEq, Eq)]
280struct CategoryChoice {
281 id: Option<String>,
282 name: String,
283}
284
285impl CategoryChoice {
286 fn uncategorized() -> Self {
287 Self {
288 id: None,
289 name: "Uncategorized".to_string(),
290 }
291 }
292}
293
294pub struct TaskForm {
295 pub title: TextInput,
296 category_id: Option<String>,
297 category_choices: Vec<CategoryChoice>,
298 pub due: TextInput,
299 pub importance: u8,
300 pub body: BodyEditor,
301 pub field: Field,
302 pub error: Option<String>,
303 pub editing: Option<String>,
305 pub areas: FieldAreas,
307 pub preview: bool,
309 pub gif: Option<(std::path::PathBuf, GifPlayback)>,
311 pub gif_pending: Option<GifLoad>,
313 pub picker: Option<DuePicker>,
315 pub last_body_click: Option<(Instant, usize)>,
317 pub menu_was_open: bool,
320 pub body_scroll: usize,
323 pub body_menu_area: Option<Rect>,
325 pub image_hits: Vec<(usize, Rect)>,
329 history: History<TaskSnap>,
330 initial: TaskDraft,
331}
332
333impl TaskForm {
334 pub fn new() -> Self {
335 Self {
336 title: TextInput::new("", MAX_TITLE_LEN),
337 category_id: None,
338 category_choices: vec![CategoryChoice::uncategorized()],
339 due: TextInput::new("", 32),
340 importance: 0,
341 body: BodyEditor::new(&[]),
342 field: Field::Title,
343 error: None,
344 editing: None,
345 areas: FieldAreas::default(),
346 preview: false,
347 gif: None,
348 gif_pending: None,
349 picker: None,
350 last_body_click: None,
351 menu_was_open: false,
352 body_scroll: 0,
353 body_menu_area: None,
354 image_hits: Vec::new(),
355 history: History::new(),
356 initial: TaskDraft::default(),
357 }
358 }
359
360 pub fn edit(task: &Task) -> Self {
361 Self {
362 title: TextInput::new(&task.title, MAX_TITLE_LEN),
363 category_id: task.category_id.clone(),
364 due: TextInput::new(&task.due, 32),
365 importance: task.importance,
366 body: BodyEditor::new(&task.body),
367 editing: Some(task.id.clone()),
368 initial: TaskDraft {
369 title: task.title.clone(),
370 category_id: task.category_id.clone(),
371 due: task.due.clone(),
372 importance: task.importance,
373 body: task.body.clone(),
374 },
375 ..Self::new()
376 }
377 }
378
379 pub fn image_hit_at(&self, line: usize, x: u16, y: u16) -> bool {
381 use ratatui::layout::Position;
382 self.image_hits
383 .iter()
384 .any(|(i, r)| *i == line && r.contains(Position { x, y }))
385 }
386
387 pub fn set_image_root(&mut self, image_root: std::path::PathBuf) {
388 self.body.set_image_root(image_root);
389 }
390
391 pub fn set_attachments(&mut self, attachments: &[crate::store::Attachment]) {
392 self.body.set_attachments(attachments);
393 }
394
395 pub fn set_categories(&mut self, categories: &[Category], selected_id: Option<&str>) {
402 self.category_choices.clear();
403 self.category_choices.push(CategoryChoice::uncategorized());
404 self.category_choices
405 .extend(
406 categories
407 .iter()
408 .filter(|category| !category.is_all())
409 .map(|category| CategoryChoice {
410 id: Some(category.id.clone()),
411 name: category.name.clone(),
412 }),
413 );
414 self.category_id = selected_id
415 .filter(|id| {
416 self.category_choices
417 .iter()
418 .any(|choice| choice.id.as_deref() == Some(*id))
419 })
420 .map(str::to_string);
421 self.initial.category_id = self.category_id.clone();
422 }
423
424 pub fn category_id(&self) -> Option<&str> {
425 self.category_id.as_deref()
426 }
427
428 pub fn category_label(&self) -> &str {
429 self.category_choices
430 .iter()
431 .find(|choice| choice.id.as_deref() == self.category_id.as_deref())
432 .map(|choice| choice.name.as_str())
433 .unwrap_or("Uncategorized")
434 }
435
436 pub fn cycle_category(&mut self, delta: i32) {
438 let len = self.category_choices.len();
439 if len <= 1 {
440 return;
441 }
442 let current = self
443 .category_choices
444 .iter()
445 .position(|choice| choice.id.as_deref() == self.category_id.as_deref())
446 .unwrap_or_default();
447 let next = (current as i32 + delta).rem_euclid(len as i32) as usize;
448 if next == current {
449 return;
450 }
451 self.before_edit(EditKind::Atomic);
452 self.category_id = self.category_choices[next].id.clone();
453 }
454
455 pub fn clear_category(&mut self) {
456 if self.category_id.is_none() {
457 return;
458 }
459 self.before_edit(EditKind::Atomic);
460 self.category_id = None;
461 }
462
463 pub fn open_image_preview(&mut self) -> Option<String> {
466 let Some(path) = self.body.selected_image() else {
468 self.preview = false;
469 return Some("No image to preview".into());
470 };
471 self.preview = true;
472 if crate::image::is_gif(&path) {
473 if matches!(&self.gif, Some((p, _)) if p == &path) {
475 return None;
476 }
477 if self
478 .gif_pending
479 .as_ref()
480 .is_none_or(|pending| pending.path() != path)
481 {
482 self.gif = None;
483 self.gif_pending = Some(GifLoad::start(path));
484 }
485 None
486 } else {
487 if !matches!(&self.gif, Some((p, _)) if p == &path) {
489 self.gif = None;
490 }
491 self.gif_pending = None;
492 None
493 }
494 }
495
496 pub fn close_image_preview(&mut self) {
497 self.preview = false;
498 }
500
501 pub fn gif_playing(&self) -> bool {
502 self.preview
503 && (self.gif_pending.is_some()
504 || (!crate::theme::reduced_motion()
505 && self
506 .gif
507 .as_ref()
508 .is_some_and(|(_, g)| g.is_animated() && !g.is_paused())))
509 }
510
511 pub fn tick_gif(&mut self) -> bool {
513 if let Some(result) = self.gif_pending.as_ref().and_then(GifLoad::poll) {
514 let path = self
515 .gif_pending
516 .take()
517 .map(|pending| pending.path().to_path_buf());
518 match (path, result) {
519 (Some(path), Ok(gif)) => self.gif = Some((path, gif)),
520 (_, Err(error)) => {
521 self.gif = None;
522 self.error = Some(error);
523 }
524 (None, Ok(_)) => {}
525 }
526 return true;
527 }
528 if crate::theme::reduced_motion() {
529 return false;
530 }
531 if let Some((_, gif)) = &mut self.gif {
532 return gif.tick();
533 }
534 false
535 }
536
537 pub fn preview_click(&mut self) {
540 if crate::theme::reduced_motion() {
541 return;
542 }
543 if let Some((_, gif)) = &mut self.gif {
544 gif.toggle_pause();
545 }
546 }
547
548 pub fn is_edit(&self) -> bool {
549 self.editing.is_some()
550 }
551
552 pub fn is_dirty(&self) -> bool {
553 self.content() != self.initial
554 }
555
556 fn content(&self) -> TaskDraft {
557 TaskDraft {
558 title: self.title.value(),
559 category_id: self.category_id.clone(),
560 due: self.due.value(),
561 importance: self.importance,
562 body: self.body.value(),
563 }
564 }
565
566 pub fn title_text(&self) -> &'static str {
567 if self.is_edit() {
568 "Edit task"
569 } else {
570 "New task"
571 }
572 }
573
574 fn snap(&self) -> TaskSnap {
575 TaskSnap {
576 title: self.title.clone(),
577 category_id: self.category_id.clone(),
578 due: self.due.clone(),
579 importance: self.importance,
580 body: self.body.clone(),
581 field: self.field,
582 }
583 }
584
585 fn restore(&mut self, s: TaskSnap) {
586 self.title = s.title;
587 self.category_id = s.category_id;
588 self.due = s.due;
589 self.importance = s.importance;
590 self.body = s.body;
591 self.field = s.field;
592 self.error = None;
593 self.picker = None;
595 self.preview = false;
596 self.gif_pending = None;
597 self.body.close_menu();
598 }
599
600 pub fn before_edit(&mut self, kind: EditKind) {
602 let Self {
603 title,
604 category_id,
605 due,
606 importance,
607 body,
608 field,
609 history,
610 ..
611 } = self;
612 history.before_edit_with(kind, || TaskSnap {
613 title: title.clone(),
614 category_id: category_id.clone(),
615 due: due.clone(),
616 importance: *importance,
617 body: body.clone(),
618 field: *field,
619 });
620 }
621
622 pub fn break_coalesce(&mut self) {
623 self.history.break_coalesce();
624 }
625
626 pub fn undo(&mut self) -> bool {
627 let Some(prev) = self.history.undo(self.snap()) else {
628 return false;
629 };
630 self.restore(prev);
631 true
632 }
633
634 pub fn redo(&mut self) -> bool {
635 let Some(next) = self.history.redo(self.snap()) else {
636 return false;
637 };
638 self.restore(next);
639 true
640 }
641
642 pub fn open_due_picker(&mut self) {
644 self.history.break_coalesce();
645 self.field = Field::Due;
646 self.picker = Some(DuePicker::new(self.due.value().trim()));
647 }
648
649 pub fn take_due_picker(&mut self) {
651 if self.picker.is_some() {
652 self.before_edit(EditKind::Atomic);
653 if let Some(picker) = self.picker.take() {
654 self.due = TextInput::new(&picker.value(), 32);
655 }
656 }
657 }
658
659 pub fn cycle_importance(&mut self) {
661 let next = (self.importance + 1) % (crate::model::MAX_IMPORTANCE + 1);
662 self.set_importance(next);
663 }
664
665 pub fn set_importance(&mut self, importance: u8) {
666 let next = importance.min(crate::model::MAX_IMPORTANCE);
667 if next == self.importance {
668 return;
669 }
670 self.before_edit(EditKind::Atomic);
671 self.importance = next;
672 }
673
674 pub fn clear_due(&mut self) {
675 if self.due.is_empty() && self.picker.is_none() {
676 return;
677 }
678 self.before_edit(EditKind::Atomic);
679 self.picker = None;
680 self.due = TextInput::new("", 32);
681 }
682
683 pub fn focus_next(&mut self) {
684 self.history.break_coalesce();
685 self.picker = None;
686 self.body.close_menu();
687 self.field = self.field.next();
688 }
689
690 pub fn focus_prev(&mut self) {
691 self.history.break_coalesce();
692 self.picker = None;
693 self.body.close_menu();
694 self.field = self.field.prev();
695 }
696
697 pub fn set_field(&mut self, field: Field) {
700 self.history.break_coalesce();
701 if field != Field::Due {
702 self.picker = None;
703 }
704 if field != Field::Body {
705 self.body.close_menu();
706 }
707 self.field = field;
708 }
709
710 pub fn submit(&mut self) -> Option<TaskDraft> {
713 self.error = None;
714
715 let title = self.title.value().trim().to_string();
716 if title.is_empty() {
717 self.error = Some("A title is required".to_string());
718 self.field = Field::Title;
719 return None;
720 }
721
722 let due_text = self.due.value().trim().to_string();
723 if !due::is_valid(&due_text) {
724 self.error = Some(format!("'{due_text}' is not a date mach understands"));
725 self.field = Field::Due;
726 return None;
727 }
728
729 let (inline_due, title) = due::parse(&title);
733 if title.is_empty() {
734 self.error = Some("A title is required".to_string());
735 self.field = Field::Title;
736 return None;
737 }
738 if !due::is_valid(&inline_due) {
739 self.error = Some(format!("'{inline_due}' is not a date mach understands"));
740 self.field = Field::Title;
741 return None;
742 }
743
744 Some(TaskDraft {
745 title,
746 category_id: self.category_id.clone(),
747 due: if due_text.is_empty() {
748 inline_due
749 } else {
750 due_text
751 },
752 importance: self.importance,
753 body: self.body.value(),
754 })
755 }
756}
757
758impl Default for TaskForm {
759 fn default() -> Self {
760 Self::new()
761 }
762}
763
764#[cfg(test)]
765mod tests {
766 use super::*;
767 use crate::undo::EditKind;
768
769 #[test]
770 fn requires_a_title() {
771 let mut form = TaskForm::new();
772 assert!(form.submit().is_none());
773 assert_eq!(form.field, Field::Title);
774 assert!(form.error.is_some());
775 }
776
777 #[test]
778 fn rejects_an_unparsable_date() {
779 let mut form = TaskForm::new();
780 form.title = TextInput::new("something", MAX_TITLE_LEN);
781 form.due = TextInput::new("next tuesday", 32);
782 assert!(form.submit().is_none());
783 assert_eq!(form.field, Field::Due);
784 }
785
786 #[test]
787 fn takes_a_date_typed_into_the_title() {
788 let mut form = TaskForm::new();
789 form.title = TextInput::new("pay rent [2030-01-02]", MAX_TITLE_LEN);
790 let draft = form.submit().expect("valid");
791 assert_eq!(draft.title, "pay rent");
792 assert_eq!(draft.due, "2030-01-02");
793 }
794
795 #[test]
796 fn an_explicit_date_wins_over_the_inline_one() {
797 let mut form = TaskForm::new();
798 form.title = TextInput::new("pay rent [2030-01-02]", MAX_TITLE_LEN);
799 form.due = TextInput::new("09:00", 32);
800 let draft = form.submit().expect("valid");
801 assert_eq!(draft.due, "09:00");
802 }
803
804 #[test]
805 fn undo_restores_title_and_body() {
806 let mut form = TaskForm::new();
807 form.before_edit(EditKind::Typing);
808 form.title = TextInput::new("hello", MAX_TITLE_LEN);
809 form.before_edit(EditKind::Atomic);
810 form.body.insert_str("note");
811 assert!(form.undo());
812 assert!(form.body.is_empty() || form.body.plain_value().is_empty());
813 assert_eq!(form.title.value(), "hello");
814 assert!(form.undo());
815 assert_eq!(form.title.value(), "");
816 assert!(form.redo());
817 assert_eq!(form.title.value(), "hello");
818 }
819
820 #[test]
821 fn undo_restores_importance() {
822 let mut form = TaskForm::new();
823 form.set_importance(2);
824 assert_eq!(form.importance, 2);
825 assert!(form.undo());
826 assert_eq!(form.importance, 0);
827 assert!(form.redo());
828 assert_eq!(form.importance, 2);
829 }
830
831 #[test]
832 fn dirty_state_tracks_content_not_focus() {
833 let mut task = TaskForm::new();
834 task.focus_next();
835 assert!(!task.is_dirty());
836 task.title.insert('x');
837 assert!(task.is_dirty());
838
839 let mut category = CategoryForm::new();
840 category.set_description_focus(true);
841 assert!(!category.is_dirty());
842 category.name.insert('x');
843 assert!(category.is_dirty());
844 }
845
846 #[test]
847 fn category_submit_exposes_a_shared_name_policy_hook() {
848 let mut form = CategoryForm::new();
849 form.name.insert_str("Work");
850 assert!(
851 form.submit_with(|name, _| {
852 (name != "Work")
853 .then_some(())
854 .ok_or_else(|| "A category with that name already exists".to_string())
855 })
856 .is_none()
857 );
858 assert_eq!(
859 form.error.as_deref(),
860 Some("A category with that name already exists")
861 );
862 }
863
864 #[test]
865 fn opening_a_gif_never_decodes_on_the_input_path() {
866 let path = std::env::temp_dir().join(format!("mach-async-{}.gif", std::process::id()));
867 std::fs::write(&path, b"not a real gif").unwrap();
868 let mut task = Task::new("gif", 0, None, "");
869 task.body = vec![Block::Image {
870 attachment_id: path.display().to_string(),
871 }];
872 let mut form = TaskForm::edit(&task);
873
874 assert!(form.open_image_preview().is_none());
875 assert!(form.gif.is_none());
876 assert!(form.gif_pending.is_some());
877 }
878
879 #[test]
880 fn category_selector_includes_uncategorized_and_is_part_of_the_draft() {
881 let categories = [
882 crate::model::Category::all_tasks(),
883 crate::model::Category {
884 id: "work".into(),
885 name: "Work".into(),
886 description: String::new(),
887 },
888 ];
889 let mut form = TaskForm::new();
890 form.set_categories(&categories, Some("work"));
891
892 assert_eq!(form.category_id(), Some("work"));
893 assert_eq!(form.category_label(), "Work");
894 assert!(!form.is_dirty());
895
896 form.cycle_category(1);
897 assert_eq!(form.category_id(), None);
898 assert_eq!(form.category_label(), "Uncategorized");
899 assert!(form.is_dirty());
900
901 form.title.insert_str("portable task");
902 assert_eq!(form.submit().expect("valid draft").category_id, None);
903 assert!(form.undo());
904 assert_eq!(form.category_id(), Some("work"));
905 }
906
907 #[test]
908 fn editing_a_task_keeps_its_category_in_the_dirty_baseline() {
909 let mut task = Task::new("move me", 0, Some("work".into()), "");
910 task.id = "task".into();
911 let categories = [crate::model::Category {
912 id: "work".into(),
913 name: "Work".into(),
914 description: String::new(),
915 }];
916 let mut form = TaskForm::edit(&task);
917 form.set_categories(&categories, task.category_id.as_deref());
918
919 assert_eq!(form.category_id(), Some("work"));
920 assert!(!form.is_dirty());
921 assert_eq!(
922 form.submit().expect("valid draft").category_id.as_deref(),
923 Some("work")
924 );
925 }
926}