1use std::time::Instant;
5
6use ratatui::layout::Rect;
7
8use crate::description::DescriptionEditor;
9use crate::due;
10use crate::duepicker::DuePicker;
11use crate::image::{GifLoad, GifPlayback, TemporaryImage};
12use crate::model::{Block, Category, Label, LabelColor, MAX_LABELS_PER_TASK, 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 Labels,
21 Due,
22 Importance,
23 Description,
24}
25
26impl Field {
27 pub fn next(self) -> Self {
29 match self {
30 Self::Title => Self::Category,
31 Self::Category => Self::Labels,
32 Self::Labels => Self::Due,
33 Self::Due => Self::Importance,
34 Self::Importance => Self::Description,
35 Self::Description => Self::Title,
36 }
37 }
38
39 pub fn prev(self) -> Self {
40 match self {
41 Self::Title => Self::Description,
42 Self::Category => Self::Title,
43 Self::Labels => Self::Category,
44 Self::Due => Self::Labels,
45 Self::Importance => Self::Due,
46 Self::Description => Self::Importance,
47 }
48 }
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
53struct CategorySnap {
54 name: TextInput,
55 description: DescriptionEditor,
56 on_description: bool,
57}
58
59pub struct CategoryForm {
61 pub name: TextInput,
62 pub description: DescriptionEditor,
63 pub on_description: bool,
64 pub error: Option<String>,
65 pub editing: Option<String>,
67 pub form_area: Rect,
69 pub name_area: Rect,
70 pub description_area: Rect,
71 pub description_menu_area: Option<Rect>,
73 history: History<CategorySnap>,
74 initial_name: String,
75 initial_description: String,
76}
77
78impl CategoryForm {
79 pub fn new() -> Self {
80 Self {
81 name: TextInput::new("", crate::model::MAX_CATEGORY_NAME_LEN),
82 description: DescriptionEditor::plain(""),
83 on_description: false,
84 error: None,
85 editing: None,
86 form_area: Rect::ZERO,
87 name_area: Rect::ZERO,
88 description_area: Rect::ZERO,
89 description_menu_area: None,
90 history: History::new(),
91 initial_name: String::new(),
92 initial_description: String::new(),
93 }
94 }
95
96 pub fn edit(category: &crate::model::Category) -> Self {
97 Self {
98 name: TextInput::new(&category.name, crate::model::MAX_CATEGORY_NAME_LEN),
99 description: DescriptionEditor::plain(&category.description),
100 editing: Some(category.id.clone()),
101 initial_name: category.name.clone(),
102 initial_description: category.description.clone(),
103 ..Self::new()
104 }
105 }
106
107 pub fn title_text(&self) -> &'static str {
108 if self.editing.is_some() {
109 "Edit category"
110 } else {
111 "New category"
112 }
113 }
114
115 pub fn toggle_field(&mut self) {
116 self.set_description_focus(!self.on_description);
117 }
118
119 pub fn set_description_focus(&mut self, description: bool) {
121 if self.on_description != description {
122 self.history.break_coalesce();
123 self.on_description = description;
124 }
125 }
126
127 pub fn is_dirty(&self) -> bool {
128 self.name.value() != self.initial_name
129 || self.description.plain_value() != self.initial_description
130 }
131
132 fn snap(&self) -> CategorySnap {
133 CategorySnap {
134 name: self.name.clone(),
135 description: self.description.clone(),
136 on_description: self.on_description,
137 }
138 }
139
140 fn restore(&mut self, s: CategorySnap) {
141 self.name = s.name;
142 self.description = s.description;
143 self.on_description = s.on_description;
144 self.error = None;
145 }
146
147 pub fn before_edit(&mut self, kind: EditKind) {
149 let Self {
150 name,
151 description,
152 on_description,
153 history,
154 ..
155 } = self;
156 history.before_edit_with(kind, || CategorySnap {
157 name: name.clone(),
158 description: description.clone(),
159 on_description: *on_description,
160 });
161 }
162
163 pub fn break_coalesce(&mut self) {
164 self.history.break_coalesce();
165 }
166
167 pub fn undo(&mut self) -> bool {
168 let Some(prev) = self.history.undo(self.snap()) else {
169 return false;
170 };
171 self.restore(prev);
172 true
173 }
174
175 pub fn redo(&mut self) -> bool {
176 let Some(next) = self.history.redo(self.snap()) else {
177 return false;
178 };
179 self.restore(next);
180 true
181 }
182
183 pub fn submit(&mut self) -> Option<(String, String)> {
185 self.submit_with(|_, _| Ok(()))
186 }
187
188 pub fn submit_with<F>(&mut self, validate_name: F) -> Option<(String, String)>
191 where
192 F: FnOnce(&str, Option<&str>) -> Result<(), String>,
193 {
194 let name = self.name.value().trim().to_string();
195 if name.is_empty() {
196 self.error = Some("A name is required".to_string());
197 self.on_description = false;
198 return None;
199 }
200 if let Err(error) = validate_name(&name, self.editing.as_deref()) {
201 self.error = Some(error);
202 self.on_description = false;
203 return None;
204 }
205 self.error = None;
206 Some((name, self.description.plain_value()))
207 }
208}
209
210impl Default for CategoryForm {
211 fn default() -> Self {
212 Self::new()
213 }
214}
215
216#[derive(Debug, Default, Clone, Copy)]
219pub struct FieldAreas {
220 pub title: Rect,
221 pub category: Rect,
222 pub labels: Rect,
223 pub due: Rect,
224 pub importance: Rect,
225 pub description: Rect,
226 pub description_bottom: Rect,
227}
228
229impl FieldAreas {
230 pub fn field_at(&self, x: u16, y: u16) -> Option<Field> {
231 let pos = ratatui::layout::Position { x, y };
232 if self.title.contains(pos) {
233 Some(Field::Title)
234 } else if self.category.contains(pos) {
235 Some(Field::Category)
236 } else if self.labels.contains(pos) {
237 Some(Field::Labels)
238 } else if self.due.contains(pos) {
239 Some(Field::Due)
240 } else if self.importance.contains(pos) {
241 Some(Field::Importance)
242 } else if self.description.contains(pos) {
243 Some(Field::Description)
244 } else {
245 None
246 }
247 }
248
249 pub fn rect(&self, field: Field) -> Rect {
250 match field {
251 Field::Title => self.title,
252 Field::Category => self.category,
253 Field::Labels => self.labels,
254 Field::Due => self.due,
255 Field::Importance => self.importance,
256 Field::Description => self.description,
257 }
258 }
259}
260
261#[derive(Debug, Clone, Default, PartialEq)]
263pub struct TaskDraft {
264 pub title: String,
265 pub category_id: Option<String>,
267 pub label_ids: Vec<String>,
269 pub due: String,
270 pub importance: u8,
271 pub description: Vec<Block>,
272}
273
274impl TaskDraft {
275 pub fn new(title: &str) -> Self {
276 Self {
277 title: title.to_string(),
278 ..Self::default()
279 }
280 }
281
282 pub(crate) fn resolved_title_and_due(&self) -> (String, String) {
283 let (inline_due, title) = due::parse(self.title.trim());
284 let due = if self.due.is_empty() {
285 inline_due
286 } else {
287 self.due.clone()
288 };
289 (title, due)
290 }
291}
292
293#[derive(Debug, Clone, PartialEq, Eq)]
295struct TaskSnap {
296 title: TextInput,
297 category_id: Option<String>,
298 label_ids: Vec<String>,
299 due: TextInput,
300 importance: u8,
301 description: DescriptionEditor,
302 field: Field,
303}
304
305#[derive(Debug, Clone, PartialEq, Eq)]
306struct CategoryChoice {
307 id: Option<String>,
308 name: String,
309}
310
311#[derive(Debug, Clone, PartialEq, Eq)]
312struct LabelChoice {
313 id: String,
314 name: String,
315 color: LabelColor,
316}
317
318#[derive(Debug, Clone, Copy, PartialEq, Eq)]
319pub struct ListPicker {
320 pub index: usize,
321 area: Rect,
322 start: usize,
323}
324
325impl ListPicker {
326 fn new(index: usize) -> Self {
327 Self {
328 index,
329 area: Rect::ZERO,
330 start: 0,
331 }
332 }
333
334 fn set_layout(&mut self, area: Rect, start: usize) {
335 self.area = area;
336 self.start = start;
337 }
338
339 fn row_at(&self, x: u16, y: u16, count: usize) -> Option<usize> {
340 if !self.area.contains(ratatui::layout::Position { x, y })
341 || y <= self.area.y
342 || y >= self.area.bottom().saturating_sub(1)
343 {
344 return None;
345 }
346 let index = self.start + usize::from(y - self.area.y - 1);
347 (index < count).then_some(index)
348 }
349
350 fn select(&mut self, index: usize, count: usize) {
351 if index < count {
352 self.index = index;
353 }
354 }
355
356 fn move_by(&mut self, delta: isize, count: usize) {
357 if count > 0 {
358 self.index = (self.index as isize + delta).clamp(0, count as isize - 1) as usize;
359 }
360 }
361}
362
363impl CategoryChoice {
364 fn uncategorized() -> Self {
365 Self {
366 id: None,
367 name: "Uncategorized".to_string(),
368 }
369 }
370}
371
372pub struct TaskForm {
373 pub title: TextInput,
374 category_id: Option<String>,
375 category_choices: Vec<CategoryChoice>,
376 label_ids: Vec<String>,
377 label_choices: Vec<LabelChoice>,
378 pub due: TextInput,
379 pub importance: u8,
380 pub description: DescriptionEditor,
381 pub field: Field,
382 pub error: Option<String>,
383 pub editing: Option<String>,
385 pub areas: FieldAreas,
387 pub form_area: Rect,
389 pub preview: bool,
391 pub gif: Option<(std::path::PathBuf, GifPlayback)>,
393 pub gif_pending: Option<GifLoad>,
395 pub picker: Option<DuePicker>,
397 pub category_picker: Option<ListPicker>,
399 pub label_picker: Option<ListPicker>,
401 pub last_description_click: Option<(Instant, usize)>,
403 pub description_scroll: usize,
406 pub description_menu_area: Option<Rect>,
408 pub image_hits: Vec<(usize, Rect)>,
412 pub(crate) image_occlusions: Vec<Rect>,
415 pub(crate) image_layout: Vec<(std::path::PathBuf, u16, u16)>,
416 history: History<TaskSnap>,
417 initial: TaskDraft,
418 temporary_images: Vec<TemporaryImage>,
419}
420
421impl TaskForm {
422 pub fn new() -> Self {
423 Self::new_with_images(crate::image::default_images_root(), &[])
424 }
425
426 pub fn new_with_images(
427 image_root: std::path::PathBuf,
428 attachments: &[crate::store::Attachment],
429 ) -> Self {
430 Self::with_description(DescriptionEditor::new_with_images(
431 &[],
432 image_root,
433 attachments,
434 ))
435 }
436
437 fn with_description(description: DescriptionEditor) -> Self {
438 Self {
439 title: TextInput::new("", MAX_TITLE_LEN),
440 category_id: None,
441 category_choices: vec![CategoryChoice::uncategorized()],
442 label_ids: Vec::new(),
443 label_choices: Vec::new(),
444 due: TextInput::new("", 32),
445 importance: 0,
446 description,
447 field: Field::Title,
448 error: None,
449 editing: None,
450 areas: FieldAreas::default(),
451 form_area: Rect::ZERO,
452 preview: false,
453 gif: None,
454 gif_pending: None,
455 picker: None,
456 category_picker: None,
457 label_picker: None,
458 last_description_click: None,
459 description_scroll: 0,
460 description_menu_area: None,
461 image_hits: Vec::new(),
462 image_occlusions: Vec::new(),
463 image_layout: Vec::new(),
464 history: History::new(),
465 initial: TaskDraft::default(),
466 temporary_images: Vec::new(),
467 }
468 }
469
470 pub fn edit(task: &Task) -> Self {
471 Self::edit_with_images(task, crate::image::default_images_root(), &[])
472 }
473
474 pub fn edit_with_images(
475 task: &Task,
476 image_root: std::path::PathBuf,
477 attachments: &[crate::store::Attachment],
478 ) -> Self {
479 let description =
480 DescriptionEditor::new_with_images(&task.description, image_root, attachments);
481 let initial_description = description.value();
482 let mut form = Self::with_description(description);
483 form.title = TextInput::new(&task.title, MAX_TITLE_LEN);
484 form.category_id = task.category_id.clone();
485 form.label_ids = task.label_ids.clone();
486 form.due = TextInput::new(&task.due, 32);
487 form.importance = task.importance;
488 form.editing = Some(task.id.clone());
489 form.initial = TaskDraft {
490 title: task.title.clone(),
491 category_id: task.category_id.clone(),
492 label_ids: task.label_ids.clone(),
493 due: task.due.clone(),
494 importance: task.importance,
495 description: initial_description,
496 };
497 form
498 }
499
500 pub fn image_hit_at(&self, line: usize, x: u16, y: u16) -> bool {
502 use ratatui::layout::Position;
503 self.image_hits
504 .iter()
505 .any(|(i, r)| *i == line && r.contains(Position { x, y }))
506 }
507
508 pub fn set_image_root(&mut self, image_root: std::path::PathBuf) {
509 self.description.set_image_root(image_root);
510 }
511
512 pub fn set_attachments(&mut self, attachments: &[crate::store::Attachment]) {
513 self.description.set_attachments(attachments);
514 }
515
516 pub fn set_categories(&mut self, categories: &[Category], selected_id: Option<&str>) {
523 self.category_choices.clear();
524 self.category_choices.push(CategoryChoice::uncategorized());
525 self.category_choices
526 .extend(
527 categories
528 .iter()
529 .filter(|category| !category.is_all())
530 .map(|category| CategoryChoice {
531 id: Some(category.id.clone()),
532 name: category.name.clone(),
533 }),
534 );
535 self.category_id = selected_id
536 .filter(|id| {
537 self.category_choices
538 .iter()
539 .any(|choice| choice.id.as_deref() == Some(*id))
540 })
541 .map(str::to_string);
542 self.initial.category_id = self.category_id.clone();
543 }
544
545 pub fn category_id(&self) -> Option<&str> {
546 self.category_id.as_deref()
547 }
548
549 pub fn category_label(&self) -> &str {
550 self.category_choices
551 .iter()
552 .find(|choice| choice.id.as_deref() == self.category_id.as_deref())
553 .map(|choice| choice.name.as_str())
554 .unwrap_or("Uncategorized")
555 }
556
557 pub fn category_choices(&self) -> impl Iterator<Item = (&str, bool)> {
558 self.category_choices.iter().map(|choice| {
559 (
560 choice.name.as_str(),
561 choice.id.as_deref() == self.category_id.as_deref(),
562 )
563 })
564 }
565
566 pub fn category_picker_open(&self) -> bool {
567 self.category_picker.is_some()
568 }
569
570 pub fn open_category_picker(&mut self) {
571 self.history.break_coalesce();
572 self.field = Field::Category;
573 self.picker = None;
574 self.label_picker = None;
575 self.description.close_menu();
576 let index = self
577 .category_choices
578 .iter()
579 .position(|choice| choice.id.as_deref() == self.category_id.as_deref())
580 .unwrap_or_default();
581 self.category_picker = Some(ListPicker::new(index));
582 }
583
584 pub fn close_category_picker(&mut self) {
585 self.category_picker = None;
586 }
587
588 pub fn commit_category_picker(&mut self) {
589 let Some(index) = self.category_picker.take().map(|picker| picker.index) else {
590 return;
591 };
592 let Some(category_id) = self
593 .category_choices
594 .get(index)
595 .map(|choice| choice.id.clone())
596 else {
597 return;
598 };
599 if category_id == self.category_id {
600 return;
601 }
602 self.before_edit(EditKind::Atomic);
603 self.category_id = category_id;
604 }
605
606 pub(crate) fn set_category_picker_layout(&mut self, area: Rect, start: usize) {
607 if let Some(picker) = &mut self.category_picker {
608 picker.set_layout(area, start);
609 }
610 }
611
612 pub fn category_picker_area(&self) -> Option<Rect> {
613 self.category_picker.as_ref().map(|picker| picker.area)
614 }
615
616 pub(crate) fn category_picker_row_at(&self, x: u16, y: u16) -> Option<usize> {
617 self.category_picker
618 .as_ref()?
619 .row_at(x, y, self.category_choices.len())
620 }
621
622 pub(crate) fn select_category_picker(&mut self, index: usize) {
623 if let Some(picker) = &mut self.category_picker {
624 picker.select(index, self.category_choices.len());
625 }
626 }
627
628 pub fn move_category_picker(&mut self, delta: isize) {
629 if let Some(picker) = &mut self.category_picker {
630 picker.move_by(delta, self.category_choices.len());
631 }
632 }
633
634 pub fn select_first_category(&mut self) {
635 self.select_category_picker(0);
636 }
637
638 pub fn select_last_category(&mut self) {
639 let last = self.category_choices.len().saturating_sub(1);
640 self.select_category_picker(last);
641 }
642
643 pub fn clear_category(&mut self) {
644 if self.category_id.is_none() {
645 return;
646 }
647 self.before_edit(EditKind::Atomic);
648 self.category_id = None;
649 }
650
651 pub fn set_labels(&mut self, labels: &[Label], selected_ids: &[String]) {
654 self.label_choices = labels
655 .iter()
656 .map(|label| LabelChoice {
657 id: label.id.clone(),
658 name: label.name.clone(),
659 color: label.color,
660 })
661 .collect();
662 self.label_ids = self
663 .label_choices
664 .iter()
665 .filter(|choice| selected_ids.contains(&choice.id))
666 .map(|choice| choice.id.clone())
667 .collect();
668 self.initial.label_ids = self.label_ids.clone();
669 }
670
671 pub fn refresh_labels(&mut self, labels: &[Label]) {
675 self.label_choices = labels
676 .iter()
677 .map(|label| LabelChoice {
678 id: label.id.clone(),
679 name: label.name.clone(),
680 color: label.color,
681 })
682 .collect();
683 let order = self
684 .label_choices
685 .iter()
686 .map(|choice| choice.id.clone())
687 .collect::<Vec<_>>();
688 let canonicalize = |ids: &mut Vec<String>| {
689 *ids = order
690 .iter()
691 .filter(|id| ids.contains(id))
692 .cloned()
693 .collect();
694 };
695 canonicalize(&mut self.label_ids);
696 canonicalize(&mut self.initial.label_ids);
697 self.history
698 .for_each_mut(|snapshot| canonicalize(&mut snapshot.label_ids));
699 }
700
701 pub fn label_ids(&self) -> &[String] {
702 &self.label_ids
703 }
704
705 pub fn selected_labels(&self) -> Vec<(&str, LabelColor)> {
706 self.label_choices
707 .iter()
708 .filter(|choice| self.label_ids.contains(&choice.id))
709 .map(|choice| (choice.name.as_str(), choice.color))
710 .collect()
711 }
712
713 pub fn label_choices(&self) -> impl Iterator<Item = (&str, &str, LabelColor, bool)> {
714 self.label_choices.iter().map(|choice| {
715 (
716 choice.id.as_str(),
717 choice.name.as_str(),
718 choice.color,
719 self.label_ids.contains(&choice.id),
720 )
721 })
722 }
723
724 pub fn label_picker_open(&self) -> bool {
725 self.label_picker.is_some()
726 }
727
728 pub fn open_label_picker(&mut self) {
729 self.history.break_coalesce();
730 self.field = Field::Labels;
731 self.picker = None;
732 self.category_picker = None;
733 self.description.close_menu();
734 let index = self
735 .label_choices
736 .iter()
737 .position(|choice| self.label_ids.contains(&choice.id))
738 .unwrap_or_default();
739 self.label_picker = Some(ListPicker::new(index));
740 }
741
742 pub fn close_label_picker(&mut self) {
743 self.label_picker = None;
744 }
745
746 pub(crate) fn set_label_picker_layout(&mut self, area: Rect, start: usize) {
747 if let Some(picker) = &mut self.label_picker {
748 picker.set_layout(area, start);
749 }
750 }
751
752 pub fn label_picker_area(&self) -> Option<Rect> {
753 self.label_picker.as_ref().map(|picker| picker.area)
754 }
755
756 pub(crate) fn label_picker_row_at(&self, x: u16, y: u16) -> Option<usize> {
757 self.label_picker
758 .as_ref()?
759 .row_at(x, y, self.label_choices.len().saturating_add(1))
760 }
761
762 pub(crate) fn select_label_picker(&mut self, index: usize) {
763 let count = self.label_choices.len().saturating_add(1);
764 if let Some(picker) = &mut self.label_picker {
765 picker.select(index, count);
766 }
767 }
768
769 pub fn label_picker_manage_selected(&self) -> bool {
770 self.label_picker
771 .as_ref()
772 .is_some_and(|picker| picker.index == self.label_choices.len())
773 }
774
775 pub fn move_label_picker(&mut self, delta: isize) {
776 let count = self.label_choices.len().saturating_add(1);
777 if let Some(picker) = &mut self.label_picker {
778 picker.move_by(delta, count);
779 }
780 }
781
782 pub fn select_first_label(&mut self) {
783 if let Some(picker) = &mut self.label_picker {
784 picker.index = 0;
785 }
786 }
787
788 pub fn select_last_label(&mut self) {
789 if let Some(picker) = &mut self.label_picker {
790 picker.index = self.label_choices.len();
791 }
792 }
793
794 pub fn toggle_current_label(&mut self) -> Result<(), &'static str> {
795 let Some(index) = self.label_picker.as_ref().map(|picker| picker.index) else {
796 return Ok(());
797 };
798 let Some(id) = self
799 .label_choices
800 .get(index)
801 .map(|choice| choice.id.clone())
802 else {
803 return Ok(());
804 };
805 self.toggle_label(&id)
806 }
807
808 pub fn toggle_label(&mut self, id: &str) -> Result<(), &'static str> {
809 if self.label_ids.iter().any(|selected| selected == id) {
810 self.before_edit(EditKind::Atomic);
811 self.label_ids.retain(|selected| selected != id);
812 return Ok(());
813 }
814 if self.label_ids.len() >= MAX_LABELS_PER_TASK {
815 return Err("This task already has the maximum number of labels");
816 }
817 if !self.label_choices.iter().any(|choice| choice.id == id) {
818 return Ok(());
819 }
820 self.before_edit(EditKind::Atomic);
821 self.label_ids.push(id.to_string());
822 self.canonicalize_label_ids();
823 Ok(())
824 }
825
826 pub fn clear_labels(&mut self) {
827 if self.label_ids.is_empty() {
828 return;
829 }
830 self.before_edit(EditKind::Atomic);
831 self.label_ids.clear();
832 }
833
834 fn canonicalize_label_ids(&mut self) {
835 self.label_ids = self
836 .label_choices
837 .iter()
838 .filter(|choice| self.label_ids.contains(&choice.id))
839 .map(|choice| choice.id.clone())
840 .collect();
841 }
842
843 pub fn open_image_preview(&mut self) -> Option<String> {
846 let Some(path) = self.description.selected_image() else {
848 self.preview = false;
849 return Some("No image to preview".into());
850 };
851 self.preview = true;
852 if crate::image::is_gif(&path) {
853 if matches!(&self.gif, Some((p, _)) if p == &path) {
855 return None;
856 }
857 if self
858 .gif_pending
859 .as_ref()
860 .is_none_or(|pending| pending.path() != path)
861 {
862 self.gif = None;
863 self.gif_pending = Some(GifLoad::start(path));
864 }
865 } else {
866 if !matches!(&self.gif, Some((p, _)) if p == &path) {
868 self.gif = None;
869 }
870 self.gif_pending = None;
871 }
872 None
873 }
874
875 pub fn close_image_preview(&mut self) {
876 self.preview = false;
877 }
879
880 pub fn gif_playing(&self) -> bool {
881 self.preview
882 && (self.gif_pending.is_some()
883 || (!crate::theme::reduced_motion()
884 && self
885 .gif
886 .as_ref()
887 .is_some_and(|(_, g)| g.is_animated() && !g.is_paused())))
888 }
889
890 pub fn tick_gif(&mut self) -> bool {
892 if let Some(result) = self.gif_pending.as_ref().and_then(GifLoad::poll) {
893 let path = self
894 .gif_pending
895 .take()
896 .expect("a polled GIF load is still pending")
897 .path()
898 .to_path_buf();
899 match result {
900 Ok(gif) => self.gif = Some((path, gif)),
901 Err(error) => {
902 self.gif = None;
903 self.error = Some(error);
904 }
905 }
906 return true;
907 }
908 if crate::theme::reduced_motion() {
909 return false;
910 }
911 if let Some((_, gif)) = &mut self.gif {
912 return gif.tick();
913 }
914 false
915 }
916
917 pub fn preview_click(&mut self) {
920 if crate::theme::reduced_motion() {
921 return;
922 }
923 if let Some((_, gif)) = &mut self.gif {
924 gif.toggle_pause();
925 }
926 }
927
928 pub fn is_edit(&self) -> bool {
929 self.editing.is_some()
930 }
931
932 pub fn is_dirty(&self) -> bool {
933 self.content() != self.initial
934 }
935
936 fn content(&self) -> TaskDraft {
937 TaskDraft {
938 title: self.title.value(),
939 category_id: self.category_id.clone(),
940 label_ids: self.label_ids.clone(),
941 due: self.due.value(),
942 importance: self.importance,
943 description: self.description.value(),
944 }
945 }
946
947 pub fn title_text(&self) -> &'static str {
948 if self.is_edit() {
949 "Edit task"
950 } else {
951 "New task"
952 }
953 }
954
955 fn snap(&self) -> TaskSnap {
956 TaskSnap {
957 title: self.title.clone(),
958 category_id: self.category_id.clone(),
959 label_ids: self.label_ids.clone(),
960 due: self.due.clone(),
961 importance: self.importance,
962 description: self.description.clone(),
963 field: self.field,
964 }
965 }
966
967 fn restore(&mut self, s: TaskSnap) {
968 self.title = s.title;
969 self.category_id = s.category_id;
970 self.label_ids = s.label_ids;
971 self.due = s.due;
972 self.importance = s.importance;
973 self.description = s.description;
974 self.field = s.field;
975 self.error = None;
976 self.picker = None;
978 self.category_picker = None;
979 self.label_picker = None;
980 self.preview = false;
981 self.gif_pending = None;
982 self.description.close_menu();
983 }
984
985 pub fn before_edit(&mut self, kind: EditKind) {
987 let Self {
988 title,
989 category_id,
990 label_ids,
991 due,
992 importance,
993 description,
994 field,
995 history,
996 ..
997 } = self;
998 history.before_edit_with(kind, || TaskSnap {
999 title: title.clone(),
1000 category_id: category_id.clone(),
1001 label_ids: label_ids.clone(),
1002 due: due.clone(),
1003 importance: *importance,
1004 description: description.clone(),
1005 field: *field,
1006 });
1007 }
1008
1009 pub fn break_coalesce(&mut self) {
1010 self.history.break_coalesce();
1011 }
1012
1013 pub fn undo(&mut self) -> bool {
1014 let Some(prev) = self.history.undo(self.snap()) else {
1015 return false;
1016 };
1017 self.restore(prev);
1018 true
1019 }
1020
1021 pub fn redo(&mut self) -> bool {
1022 let Some(next) = self.history.redo(self.snap()) else {
1023 return false;
1024 };
1025 self.restore(next);
1026 true
1027 }
1028
1029 pub(crate) fn insert_temporary_image(&mut self, image: TemporaryImage) -> bool {
1030 let reference = image.path().to_string_lossy().into_owned();
1031 if !self.description.insert_block(Block::image(&reference)) {
1032 return false;
1033 }
1034 self.temporary_images.push(image);
1035 true
1036 }
1037
1038 pub fn open_due_picker(&mut self) {
1040 self.history.break_coalesce();
1041 self.field = Field::Due;
1042 self.category_picker = None;
1043 self.label_picker = None;
1044 self.picker = Some(DuePicker::new(self.due.value().trim()));
1045 }
1046
1047 pub fn take_due_picker(&mut self) {
1049 let Some(picker) = self.picker.take() else {
1050 return;
1051 };
1052 self.before_edit(EditKind::Atomic);
1053 self.due = TextInput::new(&picker.value(), 32);
1054 }
1055
1056 pub fn cycle_importance(&mut self) {
1058 let next = crate::model::next_importance(self.importance);
1059 self.set_importance(next);
1060 }
1061
1062 pub fn set_importance(&mut self, importance: u8) {
1063 let next = importance.min(crate::model::MAX_IMPORTANCE);
1064 if next == self.importance {
1065 return;
1066 }
1067 self.before_edit(EditKind::Atomic);
1068 self.importance = next;
1069 }
1070
1071 pub fn clear_due(&mut self) {
1072 if self.due.is_empty() && self.picker.is_none() {
1073 return;
1074 }
1075 self.before_edit(EditKind::Atomic);
1076 self.picker = None;
1077 self.category_picker = None;
1078 self.label_picker = None;
1079 self.due = TextInput::new("", 32);
1080 }
1081
1082 pub fn focus_next(&mut self) {
1083 self.history.break_coalesce();
1084 self.picker = None;
1085 self.category_picker = None;
1086 self.label_picker = None;
1087 self.description.close_menu();
1088 self.field = self.field.next();
1089 }
1090
1091 pub fn focus_prev(&mut self) {
1092 self.history.break_coalesce();
1093 self.picker = None;
1094 self.category_picker = None;
1095 self.label_picker = None;
1096 self.description.close_menu();
1097 self.field = self.field.prev();
1098 }
1099
1100 pub fn set_field(&mut self, field: Field) {
1103 self.history.break_coalesce();
1104 if field != Field::Due {
1105 self.picker = None;
1106 }
1107 if field != Field::Category {
1108 self.category_picker = None;
1109 }
1110 if field != Field::Labels {
1111 self.label_picker = None;
1112 }
1113 if field != Field::Description {
1114 self.description.close_menu();
1115 }
1116 self.field = field;
1117 }
1118
1119 pub fn submit(&mut self) -> Option<TaskDraft> {
1122 self.error = None;
1123
1124 let title = self.title.value().trim().to_string();
1125 if title.is_empty() {
1126 self.error = Some("A title is required".to_string());
1127 self.field = Field::Title;
1128 return None;
1129 }
1130
1131 let due_text = self.due.value().trim().to_string();
1132 if !due::is_valid(&due_text) {
1133 self.error = Some(format!("'{due_text}' is not a date mach understands"));
1134 self.field = Field::Due;
1135 return None;
1136 }
1137
1138 let (inline_due, title) = due::parse(&title);
1142 if title.is_empty() {
1143 self.error = Some("A title is required".to_string());
1144 self.field = Field::Title;
1145 return None;
1146 }
1147 if !due::is_valid(&inline_due) {
1148 self.error = Some(format!("'{inline_due}' is not a date mach understands"));
1149 self.field = Field::Title;
1150 return None;
1151 }
1152
1153 Some(TaskDraft {
1154 title,
1155 category_id: self.category_id.clone(),
1156 label_ids: self.label_ids.clone(),
1157 due: if due_text.is_empty() {
1158 inline_due
1159 } else {
1160 due_text
1161 },
1162 importance: self.importance,
1163 description: self.description.value(),
1164 })
1165 }
1166}
1167
1168impl Default for TaskForm {
1169 fn default() -> Self {
1170 Self::new()
1171 }
1172}
1173
1174#[cfg(test)]
1175mod tests {
1176 use super::*;
1177 use crate::undo::EditKind;
1178
1179 #[test]
1180 fn requires_a_title() {
1181 let mut form = TaskForm::new();
1182 assert!(form.submit().is_none());
1183 assert_eq!(form.field, Field::Title);
1184 assert!(form.error.is_some());
1185 }
1186
1187 #[test]
1188 fn rejects_an_unparsable_date() {
1189 let mut form = TaskForm::new();
1190 form.title = TextInput::new("something", MAX_TITLE_LEN);
1191 form.due = TextInput::new("next tuesday", 32);
1192 assert!(form.submit().is_none());
1193 assert_eq!(form.field, Field::Due);
1194 }
1195
1196 #[test]
1197 fn takes_a_date_typed_into_the_title() {
1198 let mut form = TaskForm::new();
1199 form.title = TextInput::new("pay rent [2030-01-02]", MAX_TITLE_LEN);
1200 let draft = form.submit().expect("valid");
1201 assert_eq!(draft.title, "pay rent");
1202 assert_eq!(draft.due, "2030-01-02");
1203 }
1204
1205 #[test]
1206 fn an_explicit_date_wins_over_the_inline_one() {
1207 let mut form = TaskForm::new();
1208 form.title = TextInput::new("pay rent [2030-01-02]", MAX_TITLE_LEN);
1209 form.due = TextInput::new("09:00", 32);
1210 let draft = form.submit().expect("valid");
1211 assert_eq!(draft.due, "09:00");
1212 }
1213
1214 #[test]
1215 fn undo_restores_title_and_description() {
1216 let mut form = TaskForm::new();
1217 form.before_edit(EditKind::Typing);
1218 form.title = TextInput::new("hello", MAX_TITLE_LEN);
1219 form.before_edit(EditKind::Atomic);
1220 form.description.insert_str("note");
1221 assert!(form.undo());
1222 assert!(form.description.is_empty() || form.description.plain_value().is_empty());
1223 assert_eq!(form.title.value(), "hello");
1224 assert!(form.undo());
1225 assert_eq!(form.title.value(), "");
1226 assert!(form.redo());
1227 assert_eq!(form.title.value(), "hello");
1228 }
1229
1230 #[test]
1231 fn undo_restores_importance() {
1232 let mut form = TaskForm::new();
1233 form.set_importance(2);
1234 assert_eq!(form.importance, 2);
1235 assert!(form.undo());
1236 assert_eq!(form.importance, 0);
1237 assert!(form.redo());
1238 assert_eq!(form.importance, 2);
1239 }
1240
1241 #[test]
1242 fn dirty_state_tracks_content_not_focus() {
1243 let mut task = TaskForm::new();
1244 task.focus_next();
1245 assert!(!task.is_dirty());
1246 task.title.insert('x');
1247 assert!(task.is_dirty());
1248
1249 let mut category = CategoryForm::new();
1250 category.set_description_focus(true);
1251 assert!(!category.is_dirty());
1252 category.name.insert('x');
1253 assert!(category.is_dirty());
1254 }
1255
1256 #[test]
1257 fn category_submit_exposes_a_shared_name_policy_hook() {
1258 let mut form = CategoryForm::new();
1259 form.name.insert_str("Work");
1260 assert!(
1261 form.submit_with(|name, _| {
1262 (name != "Work")
1263 .then_some(())
1264 .ok_or_else(|| "A category with that name already exists".to_string())
1265 })
1266 .is_none()
1267 );
1268 assert_eq!(
1269 form.error.as_deref(),
1270 Some("A category with that name already exists")
1271 );
1272 }
1273
1274 #[test]
1275 fn opening_a_gif_never_decodes_on_the_input_path() {
1276 let path = std::env::temp_dir().join(format!("mach-async-{}.gif", std::process::id()));
1277 std::fs::write(&path, b"not a real gif").unwrap();
1278 let mut task = Task::new("gif", 0, None, "");
1279 task.description = vec![Block::Image {
1280 attachment_id: path.display().to_string(),
1281 }];
1282 let mut form = TaskForm::edit(&task);
1283
1284 assert!(form.open_image_preview().is_none());
1285 assert!(form.gif.is_none());
1286 assert!(form.gif_pending.is_some());
1287 }
1288
1289 #[test]
1290 fn category_picker_includes_uncategorized_and_commits_to_the_draft() {
1291 let categories = [
1292 crate::model::Category::all_tasks(),
1293 crate::model::Category {
1294 id: "work".into(),
1295 name: "Work".into(),
1296 description: String::new(),
1297 },
1298 ];
1299 let mut form = TaskForm::new();
1300 form.set_categories(&categories, Some("work"));
1301
1302 assert_eq!(form.category_id(), Some("work"));
1303 assert_eq!(form.category_label(), "Work");
1304 assert!(!form.is_dirty());
1305
1306 form.open_category_picker();
1307 form.select_category_picker(0);
1308 form.commit_category_picker();
1309 assert_eq!(form.category_id(), None);
1310 assert_eq!(form.category_label(), "Uncategorized");
1311 assert!(form.is_dirty());
1312
1313 form.title.insert_str("portable task");
1314 assert_eq!(form.submit().expect("valid draft").category_id, None);
1315 assert!(form.undo());
1316 assert_eq!(form.category_id(), Some("work"));
1317 }
1318
1319 #[test]
1320 fn editing_a_task_keeps_its_category_in_the_dirty_baseline() {
1321 let mut task = Task::new("move me", 0, Some("work".into()), "");
1322 task.id = "task".into();
1323 let categories = [crate::model::Category {
1324 id: "work".into(),
1325 name: "Work".into(),
1326 description: String::new(),
1327 }];
1328 let mut form = TaskForm::edit(&task);
1329 form.set_categories(&categories, task.category_id.as_deref());
1330
1331 assert_eq!(form.category_id(), Some("work"));
1332 assert!(!form.is_dirty());
1333 assert_eq!(
1334 form.submit().expect("valid draft").category_id.as_deref(),
1335 Some("work")
1336 );
1337 }
1338}