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::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    /// Tab order follows the layout: title, metadata, then description.
28    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/// Editable content of the category dialog (for undo).
52#[derive(Debug, Clone, PartialEq, Eq)]
53struct CategorySnap {
54    name: TextInput,
55    description: DescriptionEditor,
56    on_description: bool,
57}
58
59/// The category dialog: a name and a text-only description of what it is for.
60pub struct CategoryForm {
61    pub name: TextInput,
62    pub description: DescriptionEditor,
63    pub on_description: bool,
64    pub error: Option<String>,
65    /// The category being edited; `None` when creating one.
66    pub editing: Option<String>,
67    /// Full modal rectangle from the last frame, including its chrome.
68    pub form_area: Rect,
69    pub name_area: Rect,
70    pub description_area: Rect,
71    /// Screen rect of the open description `/` dropdown.
72    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    /// Focus a particular field while preserving undo coalescing boundaries.
120    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    /// Snapshot before a content edit.
148    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    /// `(name, description)` once there is a name to save.
184    pub fn submit(&mut self) -> Option<(String, String)> {
185        self.submit_with(|_, _| Ok(()))
186    }
187
188    /// Validate and submit through a caller-provided uniqueness/policy hook.
189    /// The hook receives the normalized name and the edited category id.
190    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/// Where each field's box landed in the last frame, so a click can find
217/// the field under the pointer.
218#[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/// The values a submitted form hands back to the app.
261#[derive(Debug, Clone, Default, PartialEq)]
262pub struct TaskDraft {
263    pub title: String,
264    /// Real category UUID, or `None` for Uncategorized.
265    pub category_id: Option<String>,
266    /// Stable label IDs, kept in the global label order.
267    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/// Editable content of the task dialog (for undo). UI chrome is excluded.
293#[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    /// The task being edited; `None` when creating a new one.
345    pub editing: Option<String>,
346    /// Filled in while drawing; used to hit-test clicks.
347    pub areas: FieldAreas,
348    /// Full editor rectangle from the last frame, including its chrome.
349    pub form_area: Rect,
350    /// Whether the description's image is shown full size.
351    pub preview: bool,
352    /// Decoded GIF for preview, keyed by path (kept after close for fast reopen).
353    pub gif: Option<(std::path::PathBuf, GifPlayback)>,
354    /// GIF decode in progress; polled by the normal animation tick.
355    pub gif_pending: Option<GifLoad>,
356    /// The calendar, while a due date is being picked.
357    pub picker: Option<DuePicker>,
358    /// The bounded multi-select list, while Labels is being edited.
359    pub label_picker: Option<LabelPicker>,
360    /// Last description click (line index), for double-click to open a picture.
361    pub last_description_click: Option<(Instant, usize)>,
362    /// Description scroll after last paint — scroll changes need the same protocol
363    /// drop as menu close so pictures that shrink/move do not ghost.
364    pub description_scroll: usize,
365    /// Screen rect of the open description `/` dropdown (for mouse hit-testing).
366    pub description_menu_area: Option<Rect>,
367    /// Where each description picture was drawn last frame: `(line index, screen rect)`.
368    /// Clicks only select a picture when they land inside this rect — not the
369    /// full-width letterbox gutter.
370    pub image_hits: Vec<(usize, Rect)>,
371    /// Overlay rectangles that occluded description images last frame.
372    /// Changes require graphics placements to be emitted again.
373    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    /// Whether `(x, y)` sits on the drawn picture for description line `line`.
459    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    /// Install the real categories available to this form and select the
475    /// task's starting category. `All tasks` is a view, not a destination;
476    /// Uncategorized is always the first choice.
477    ///
478    /// Call this once when opening the form, before the user edits it. The
479    /// selected value becomes part of the dirty-state baseline.
480    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    /// Select the previous/next category, wrapping at either end.
516    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    /// Install the global label vocabulary and canonicalize this task's
543    /// selected IDs into that same order. Call once when opening the form.
544    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    /// Refresh the global vocabulary without discarding this form's draft or
563    /// undo history. Deleted IDs are removed and surviving IDs follow global
564    /// label order in every snapshot.
565    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    /// Open the full-size image viewer. GIF frames decode asynchronously;
749    /// the still-image cache can paint a placeholder in the meantime.
750    pub fn open_image_preview(&mut self) -> Option<String> {
751        // Only the image under the cursor — never fall back to "first in description".
752        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            // Keep a decoded GIF across close/reopen while this form is open.
759            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            // Different still — drop any previous GIF cache.
772            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        // Keep `gif` so reopening the same animation is instant.
783    }
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    /// Advance GIF animation; returns true when the frame changed.
796    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    /// Click while preview is open: pause/resume an animated GIF.
823    /// Static images ignore the click (Esc still closes).
824    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        // Overlays are not part of content history.
882        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    /// Snapshot before a content edit (call before mutating).
890    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    /// Opens the calendar on whatever the field already reads.
943    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    /// Writes the picked day back into the field.
951    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    /// Steps importance up, wrapping back to none after three.
960    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    /// Move focus; dismiss the calendar and slash menu when leaving
1001    /// the fields that own them.
1002    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    /// Validates the form. On success returns the draft; on failure sets
1017    /// `error` and focuses the offending field.
1018    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        // A `[date]` typed into the title still works, and fills the due
1036        // field when it was left empty. It gets the same check the Due
1037        // field does — otherwise it is a way around it.
1038        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}