Skip to main content

mach/
form.rs

1//! The task dialog — title, description, due date and subtasks — used for
2//! both creating and editing a task.
3
4use 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    /// Tab order follows the layout: title, metadata, then body.
27    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/// Editable content of the category dialog (for undo).
49#[derive(Debug, Clone, PartialEq, Eq)]
50struct CategorySnap {
51    name: TextInput,
52    description: BodyEditor,
53    on_description: bool,
54}
55
56/// The category dialog: a name and a note about what it is for, both
57/// plain text.
58pub struct CategoryForm {
59    pub name: TextInput,
60    pub description: BodyEditor,
61    pub on_description: bool,
62    pub error: Option<String>,
63    /// The category being edited; `None` when creating one.
64    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    /// Focus a particular field while preserving undo coalescing boundaries.
112    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    /// Snapshot before a content edit.
140    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    /// `(name, description)` once there is a name to save.
176    pub fn submit(&mut self) -> Option<(String, String)> {
177        self.submit_with(|_, _| Ok(()))
178    }
179
180    /// Validate and submit through a caller-provided uniqueness/policy hook.
181    /// The hook receives the normalized name and the edited category id.
182    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/// Where each field's box landed in the last frame, so a click can find
209/// the field under the pointer.
210#[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/// The values a submitted form hands back to the app.
249#[derive(Debug, Clone, Default, PartialEq)]
250pub struct TaskDraft {
251    pub title: String,
252    /// Real category UUID, or `None` for Uncategorized.
253    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/// Editable content of the task dialog (for undo). UI chrome is excluded.
269#[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    /// The task being edited; `None` when creating a new one.
304    pub editing: Option<String>,
305    /// Filled in while drawing; used to hit-test clicks.
306    pub areas: FieldAreas,
307    /// Whether the description's image is shown full size.
308    pub preview: bool,
309    /// Decoded GIF for preview, keyed by path (kept after close for fast reopen).
310    pub gif: Option<(std::path::PathBuf, GifPlayback)>,
311    /// GIF decode in progress; polled by the normal animation tick.
312    pub gif_pending: Option<GifLoad>,
313    /// The calendar, while a due date is being picked.
314    pub picker: Option<DuePicker>,
315    /// Last body click (line index), for double-click to open a picture.
316    pub last_body_click: Option<(Instant, usize)>,
317    /// Whether the `/` menu was open last frame — used to re-emit images
318    /// after the menu is dismissed (see `ui::draw_body`).
319    pub menu_was_open: bool,
320    /// Body scroll after last paint — scroll changes need the same protocol
321    /// drop as menu close so pictures that shrink/move do not ghost.
322    pub body_scroll: usize,
323    /// Screen rect of the open body `/` dropdown (for mouse hit-testing).
324    pub body_menu_area: Option<Rect>,
325    /// Where each body picture was drawn last frame: `(line index, screen rect)`.
326    /// Clicks only select a picture when they land inside this rect — not the
327    /// full-width letterbox gutter.
328    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    /// Whether `(x, y)` sits on the drawn picture for body line `line`.
380    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    /// Install the real categories available to this form and select the
396    /// task's starting category. `All tasks` is a view, not a destination;
397    /// Uncategorized is always the first choice.
398    ///
399    /// Call this once when opening the form, before the user edits it. The
400    /// selected value becomes part of the dirty-state baseline.
401    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    /// Select the previous/next category, wrapping at either end.
437    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    /// Open the full-size image viewer. GIF frames decode asynchronously;
464    /// the still-image cache can paint a placeholder in the meantime.
465    pub fn open_image_preview(&mut self) -> Option<String> {
466        // Only the image under the cursor — never fall back to "first in body".
467        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            // Keep a decoded GIF across close/reopen while this form is open.
474            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            // Different still — drop any previous GIF cache.
488            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        // Keep `gif` so reopening the same animation is instant.
499    }
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    /// Advance GIF animation; returns true when the frame changed.
512    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    /// Click while preview is open: pause/resume an animated GIF.
538    /// Static images ignore the click (Esc still closes).
539    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        // Overlays are not part of content history.
594        self.picker = None;
595        self.preview = false;
596        self.gif_pending = None;
597        self.body.close_menu();
598    }
599
600    /// Snapshot before a content edit (call before mutating).
601    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    /// Opens the calendar on whatever the field already reads.
643    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    /// Writes the picked day back into the field.
650    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    /// Steps importance up, wrapping back to none after three.
660    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    /// Move focus; dismiss the calendar and slash menu when leaving
698    /// the fields that own them.
699    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    /// Validates the form. On success returns the draft; on failure sets
711    /// `error` and focuses the offending field.
712    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        // A `[date]` typed into the title still works, and fills the due
730        // field when it was left empty. It gets the same check the Due
731        // field does — otherwise it is a way around it.
732        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}