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}
227
228impl FieldAreas {
229 pub fn field_at(&self, x: u16, y: u16) -> Option<Field> {
230 let pos = ratatui::layout::Position { x, y };
231 if self.title.contains(pos) {
232 Some(Field::Title)
233 } else if self.category.contains(pos) {
234 Some(Field::Category)
235 } else if self.labels.contains(pos) {
236 Some(Field::Labels)
237 } else if self.due.contains(pos) {
238 Some(Field::Due)
239 } else if self.importance.contains(pos) {
240 Some(Field::Importance)
241 } else if self.description.contains(pos) {
242 Some(Field::Description)
243 } else {
244 None
245 }
246 }
247
248 pub fn rect(&self, field: Field) -> Rect {
249 match field {
250 Field::Title => self.title,
251 Field::Category => self.category,
252 Field::Labels => self.labels,
253 Field::Due => self.due,
254 Field::Importance => self.importance,
255 Field::Description => self.description,
256 }
257 }
258}
259
260#[derive(Debug, Clone, Default, PartialEq)]
262pub struct TaskDraft {
263 pub title: String,
264 pub category_id: Option<String>,
266 pub label_ids: Vec<String>,
268 pub due: String,
269 pub importance: u8,
270 pub description: Vec<Block>,
271}
272
273impl TaskDraft {
274 pub fn new(title: &str) -> Self {
275 Self {
276 title: title.to_string(),
277 ..Self::default()
278 }
279 }
280
281 pub(crate) fn resolved_title_and_due(&self) -> (String, String) {
282 let (inline_due, title) = due::parse(self.title.trim());
283 let due = if self.due.is_empty() {
284 inline_due
285 } else {
286 self.due.clone()
287 };
288 (title, due)
289 }
290}
291
292#[derive(Debug, Clone, PartialEq, Eq)]
294struct TaskSnap {
295 title: TextInput,
296 category_id: Option<String>,
297 label_ids: Vec<String>,
298 due: TextInput,
299 importance: u8,
300 description: DescriptionEditor,
301 field: Field,
302}
303
304#[derive(Debug, Clone, PartialEq, Eq)]
305struct CategoryChoice {
306 id: Option<String>,
307 name: String,
308}
309
310#[derive(Debug, Clone, PartialEq, Eq)]
311struct LabelChoice {
312 id: String,
313 name: String,
314 color: LabelColor,
315}
316
317#[derive(Debug, Clone, Copy, PartialEq, Eq)]
318pub struct LabelPicker {
319 pub index: usize,
320 area: Rect,
321 start: usize,
322}
323
324impl CategoryChoice {
325 fn uncategorized() -> Self {
326 Self {
327 id: None,
328 name: "Uncategorized".to_string(),
329 }
330 }
331}
332
333pub struct TaskForm {
334 pub title: TextInput,
335 category_id: Option<String>,
336 category_choices: Vec<CategoryChoice>,
337 label_ids: Vec<String>,
338 label_choices: Vec<LabelChoice>,
339 pub due: TextInput,
340 pub importance: u8,
341 pub description: DescriptionEditor,
342 pub field: Field,
343 pub error: Option<String>,
344 pub editing: Option<String>,
346 pub areas: FieldAreas,
348 pub form_area: Rect,
350 pub preview: bool,
352 pub gif: Option<(std::path::PathBuf, GifPlayback)>,
354 pub gif_pending: Option<GifLoad>,
356 pub picker: Option<DuePicker>,
358 pub label_picker: Option<LabelPicker>,
360 pub last_description_click: Option<(Instant, usize)>,
362 pub description_scroll: usize,
365 pub description_menu_area: Option<Rect>,
367 pub image_hits: Vec<(usize, Rect)>,
371 pub(crate) image_occlusions: Vec<Rect>,
374 pub(crate) image_layout: Vec<(std::path::PathBuf, u16, u16)>,
375 history: History<TaskSnap>,
376 initial: TaskDraft,
377 temporary_images: Vec<TemporaryImage>,
378}
379
380impl TaskForm {
381 pub fn new() -> Self {
382 Self::new_with_images(crate::image::default_images_root(), &[])
383 }
384
385 pub fn new_with_images(
386 image_root: std::path::PathBuf,
387 attachments: &[crate::store::Attachment],
388 ) -> Self {
389 Self::with_description(DescriptionEditor::new_with_images(
390 &[],
391 image_root,
392 attachments,
393 ))
394 }
395
396 fn with_description(description: DescriptionEditor) -> Self {
397 Self {
398 title: TextInput::new("", MAX_TITLE_LEN),
399 category_id: None,
400 category_choices: vec![CategoryChoice::uncategorized()],
401 label_ids: Vec::new(),
402 label_choices: Vec::new(),
403 due: TextInput::new("", 32),
404 importance: 0,
405 description,
406 field: Field::Title,
407 error: None,
408 editing: None,
409 areas: FieldAreas::default(),
410 form_area: Rect::ZERO,
411 preview: false,
412 gif: None,
413 gif_pending: None,
414 picker: None,
415 label_picker: None,
416 last_description_click: None,
417 description_scroll: 0,
418 description_menu_area: None,
419 image_hits: Vec::new(),
420 image_occlusions: Vec::new(),
421 image_layout: Vec::new(),
422 history: History::new(),
423 initial: TaskDraft::default(),
424 temporary_images: Vec::new(),
425 }
426 }
427
428 pub fn edit(task: &Task) -> Self {
429 Self::edit_with_images(task, crate::image::default_images_root(), &[])
430 }
431
432 pub fn edit_with_images(
433 task: &Task,
434 image_root: std::path::PathBuf,
435 attachments: &[crate::store::Attachment],
436 ) -> Self {
437 let description =
438 DescriptionEditor::new_with_images(&task.description, image_root, attachments);
439 let initial_description = description.value();
440 let mut form = Self::with_description(description);
441 form.title = TextInput::new(&task.title, MAX_TITLE_LEN);
442 form.category_id = task.category_id.clone();
443 form.label_ids = task.label_ids.clone();
444 form.due = TextInput::new(&task.due, 32);
445 form.importance = task.importance;
446 form.editing = Some(task.id.clone());
447 form.initial = TaskDraft {
448 title: task.title.clone(),
449 category_id: task.category_id.clone(),
450 label_ids: task.label_ids.clone(),
451 due: task.due.clone(),
452 importance: task.importance,
453 description: initial_description,
454 };
455 form
456 }
457
458 pub fn image_hit_at(&self, line: usize, x: u16, y: u16) -> bool {
460 use ratatui::layout::Position;
461 self.image_hits
462 .iter()
463 .any(|(i, r)| *i == line && r.contains(Position { x, y }))
464 }
465
466 pub fn set_image_root(&mut self, image_root: std::path::PathBuf) {
467 self.description.set_image_root(image_root);
468 }
469
470 pub fn set_attachments(&mut self, attachments: &[crate::store::Attachment]) {
471 self.description.set_attachments(attachments);
472 }
473
474 pub fn set_categories(&mut self, categories: &[Category], selected_id: Option<&str>) {
481 self.category_choices.clear();
482 self.category_choices.push(CategoryChoice::uncategorized());
483 self.category_choices
484 .extend(
485 categories
486 .iter()
487 .filter(|category| !category.is_all())
488 .map(|category| CategoryChoice {
489 id: Some(category.id.clone()),
490 name: category.name.clone(),
491 }),
492 );
493 self.category_id = selected_id
494 .filter(|id| {
495 self.category_choices
496 .iter()
497 .any(|choice| choice.id.as_deref() == Some(*id))
498 })
499 .map(str::to_string);
500 self.initial.category_id = self.category_id.clone();
501 }
502
503 pub fn category_id(&self) -> Option<&str> {
504 self.category_id.as_deref()
505 }
506
507 pub fn category_label(&self) -> &str {
508 self.category_choices
509 .iter()
510 .find(|choice| choice.id.as_deref() == self.category_id.as_deref())
511 .map(|choice| choice.name.as_str())
512 .unwrap_or("Uncategorized")
513 }
514
515 pub fn cycle_category(&mut self, delta: i32) {
517 let len = self.category_choices.len();
518 if len <= 1 {
519 return;
520 }
521 let current = self
522 .category_choices
523 .iter()
524 .position(|choice| choice.id.as_deref() == self.category_id.as_deref())
525 .unwrap_or_default();
526 let next = (current as i32 + delta).rem_euclid(len as i32) as usize;
527 if next == current {
528 return;
529 }
530 self.before_edit(EditKind::Atomic);
531 self.category_id = self.category_choices[next].id.clone();
532 }
533
534 pub fn clear_category(&mut self) {
535 if self.category_id.is_none() {
536 return;
537 }
538 self.before_edit(EditKind::Atomic);
539 self.category_id = None;
540 }
541
542 pub fn set_labels(&mut self, labels: &[Label], selected_ids: &[String]) {
545 self.label_choices = labels
546 .iter()
547 .map(|label| LabelChoice {
548 id: label.id.clone(),
549 name: label.name.clone(),
550 color: label.color,
551 })
552 .collect();
553 self.label_ids = self
554 .label_choices
555 .iter()
556 .filter(|choice| selected_ids.contains(&choice.id))
557 .map(|choice| choice.id.clone())
558 .collect();
559 self.initial.label_ids = self.label_ids.clone();
560 }
561
562 pub fn refresh_labels(&mut self, labels: &[Label]) {
566 self.label_choices = labels
567 .iter()
568 .map(|label| LabelChoice {
569 id: label.id.clone(),
570 name: label.name.clone(),
571 color: label.color,
572 })
573 .collect();
574 let order = self
575 .label_choices
576 .iter()
577 .map(|choice| choice.id.clone())
578 .collect::<Vec<_>>();
579 let canonicalize = |ids: &mut Vec<String>| {
580 *ids = order
581 .iter()
582 .filter(|id| ids.contains(id))
583 .cloned()
584 .collect();
585 };
586 canonicalize(&mut self.label_ids);
587 canonicalize(&mut self.initial.label_ids);
588 self.history
589 .for_each_mut(|snapshot| canonicalize(&mut snapshot.label_ids));
590 }
591
592 pub fn label_ids(&self) -> &[String] {
593 &self.label_ids
594 }
595
596 pub fn selected_labels(&self) -> Vec<(&str, LabelColor)> {
597 self.label_choices
598 .iter()
599 .filter(|choice| self.label_ids.contains(&choice.id))
600 .map(|choice| (choice.name.as_str(), choice.color))
601 .collect()
602 }
603
604 pub fn label_choices(&self) -> impl Iterator<Item = (&str, &str, LabelColor, bool)> {
605 self.label_choices.iter().map(|choice| {
606 (
607 choice.id.as_str(),
608 choice.name.as_str(),
609 choice.color,
610 self.label_ids.contains(&choice.id),
611 )
612 })
613 }
614
615 pub fn label_picker_open(&self) -> bool {
616 self.label_picker.is_some()
617 }
618
619 pub fn open_label_picker(&mut self) {
620 self.history.break_coalesce();
621 self.field = Field::Labels;
622 self.picker = None;
623 self.description.close_menu();
624 let index = self
625 .label_choices
626 .iter()
627 .position(|choice| self.label_ids.contains(&choice.id))
628 .unwrap_or_default();
629 self.label_picker = Some(LabelPicker {
630 index,
631 area: Rect::default(),
632 start: 0,
633 });
634 }
635
636 pub fn close_label_picker(&mut self) {
637 self.label_picker = None;
638 }
639
640 pub(crate) fn set_label_picker_layout(&mut self, area: Rect, start: usize) {
641 if let Some(picker) = &mut self.label_picker {
642 picker.area = area;
643 picker.start = start;
644 }
645 }
646
647 pub fn label_picker_area(&self) -> Option<Rect> {
648 self.label_picker.as_ref().map(|picker| picker.area)
649 }
650
651 pub(crate) fn label_picker_row_at(&self, x: u16, y: u16) -> Option<usize> {
652 let picker = self.label_picker.as_ref()?;
653 if !picker.area.contains(ratatui::layout::Position { x, y })
654 || y <= picker.area.y
655 || y >= picker.area.bottom().saturating_sub(1)
656 {
657 return None;
658 }
659 let index = picker.start + usize::from(y - picker.area.y - 1);
660 (index <= self.label_choices.len()).then_some(index)
661 }
662
663 pub(crate) fn select_label_picker(&mut self, index: usize) {
664 if let Some(picker) = &mut self.label_picker
665 && index <= self.label_choices.len()
666 {
667 picker.index = index;
668 }
669 }
670
671 pub fn label_picker_manage_selected(&self) -> bool {
672 self.label_picker
673 .as_ref()
674 .is_some_and(|picker| picker.index == self.label_choices.len())
675 }
676
677 pub fn move_label_picker(&mut self, delta: isize) {
678 let count = self.label_choices.len().saturating_add(1);
679 let Some(picker) = &mut self.label_picker else {
680 return;
681 };
682 if count > 0 {
683 picker.index = (picker.index as isize + delta).clamp(0, count as isize - 1) as usize;
684 }
685 }
686
687 pub fn select_first_label(&mut self) {
688 if let Some(picker) = &mut self.label_picker {
689 picker.index = 0;
690 }
691 }
692
693 pub fn select_last_label(&mut self) {
694 if let Some(picker) = &mut self.label_picker {
695 picker.index = self.label_choices.len();
696 }
697 }
698
699 pub fn toggle_current_label(&mut self) -> Result<(), &'static str> {
700 let Some(index) = self.label_picker.as_ref().map(|picker| picker.index) else {
701 return Ok(());
702 };
703 let Some(id) = self
704 .label_choices
705 .get(index)
706 .map(|choice| choice.id.clone())
707 else {
708 return Ok(());
709 };
710 self.toggle_label(&id)
711 }
712
713 pub fn toggle_label(&mut self, id: &str) -> Result<(), &'static str> {
714 if self.label_ids.iter().any(|selected| selected == id) {
715 self.before_edit(EditKind::Atomic);
716 self.label_ids.retain(|selected| selected != id);
717 return Ok(());
718 }
719 if self.label_ids.len() >= MAX_LABELS_PER_TASK {
720 return Err("This task already has the maximum number of labels");
721 }
722 if !self.label_choices.iter().any(|choice| choice.id == id) {
723 return Ok(());
724 }
725 self.before_edit(EditKind::Atomic);
726 self.label_ids.push(id.to_string());
727 self.canonicalize_label_ids();
728 Ok(())
729 }
730
731 pub fn clear_labels(&mut self) {
732 if self.label_ids.is_empty() {
733 return;
734 }
735 self.before_edit(EditKind::Atomic);
736 self.label_ids.clear();
737 }
738
739 fn canonicalize_label_ids(&mut self) {
740 self.label_ids = self
741 .label_choices
742 .iter()
743 .filter(|choice| self.label_ids.contains(&choice.id))
744 .map(|choice| choice.id.clone())
745 .collect();
746 }
747
748 pub fn open_image_preview(&mut self) -> Option<String> {
751 let Some(path) = self.description.selected_image() else {
753 self.preview = false;
754 return Some("No image to preview".into());
755 };
756 self.preview = true;
757 if crate::image::is_gif(&path) {
758 if matches!(&self.gif, Some((p, _)) if p == &path) {
760 return None;
761 }
762 if self
763 .gif_pending
764 .as_ref()
765 .is_none_or(|pending| pending.path() != path)
766 {
767 self.gif = None;
768 self.gif_pending = Some(GifLoad::start(path));
769 }
770 } else {
771 if !matches!(&self.gif, Some((p, _)) if p == &path) {
773 self.gif = None;
774 }
775 self.gif_pending = None;
776 }
777 None
778 }
779
780 pub fn close_image_preview(&mut self) {
781 self.preview = false;
782 }
784
785 pub fn gif_playing(&self) -> bool {
786 self.preview
787 && (self.gif_pending.is_some()
788 || (!crate::theme::reduced_motion()
789 && self
790 .gif
791 .as_ref()
792 .is_some_and(|(_, g)| g.is_animated() && !g.is_paused())))
793 }
794
795 pub fn tick_gif(&mut self) -> bool {
797 if let Some(result) = self.gif_pending.as_ref().and_then(GifLoad::poll) {
798 let path = self
799 .gif_pending
800 .take()
801 .expect("a polled GIF load is still pending")
802 .path()
803 .to_path_buf();
804 match result {
805 Ok(gif) => self.gif = Some((path, gif)),
806 Err(error) => {
807 self.gif = None;
808 self.error = Some(error);
809 }
810 }
811 return true;
812 }
813 if crate::theme::reduced_motion() {
814 return false;
815 }
816 if let Some((_, gif)) = &mut self.gif {
817 return gif.tick();
818 }
819 false
820 }
821
822 pub fn preview_click(&mut self) {
825 if crate::theme::reduced_motion() {
826 return;
827 }
828 if let Some((_, gif)) = &mut self.gif {
829 gif.toggle_pause();
830 }
831 }
832
833 pub fn is_edit(&self) -> bool {
834 self.editing.is_some()
835 }
836
837 pub fn is_dirty(&self) -> bool {
838 self.content() != self.initial
839 }
840
841 fn content(&self) -> TaskDraft {
842 TaskDraft {
843 title: self.title.value(),
844 category_id: self.category_id.clone(),
845 label_ids: self.label_ids.clone(),
846 due: self.due.value(),
847 importance: self.importance,
848 description: self.description.value(),
849 }
850 }
851
852 pub fn title_text(&self) -> &'static str {
853 if self.is_edit() {
854 "Edit task"
855 } else {
856 "New task"
857 }
858 }
859
860 fn snap(&self) -> TaskSnap {
861 TaskSnap {
862 title: self.title.clone(),
863 category_id: self.category_id.clone(),
864 label_ids: self.label_ids.clone(),
865 due: self.due.clone(),
866 importance: self.importance,
867 description: self.description.clone(),
868 field: self.field,
869 }
870 }
871
872 fn restore(&mut self, s: TaskSnap) {
873 self.title = s.title;
874 self.category_id = s.category_id;
875 self.label_ids = s.label_ids;
876 self.due = s.due;
877 self.importance = s.importance;
878 self.description = s.description;
879 self.field = s.field;
880 self.error = None;
881 self.picker = None;
883 self.label_picker = None;
884 self.preview = false;
885 self.gif_pending = None;
886 self.description.close_menu();
887 }
888
889 pub fn before_edit(&mut self, kind: EditKind) {
891 let Self {
892 title,
893 category_id,
894 label_ids,
895 due,
896 importance,
897 description,
898 field,
899 history,
900 ..
901 } = self;
902 history.before_edit_with(kind, || TaskSnap {
903 title: title.clone(),
904 category_id: category_id.clone(),
905 label_ids: label_ids.clone(),
906 due: due.clone(),
907 importance: *importance,
908 description: description.clone(),
909 field: *field,
910 });
911 }
912
913 pub fn break_coalesce(&mut self) {
914 self.history.break_coalesce();
915 }
916
917 pub fn undo(&mut self) -> bool {
918 let Some(prev) = self.history.undo(self.snap()) else {
919 return false;
920 };
921 self.restore(prev);
922 true
923 }
924
925 pub fn redo(&mut self) -> bool {
926 let Some(next) = self.history.redo(self.snap()) else {
927 return false;
928 };
929 self.restore(next);
930 true
931 }
932
933 pub(crate) fn insert_temporary_image(&mut self, image: TemporaryImage) -> bool {
934 let reference = image.path().to_string_lossy().into_owned();
935 if !self.description.insert_block(Block::image(&reference)) {
936 return false;
937 }
938 self.temporary_images.push(image);
939 true
940 }
941
942 pub fn open_due_picker(&mut self) {
944 self.history.break_coalesce();
945 self.field = Field::Due;
946 self.label_picker = None;
947 self.picker = Some(DuePicker::new(self.due.value().trim()));
948 }
949
950 pub fn take_due_picker(&mut self) {
952 let Some(picker) = self.picker.take() else {
953 return;
954 };
955 self.before_edit(EditKind::Atomic);
956 self.due = TextInput::new(&picker.value(), 32);
957 }
958
959 pub fn cycle_importance(&mut self) {
961 let next = crate::model::next_importance(self.importance);
962 self.set_importance(next);
963 }
964
965 pub fn set_importance(&mut self, importance: u8) {
966 let next = importance.min(crate::model::MAX_IMPORTANCE);
967 if next == self.importance {
968 return;
969 }
970 self.before_edit(EditKind::Atomic);
971 self.importance = next;
972 }
973
974 pub fn clear_due(&mut self) {
975 if self.due.is_empty() && self.picker.is_none() {
976 return;
977 }
978 self.before_edit(EditKind::Atomic);
979 self.picker = None;
980 self.label_picker = None;
981 self.due = TextInput::new("", 32);
982 }
983
984 pub fn focus_next(&mut self) {
985 self.history.break_coalesce();
986 self.picker = None;
987 self.label_picker = None;
988 self.description.close_menu();
989 self.field = self.field.next();
990 }
991
992 pub fn focus_prev(&mut self) {
993 self.history.break_coalesce();
994 self.picker = None;
995 self.label_picker = None;
996 self.description.close_menu();
997 self.field = self.field.prev();
998 }
999
1000 pub fn set_field(&mut self, field: Field) {
1003 self.history.break_coalesce();
1004 if field != Field::Due {
1005 self.picker = None;
1006 }
1007 if field != Field::Labels {
1008 self.label_picker = None;
1009 }
1010 if field != Field::Description {
1011 self.description.close_menu();
1012 }
1013 self.field = field;
1014 }
1015
1016 pub fn submit(&mut self) -> Option<TaskDraft> {
1019 self.error = None;
1020
1021 let title = self.title.value().trim().to_string();
1022 if title.is_empty() {
1023 self.error = Some("A title is required".to_string());
1024 self.field = Field::Title;
1025 return None;
1026 }
1027
1028 let due_text = self.due.value().trim().to_string();
1029 if !due::is_valid(&due_text) {
1030 self.error = Some(format!("'{due_text}' is not a date mach understands"));
1031 self.field = Field::Due;
1032 return None;
1033 }
1034
1035 let (inline_due, title) = due::parse(&title);
1039 if title.is_empty() {
1040 self.error = Some("A title is required".to_string());
1041 self.field = Field::Title;
1042 return None;
1043 }
1044 if !due::is_valid(&inline_due) {
1045 self.error = Some(format!("'{inline_due}' is not a date mach understands"));
1046 self.field = Field::Title;
1047 return None;
1048 }
1049
1050 Some(TaskDraft {
1051 title,
1052 category_id: self.category_id.clone(),
1053 label_ids: self.label_ids.clone(),
1054 due: if due_text.is_empty() {
1055 inline_due
1056 } else {
1057 due_text
1058 },
1059 importance: self.importance,
1060 description: self.description.value(),
1061 })
1062 }
1063}
1064
1065impl Default for TaskForm {
1066 fn default() -> Self {
1067 Self::new()
1068 }
1069}
1070
1071#[cfg(test)]
1072mod tests {
1073 use super::*;
1074 use crate::undo::EditKind;
1075
1076 #[test]
1077 fn requires_a_title() {
1078 let mut form = TaskForm::new();
1079 assert!(form.submit().is_none());
1080 assert_eq!(form.field, Field::Title);
1081 assert!(form.error.is_some());
1082 }
1083
1084 #[test]
1085 fn rejects_an_unparsable_date() {
1086 let mut form = TaskForm::new();
1087 form.title = TextInput::new("something", MAX_TITLE_LEN);
1088 form.due = TextInput::new("next tuesday", 32);
1089 assert!(form.submit().is_none());
1090 assert_eq!(form.field, Field::Due);
1091 }
1092
1093 #[test]
1094 fn takes_a_date_typed_into_the_title() {
1095 let mut form = TaskForm::new();
1096 form.title = TextInput::new("pay rent [2030-01-02]", MAX_TITLE_LEN);
1097 let draft = form.submit().expect("valid");
1098 assert_eq!(draft.title, "pay rent");
1099 assert_eq!(draft.due, "2030-01-02");
1100 }
1101
1102 #[test]
1103 fn an_explicit_date_wins_over_the_inline_one() {
1104 let mut form = TaskForm::new();
1105 form.title = TextInput::new("pay rent [2030-01-02]", MAX_TITLE_LEN);
1106 form.due = TextInput::new("09:00", 32);
1107 let draft = form.submit().expect("valid");
1108 assert_eq!(draft.due, "09:00");
1109 }
1110
1111 #[test]
1112 fn undo_restores_title_and_description() {
1113 let mut form = TaskForm::new();
1114 form.before_edit(EditKind::Typing);
1115 form.title = TextInput::new("hello", MAX_TITLE_LEN);
1116 form.before_edit(EditKind::Atomic);
1117 form.description.insert_str("note");
1118 assert!(form.undo());
1119 assert!(form.description.is_empty() || form.description.plain_value().is_empty());
1120 assert_eq!(form.title.value(), "hello");
1121 assert!(form.undo());
1122 assert_eq!(form.title.value(), "");
1123 assert!(form.redo());
1124 assert_eq!(form.title.value(), "hello");
1125 }
1126
1127 #[test]
1128 fn undo_restores_importance() {
1129 let mut form = TaskForm::new();
1130 form.set_importance(2);
1131 assert_eq!(form.importance, 2);
1132 assert!(form.undo());
1133 assert_eq!(form.importance, 0);
1134 assert!(form.redo());
1135 assert_eq!(form.importance, 2);
1136 }
1137
1138 #[test]
1139 fn dirty_state_tracks_content_not_focus() {
1140 let mut task = TaskForm::new();
1141 task.focus_next();
1142 assert!(!task.is_dirty());
1143 task.title.insert('x');
1144 assert!(task.is_dirty());
1145
1146 let mut category = CategoryForm::new();
1147 category.set_description_focus(true);
1148 assert!(!category.is_dirty());
1149 category.name.insert('x');
1150 assert!(category.is_dirty());
1151 }
1152
1153 #[test]
1154 fn category_submit_exposes_a_shared_name_policy_hook() {
1155 let mut form = CategoryForm::new();
1156 form.name.insert_str("Work");
1157 assert!(
1158 form.submit_with(|name, _| {
1159 (name != "Work")
1160 .then_some(())
1161 .ok_or_else(|| "A category with that name already exists".to_string())
1162 })
1163 .is_none()
1164 );
1165 assert_eq!(
1166 form.error.as_deref(),
1167 Some("A category with that name already exists")
1168 );
1169 }
1170
1171 #[test]
1172 fn opening_a_gif_never_decodes_on_the_input_path() {
1173 let path = std::env::temp_dir().join(format!("mach-async-{}.gif", std::process::id()));
1174 std::fs::write(&path, b"not a real gif").unwrap();
1175 let mut task = Task::new("gif", 0, None, "");
1176 task.description = vec![Block::Image {
1177 attachment_id: path.display().to_string(),
1178 }];
1179 let mut form = TaskForm::edit(&task);
1180
1181 assert!(form.open_image_preview().is_none());
1182 assert!(form.gif.is_none());
1183 assert!(form.gif_pending.is_some());
1184 }
1185
1186 #[test]
1187 fn category_selector_includes_uncategorized_and_is_part_of_the_draft() {
1188 let categories = [
1189 crate::model::Category::all_tasks(),
1190 crate::model::Category {
1191 id: "work".into(),
1192 name: "Work".into(),
1193 description: String::new(),
1194 },
1195 ];
1196 let mut form = TaskForm::new();
1197 form.set_categories(&categories, Some("work"));
1198
1199 assert_eq!(form.category_id(), Some("work"));
1200 assert_eq!(form.category_label(), "Work");
1201 assert!(!form.is_dirty());
1202
1203 form.cycle_category(1);
1204 assert_eq!(form.category_id(), None);
1205 assert_eq!(form.category_label(), "Uncategorized");
1206 assert!(form.is_dirty());
1207
1208 form.title.insert_str("portable task");
1209 assert_eq!(form.submit().expect("valid draft").category_id, None);
1210 assert!(form.undo());
1211 assert_eq!(form.category_id(), Some("work"));
1212 }
1213
1214 #[test]
1215 fn editing_a_task_keeps_its_category_in_the_dirty_baseline() {
1216 let mut task = Task::new("move me", 0, Some("work".into()), "");
1217 task.id = "task".into();
1218 let categories = [crate::model::Category {
1219 id: "work".into(),
1220 name: "Work".into(),
1221 description: String::new(),
1222 }];
1223 let mut form = TaskForm::edit(&task);
1224 form.set_categories(&categories, task.category_id.as_deref());
1225
1226 assert_eq!(form.category_id(), Some("work"));
1227 assert!(!form.is_dirty());
1228 assert_eq!(
1229 form.submit().expect("valid draft").category_id.as_deref(),
1230 Some("work")
1231 );
1232 }
1233}