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    pub description_bottom: Rect,
227}
228
229impl FieldAreas {
230    pub fn field_at(&self, x: u16, y: u16) -> Option<Field> {
231        let pos = ratatui::layout::Position { x, y };
232        if self.title.contains(pos) {
233            Some(Field::Title)
234        } else if self.category.contains(pos) {
235            Some(Field::Category)
236        } else if self.labels.contains(pos) {
237            Some(Field::Labels)
238        } else if self.due.contains(pos) {
239            Some(Field::Due)
240        } else if self.importance.contains(pos) {
241            Some(Field::Importance)
242        } else if self.description.contains(pos) {
243            Some(Field::Description)
244        } else {
245            None
246        }
247    }
248
249    pub fn rect(&self, field: Field) -> Rect {
250        match field {
251            Field::Title => self.title,
252            Field::Category => self.category,
253            Field::Labels => self.labels,
254            Field::Due => self.due,
255            Field::Importance => self.importance,
256            Field::Description => self.description,
257        }
258    }
259}
260
261/// The values a submitted form hands back to the app.
262#[derive(Debug, Clone, Default, PartialEq)]
263pub struct TaskDraft {
264    pub title: String,
265    /// Real category UUID, or `None` for Uncategorized.
266    pub category_id: Option<String>,
267    /// Stable label IDs, kept in the global label order.
268    pub label_ids: Vec<String>,
269    pub due: String,
270    pub importance: u8,
271    pub description: Vec<Block>,
272}
273
274impl TaskDraft {
275    pub fn new(title: &str) -> Self {
276        Self {
277            title: title.to_string(),
278            ..Self::default()
279        }
280    }
281
282    pub(crate) fn resolved_title_and_due(&self) -> (String, String) {
283        let (inline_due, title) = due::parse(self.title.trim());
284        let due = if self.due.is_empty() {
285            inline_due
286        } else {
287            self.due.clone()
288        };
289        (title, due)
290    }
291}
292
293/// Editable content of the task dialog (for undo). UI chrome is excluded.
294#[derive(Debug, Clone, PartialEq, Eq)]
295struct TaskSnap {
296    title: TextInput,
297    category_id: Option<String>,
298    label_ids: Vec<String>,
299    due: TextInput,
300    importance: u8,
301    description: DescriptionEditor,
302    field: Field,
303}
304
305#[derive(Debug, Clone, PartialEq, Eq)]
306struct CategoryChoice {
307    id: Option<String>,
308    name: String,
309}
310
311#[derive(Debug, Clone, PartialEq, Eq)]
312struct LabelChoice {
313    id: String,
314    name: String,
315    color: LabelColor,
316}
317
318#[derive(Debug, Clone, Copy, PartialEq, Eq)]
319pub struct ListPicker {
320    pub index: usize,
321    area: Rect,
322    start: usize,
323}
324
325impl ListPicker {
326    fn new(index: usize) -> Self {
327        Self {
328            index,
329            area: Rect::ZERO,
330            start: 0,
331        }
332    }
333
334    fn set_layout(&mut self, area: Rect, start: usize) {
335        self.area = area;
336        self.start = start;
337    }
338
339    fn row_at(&self, x: u16, y: u16, count: usize) -> Option<usize> {
340        if !self.area.contains(ratatui::layout::Position { x, y })
341            || y <= self.area.y
342            || y >= self.area.bottom().saturating_sub(1)
343        {
344            return None;
345        }
346        let index = self.start + usize::from(y - self.area.y - 1);
347        (index < count).then_some(index)
348    }
349
350    fn select(&mut self, index: usize, count: usize) {
351        if index < count {
352            self.index = index;
353        }
354    }
355
356    fn move_by(&mut self, delta: isize, count: usize) {
357        if count > 0 {
358            self.index = (self.index as isize + delta).clamp(0, count as isize - 1) as usize;
359        }
360    }
361}
362
363impl CategoryChoice {
364    fn uncategorized() -> Self {
365        Self {
366            id: None,
367            name: "Uncategorized".to_string(),
368        }
369    }
370}
371
372pub struct TaskForm {
373    pub title: TextInput,
374    category_id: Option<String>,
375    category_choices: Vec<CategoryChoice>,
376    label_ids: Vec<String>,
377    label_choices: Vec<LabelChoice>,
378    pub due: TextInput,
379    pub importance: u8,
380    pub description: DescriptionEditor,
381    pub field: Field,
382    pub error: Option<String>,
383    /// The task being edited; `None` when creating a new one.
384    pub editing: Option<String>,
385    /// Filled in while drawing; used to hit-test clicks.
386    pub areas: FieldAreas,
387    /// Full editor rectangle from the last frame, including its chrome.
388    pub form_area: Rect,
389    /// Whether the description's image is shown full size.
390    pub preview: bool,
391    /// Decoded GIF for preview, keyed by path (kept after close for fast reopen).
392    pub gif: Option<(std::path::PathBuf, GifPlayback)>,
393    /// GIF decode in progress; polled by the normal animation tick.
394    pub gif_pending: Option<GifLoad>,
395    /// The calendar, while a due date is being picked.
396    pub picker: Option<DuePicker>,
397    /// The bounded single-select list, while Category is being edited.
398    pub category_picker: Option<ListPicker>,
399    /// The bounded multi-select list, while Labels is being edited.
400    pub label_picker: Option<ListPicker>,
401    /// Last description click (line index), for double-click to open a picture.
402    pub last_description_click: Option<(Instant, usize)>,
403    /// Description scroll after last paint — scroll changes need the same protocol
404    /// drop as menu close so pictures that shrink/move do not ghost.
405    pub description_scroll: usize,
406    /// Screen rect of the open description `/` dropdown (for mouse hit-testing).
407    pub description_menu_area: Option<Rect>,
408    /// Where each description picture was drawn last frame: `(line index, screen rect)`.
409    /// Clicks only select a picture when they land inside this rect — not the
410    /// full-width letterbox gutter.
411    pub image_hits: Vec<(usize, Rect)>,
412    /// Overlay rectangles that occluded description images last frame.
413    /// Changes require graphics placements to be emitted again.
414    pub(crate) image_occlusions: Vec<Rect>,
415    pub(crate) image_layout: Vec<(std::path::PathBuf, u16, u16)>,
416    history: History<TaskSnap>,
417    initial: TaskDraft,
418    temporary_images: Vec<TemporaryImage>,
419}
420
421impl TaskForm {
422    pub fn new() -> Self {
423        Self::new_with_images(crate::image::default_images_root(), &[])
424    }
425
426    pub fn new_with_images(
427        image_root: std::path::PathBuf,
428        attachments: &[crate::store::Attachment],
429    ) -> Self {
430        Self::with_description(DescriptionEditor::new_with_images(
431            &[],
432            image_root,
433            attachments,
434        ))
435    }
436
437    fn with_description(description: DescriptionEditor) -> Self {
438        Self {
439            title: TextInput::new("", MAX_TITLE_LEN),
440            category_id: None,
441            category_choices: vec![CategoryChoice::uncategorized()],
442            label_ids: Vec::new(),
443            label_choices: Vec::new(),
444            due: TextInput::new("", 32),
445            importance: 0,
446            description,
447            field: Field::Title,
448            error: None,
449            editing: None,
450            areas: FieldAreas::default(),
451            form_area: Rect::ZERO,
452            preview: false,
453            gif: None,
454            gif_pending: None,
455            picker: None,
456            category_picker: None,
457            label_picker: None,
458            last_description_click: None,
459            description_scroll: 0,
460            description_menu_area: None,
461            image_hits: Vec::new(),
462            image_occlusions: Vec::new(),
463            image_layout: Vec::new(),
464            history: History::new(),
465            initial: TaskDraft::default(),
466            temporary_images: Vec::new(),
467        }
468    }
469
470    pub fn edit(task: &Task) -> Self {
471        Self::edit_with_images(task, crate::image::default_images_root(), &[])
472    }
473
474    pub fn edit_with_images(
475        task: &Task,
476        image_root: std::path::PathBuf,
477        attachments: &[crate::store::Attachment],
478    ) -> Self {
479        let description =
480            DescriptionEditor::new_with_images(&task.description, image_root, attachments);
481        let initial_description = description.value();
482        let mut form = Self::with_description(description);
483        form.title = TextInput::new(&task.title, MAX_TITLE_LEN);
484        form.category_id = task.category_id.clone();
485        form.label_ids = task.label_ids.clone();
486        form.due = TextInput::new(&task.due, 32);
487        form.importance = task.importance;
488        form.editing = Some(task.id.clone());
489        form.initial = TaskDraft {
490            title: task.title.clone(),
491            category_id: task.category_id.clone(),
492            label_ids: task.label_ids.clone(),
493            due: task.due.clone(),
494            importance: task.importance,
495            description: initial_description,
496        };
497        form
498    }
499
500    /// Whether `(x, y)` sits on the drawn picture for description line `line`.
501    pub fn image_hit_at(&self, line: usize, x: u16, y: u16) -> bool {
502        use ratatui::layout::Position;
503        self.image_hits
504            .iter()
505            .any(|(i, r)| *i == line && r.contains(Position { x, y }))
506    }
507
508    pub fn set_image_root(&mut self, image_root: std::path::PathBuf) {
509        self.description.set_image_root(image_root);
510    }
511
512    pub fn set_attachments(&mut self, attachments: &[crate::store::Attachment]) {
513        self.description.set_attachments(attachments);
514    }
515
516    /// Install the real categories available to this form and select the
517    /// task's starting category. `All tasks` is a view, not a destination;
518    /// Uncategorized is always the first choice.
519    ///
520    /// Call this once when opening the form, before the user edits it. The
521    /// selected value becomes part of the dirty-state baseline.
522    pub fn set_categories(&mut self, categories: &[Category], selected_id: Option<&str>) {
523        self.category_choices.clear();
524        self.category_choices.push(CategoryChoice::uncategorized());
525        self.category_choices
526            .extend(
527                categories
528                    .iter()
529                    .filter(|category| !category.is_all())
530                    .map(|category| CategoryChoice {
531                        id: Some(category.id.clone()),
532                        name: category.name.clone(),
533                    }),
534            );
535        self.category_id = selected_id
536            .filter(|id| {
537                self.category_choices
538                    .iter()
539                    .any(|choice| choice.id.as_deref() == Some(*id))
540            })
541            .map(str::to_string);
542        self.initial.category_id = self.category_id.clone();
543    }
544
545    pub fn category_id(&self) -> Option<&str> {
546        self.category_id.as_deref()
547    }
548
549    pub fn category_label(&self) -> &str {
550        self.category_choices
551            .iter()
552            .find(|choice| choice.id.as_deref() == self.category_id.as_deref())
553            .map(|choice| choice.name.as_str())
554            .unwrap_or("Uncategorized")
555    }
556
557    pub fn category_choices(&self) -> impl Iterator<Item = (&str, bool)> {
558        self.category_choices.iter().map(|choice| {
559            (
560                choice.name.as_str(),
561                choice.id.as_deref() == self.category_id.as_deref(),
562            )
563        })
564    }
565
566    pub fn category_picker_open(&self) -> bool {
567        self.category_picker.is_some()
568    }
569
570    pub fn open_category_picker(&mut self) {
571        self.history.break_coalesce();
572        self.field = Field::Category;
573        self.picker = None;
574        self.label_picker = None;
575        self.description.close_menu();
576        let index = self
577            .category_choices
578            .iter()
579            .position(|choice| choice.id.as_deref() == self.category_id.as_deref())
580            .unwrap_or_default();
581        self.category_picker = Some(ListPicker::new(index));
582    }
583
584    pub fn close_category_picker(&mut self) {
585        self.category_picker = None;
586    }
587
588    pub fn commit_category_picker(&mut self) {
589        let Some(index) = self.category_picker.take().map(|picker| picker.index) else {
590            return;
591        };
592        let Some(category_id) = self
593            .category_choices
594            .get(index)
595            .map(|choice| choice.id.clone())
596        else {
597            return;
598        };
599        if category_id == self.category_id {
600            return;
601        }
602        self.before_edit(EditKind::Atomic);
603        self.category_id = category_id;
604    }
605
606    pub(crate) fn set_category_picker_layout(&mut self, area: Rect, start: usize) {
607        if let Some(picker) = &mut self.category_picker {
608            picker.set_layout(area, start);
609        }
610    }
611
612    pub fn category_picker_area(&self) -> Option<Rect> {
613        self.category_picker.as_ref().map(|picker| picker.area)
614    }
615
616    pub(crate) fn category_picker_row_at(&self, x: u16, y: u16) -> Option<usize> {
617        self.category_picker
618            .as_ref()?
619            .row_at(x, y, self.category_choices.len())
620    }
621
622    pub(crate) fn select_category_picker(&mut self, index: usize) {
623        if let Some(picker) = &mut self.category_picker {
624            picker.select(index, self.category_choices.len());
625        }
626    }
627
628    pub fn move_category_picker(&mut self, delta: isize) {
629        if let Some(picker) = &mut self.category_picker {
630            picker.move_by(delta, self.category_choices.len());
631        }
632    }
633
634    pub fn select_first_category(&mut self) {
635        self.select_category_picker(0);
636    }
637
638    pub fn select_last_category(&mut self) {
639        let last = self.category_choices.len().saturating_sub(1);
640        self.select_category_picker(last);
641    }
642
643    pub fn clear_category(&mut self) {
644        if self.category_id.is_none() {
645            return;
646        }
647        self.before_edit(EditKind::Atomic);
648        self.category_id = None;
649    }
650
651    /// Install the global label vocabulary and canonicalize this task's
652    /// selected IDs into that same order. Call once when opening the form.
653    pub fn set_labels(&mut self, labels: &[Label], selected_ids: &[String]) {
654        self.label_choices = labels
655            .iter()
656            .map(|label| LabelChoice {
657                id: label.id.clone(),
658                name: label.name.clone(),
659                color: label.color,
660            })
661            .collect();
662        self.label_ids = self
663            .label_choices
664            .iter()
665            .filter(|choice| selected_ids.contains(&choice.id))
666            .map(|choice| choice.id.clone())
667            .collect();
668        self.initial.label_ids = self.label_ids.clone();
669    }
670
671    /// Refresh the global vocabulary without discarding this form's draft or
672    /// undo history. Deleted IDs are removed and surviving IDs follow global
673    /// label order in every snapshot.
674    pub fn refresh_labels(&mut self, labels: &[Label]) {
675        self.label_choices = labels
676            .iter()
677            .map(|label| LabelChoice {
678                id: label.id.clone(),
679                name: label.name.clone(),
680                color: label.color,
681            })
682            .collect();
683        let order = self
684            .label_choices
685            .iter()
686            .map(|choice| choice.id.clone())
687            .collect::<Vec<_>>();
688        let canonicalize = |ids: &mut Vec<String>| {
689            *ids = order
690                .iter()
691                .filter(|id| ids.contains(id))
692                .cloned()
693                .collect();
694        };
695        canonicalize(&mut self.label_ids);
696        canonicalize(&mut self.initial.label_ids);
697        self.history
698            .for_each_mut(|snapshot| canonicalize(&mut snapshot.label_ids));
699    }
700
701    pub fn label_ids(&self) -> &[String] {
702        &self.label_ids
703    }
704
705    pub fn selected_labels(&self) -> Vec<(&str, LabelColor)> {
706        self.label_choices
707            .iter()
708            .filter(|choice| self.label_ids.contains(&choice.id))
709            .map(|choice| (choice.name.as_str(), choice.color))
710            .collect()
711    }
712
713    pub fn label_choices(&self) -> impl Iterator<Item = (&str, &str, LabelColor, bool)> {
714        self.label_choices.iter().map(|choice| {
715            (
716                choice.id.as_str(),
717                choice.name.as_str(),
718                choice.color,
719                self.label_ids.contains(&choice.id),
720            )
721        })
722    }
723
724    pub fn label_picker_open(&self) -> bool {
725        self.label_picker.is_some()
726    }
727
728    pub fn open_label_picker(&mut self) {
729        self.history.break_coalesce();
730        self.field = Field::Labels;
731        self.picker = None;
732        self.category_picker = None;
733        self.description.close_menu();
734        let index = self
735            .label_choices
736            .iter()
737            .position(|choice| self.label_ids.contains(&choice.id))
738            .unwrap_or_default();
739        self.label_picker = Some(ListPicker::new(index));
740    }
741
742    pub fn close_label_picker(&mut self) {
743        self.label_picker = None;
744    }
745
746    pub(crate) fn set_label_picker_layout(&mut self, area: Rect, start: usize) {
747        if let Some(picker) = &mut self.label_picker {
748            picker.set_layout(area, start);
749        }
750    }
751
752    pub fn label_picker_area(&self) -> Option<Rect> {
753        self.label_picker.as_ref().map(|picker| picker.area)
754    }
755
756    pub(crate) fn label_picker_row_at(&self, x: u16, y: u16) -> Option<usize> {
757        self.label_picker
758            .as_ref()?
759            .row_at(x, y, self.label_choices.len().saturating_add(1))
760    }
761
762    pub(crate) fn select_label_picker(&mut self, index: usize) {
763        let count = self.label_choices.len().saturating_add(1);
764        if let Some(picker) = &mut self.label_picker {
765            picker.select(index, count);
766        }
767    }
768
769    pub fn label_picker_manage_selected(&self) -> bool {
770        self.label_picker
771            .as_ref()
772            .is_some_and(|picker| picker.index == self.label_choices.len())
773    }
774
775    pub fn move_label_picker(&mut self, delta: isize) {
776        let count = self.label_choices.len().saturating_add(1);
777        if let Some(picker) = &mut self.label_picker {
778            picker.move_by(delta, count);
779        }
780    }
781
782    pub fn select_first_label(&mut self) {
783        if let Some(picker) = &mut self.label_picker {
784            picker.index = 0;
785        }
786    }
787
788    pub fn select_last_label(&mut self) {
789        if let Some(picker) = &mut self.label_picker {
790            picker.index = self.label_choices.len();
791        }
792    }
793
794    pub fn toggle_current_label(&mut self) -> Result<(), &'static str> {
795        let Some(index) = self.label_picker.as_ref().map(|picker| picker.index) else {
796            return Ok(());
797        };
798        let Some(id) = self
799            .label_choices
800            .get(index)
801            .map(|choice| choice.id.clone())
802        else {
803            return Ok(());
804        };
805        self.toggle_label(&id)
806    }
807
808    pub fn toggle_label(&mut self, id: &str) -> Result<(), &'static str> {
809        if self.label_ids.iter().any(|selected| selected == id) {
810            self.before_edit(EditKind::Atomic);
811            self.label_ids.retain(|selected| selected != id);
812            return Ok(());
813        }
814        if self.label_ids.len() >= MAX_LABELS_PER_TASK {
815            return Err("This task already has the maximum number of labels");
816        }
817        if !self.label_choices.iter().any(|choice| choice.id == id) {
818            return Ok(());
819        }
820        self.before_edit(EditKind::Atomic);
821        self.label_ids.push(id.to_string());
822        self.canonicalize_label_ids();
823        Ok(())
824    }
825
826    pub fn clear_labels(&mut self) {
827        if self.label_ids.is_empty() {
828            return;
829        }
830        self.before_edit(EditKind::Atomic);
831        self.label_ids.clear();
832    }
833
834    fn canonicalize_label_ids(&mut self) {
835        self.label_ids = self
836            .label_choices
837            .iter()
838            .filter(|choice| self.label_ids.contains(&choice.id))
839            .map(|choice| choice.id.clone())
840            .collect();
841    }
842
843    /// Open the full-size image viewer. GIF frames decode asynchronously;
844    /// the still-image cache can paint a placeholder in the meantime.
845    pub fn open_image_preview(&mut self) -> Option<String> {
846        // Only the image under the cursor — never fall back to "first in description".
847        let Some(path) = self.description.selected_image() else {
848            self.preview = false;
849            return Some("No image to preview".into());
850        };
851        self.preview = true;
852        if crate::image::is_gif(&path) {
853            // Keep a decoded GIF across close/reopen while this form is open.
854            if matches!(&self.gif, Some((p, _)) if p == &path) {
855                return None;
856            }
857            if self
858                .gif_pending
859                .as_ref()
860                .is_none_or(|pending| pending.path() != path)
861            {
862                self.gif = None;
863                self.gif_pending = Some(GifLoad::start(path));
864            }
865        } else {
866            // Different still — drop any previous GIF cache.
867            if !matches!(&self.gif, Some((p, _)) if p == &path) {
868                self.gif = None;
869            }
870            self.gif_pending = None;
871        }
872        None
873    }
874
875    pub fn close_image_preview(&mut self) {
876        self.preview = false;
877        // Keep `gif` so reopening the same animation is instant.
878    }
879
880    pub fn gif_playing(&self) -> bool {
881        self.preview
882            && (self.gif_pending.is_some()
883                || (!crate::theme::reduced_motion()
884                    && self
885                        .gif
886                        .as_ref()
887                        .is_some_and(|(_, g)| g.is_animated() && !g.is_paused())))
888    }
889
890    /// Advance GIF animation; returns true when the frame changed.
891    pub fn tick_gif(&mut self) -> bool {
892        if let Some(result) = self.gif_pending.as_ref().and_then(GifLoad::poll) {
893            let path = self
894                .gif_pending
895                .take()
896                .expect("a polled GIF load is still pending")
897                .path()
898                .to_path_buf();
899            match result {
900                Ok(gif) => self.gif = Some((path, gif)),
901                Err(error) => {
902                    self.gif = None;
903                    self.error = Some(error);
904                }
905            }
906            return true;
907        }
908        if crate::theme::reduced_motion() {
909            return false;
910        }
911        if let Some((_, gif)) = &mut self.gif {
912            return gif.tick();
913        }
914        false
915    }
916
917    /// Click while preview is open: pause/resume an animated GIF.
918    /// Static images ignore the click (Esc still closes).
919    pub fn preview_click(&mut self) {
920        if crate::theme::reduced_motion() {
921            return;
922        }
923        if let Some((_, gif)) = &mut self.gif {
924            gif.toggle_pause();
925        }
926    }
927
928    pub fn is_edit(&self) -> bool {
929        self.editing.is_some()
930    }
931
932    pub fn is_dirty(&self) -> bool {
933        self.content() != self.initial
934    }
935
936    fn content(&self) -> TaskDraft {
937        TaskDraft {
938            title: self.title.value(),
939            category_id: self.category_id.clone(),
940            label_ids: self.label_ids.clone(),
941            due: self.due.value(),
942            importance: self.importance,
943            description: self.description.value(),
944        }
945    }
946
947    pub fn title_text(&self) -> &'static str {
948        if self.is_edit() {
949            "Edit task"
950        } else {
951            "New task"
952        }
953    }
954
955    fn snap(&self) -> TaskSnap {
956        TaskSnap {
957            title: self.title.clone(),
958            category_id: self.category_id.clone(),
959            label_ids: self.label_ids.clone(),
960            due: self.due.clone(),
961            importance: self.importance,
962            description: self.description.clone(),
963            field: self.field,
964        }
965    }
966
967    fn restore(&mut self, s: TaskSnap) {
968        self.title = s.title;
969        self.category_id = s.category_id;
970        self.label_ids = s.label_ids;
971        self.due = s.due;
972        self.importance = s.importance;
973        self.description = s.description;
974        self.field = s.field;
975        self.error = None;
976        // Overlays are not part of content history.
977        self.picker = None;
978        self.category_picker = None;
979        self.label_picker = None;
980        self.preview = false;
981        self.gif_pending = None;
982        self.description.close_menu();
983    }
984
985    /// Snapshot before a content edit (call before mutating).
986    pub fn before_edit(&mut self, kind: EditKind) {
987        let Self {
988            title,
989            category_id,
990            label_ids,
991            due,
992            importance,
993            description,
994            field,
995            history,
996            ..
997        } = self;
998        history.before_edit_with(kind, || TaskSnap {
999            title: title.clone(),
1000            category_id: category_id.clone(),
1001            label_ids: label_ids.clone(),
1002            due: due.clone(),
1003            importance: *importance,
1004            description: description.clone(),
1005            field: *field,
1006        });
1007    }
1008
1009    pub fn break_coalesce(&mut self) {
1010        self.history.break_coalesce();
1011    }
1012
1013    pub fn undo(&mut self) -> bool {
1014        let Some(prev) = self.history.undo(self.snap()) else {
1015            return false;
1016        };
1017        self.restore(prev);
1018        true
1019    }
1020
1021    pub fn redo(&mut self) -> bool {
1022        let Some(next) = self.history.redo(self.snap()) else {
1023            return false;
1024        };
1025        self.restore(next);
1026        true
1027    }
1028
1029    pub(crate) fn insert_temporary_image(&mut self, image: TemporaryImage) -> bool {
1030        let reference = image.path().to_string_lossy().into_owned();
1031        if !self.description.insert_block(Block::image(&reference)) {
1032            return false;
1033        }
1034        self.temporary_images.push(image);
1035        true
1036    }
1037
1038    /// Opens the calendar on whatever the field already reads.
1039    pub fn open_due_picker(&mut self) {
1040        self.history.break_coalesce();
1041        self.field = Field::Due;
1042        self.category_picker = None;
1043        self.label_picker = None;
1044        self.picker = Some(DuePicker::new(self.due.value().trim()));
1045    }
1046
1047    /// Writes the picked day back into the field.
1048    pub fn take_due_picker(&mut self) {
1049        let Some(picker) = self.picker.take() else {
1050            return;
1051        };
1052        self.before_edit(EditKind::Atomic);
1053        self.due = TextInput::new(&picker.value(), 32);
1054    }
1055
1056    /// Steps importance up, wrapping back to none after three.
1057    pub fn cycle_importance(&mut self) {
1058        let next = crate::model::next_importance(self.importance);
1059        self.set_importance(next);
1060    }
1061
1062    pub fn set_importance(&mut self, importance: u8) {
1063        let next = importance.min(crate::model::MAX_IMPORTANCE);
1064        if next == self.importance {
1065            return;
1066        }
1067        self.before_edit(EditKind::Atomic);
1068        self.importance = next;
1069    }
1070
1071    pub fn clear_due(&mut self) {
1072        if self.due.is_empty() && self.picker.is_none() {
1073            return;
1074        }
1075        self.before_edit(EditKind::Atomic);
1076        self.picker = None;
1077        self.category_picker = None;
1078        self.label_picker = None;
1079        self.due = TextInput::new("", 32);
1080    }
1081
1082    pub fn focus_next(&mut self) {
1083        self.history.break_coalesce();
1084        self.picker = None;
1085        self.category_picker = None;
1086        self.label_picker = None;
1087        self.description.close_menu();
1088        self.field = self.field.next();
1089    }
1090
1091    pub fn focus_prev(&mut self) {
1092        self.history.break_coalesce();
1093        self.picker = None;
1094        self.category_picker = None;
1095        self.label_picker = None;
1096        self.description.close_menu();
1097        self.field = self.field.prev();
1098    }
1099
1100    /// Move focus; dismiss the calendar and slash menu when leaving
1101    /// the fields that own them.
1102    pub fn set_field(&mut self, field: Field) {
1103        self.history.break_coalesce();
1104        if field != Field::Due {
1105            self.picker = None;
1106        }
1107        if field != Field::Category {
1108            self.category_picker = None;
1109        }
1110        if field != Field::Labels {
1111            self.label_picker = None;
1112        }
1113        if field != Field::Description {
1114            self.description.close_menu();
1115        }
1116        self.field = field;
1117    }
1118
1119    /// Validates the form. On success returns the draft; on failure sets
1120    /// `error` and focuses the offending field.
1121    pub fn submit(&mut self) -> Option<TaskDraft> {
1122        self.error = None;
1123
1124        let title = self.title.value().trim().to_string();
1125        if title.is_empty() {
1126            self.error = Some("A title is required".to_string());
1127            self.field = Field::Title;
1128            return None;
1129        }
1130
1131        let due_text = self.due.value().trim().to_string();
1132        if !due::is_valid(&due_text) {
1133            self.error = Some(format!("'{due_text}' is not a date mach understands"));
1134            self.field = Field::Due;
1135            return None;
1136        }
1137
1138        // A `[date]` typed into the title still works, and fills the due
1139        // field when it was left empty. It gets the same check the Due
1140        // field does — otherwise it is a way around it.
1141        let (inline_due, title) = due::parse(&title);
1142        if title.is_empty() {
1143            self.error = Some("A title is required".to_string());
1144            self.field = Field::Title;
1145            return None;
1146        }
1147        if !due::is_valid(&inline_due) {
1148            self.error = Some(format!("'{inline_due}' is not a date mach understands"));
1149            self.field = Field::Title;
1150            return None;
1151        }
1152
1153        Some(TaskDraft {
1154            title,
1155            category_id: self.category_id.clone(),
1156            label_ids: self.label_ids.clone(),
1157            due: if due_text.is_empty() {
1158                inline_due
1159            } else {
1160                due_text
1161            },
1162            importance: self.importance,
1163            description: self.description.value(),
1164        })
1165    }
1166}
1167
1168impl Default for TaskForm {
1169    fn default() -> Self {
1170        Self::new()
1171    }
1172}
1173
1174#[cfg(test)]
1175mod tests {
1176    use super::*;
1177    use crate::undo::EditKind;
1178
1179    #[test]
1180    fn requires_a_title() {
1181        let mut form = TaskForm::new();
1182        assert!(form.submit().is_none());
1183        assert_eq!(form.field, Field::Title);
1184        assert!(form.error.is_some());
1185    }
1186
1187    #[test]
1188    fn rejects_an_unparsable_date() {
1189        let mut form = TaskForm::new();
1190        form.title = TextInput::new("something", MAX_TITLE_LEN);
1191        form.due = TextInput::new("next tuesday", 32);
1192        assert!(form.submit().is_none());
1193        assert_eq!(form.field, Field::Due);
1194    }
1195
1196    #[test]
1197    fn takes_a_date_typed_into_the_title() {
1198        let mut form = TaskForm::new();
1199        form.title = TextInput::new("pay rent [2030-01-02]", MAX_TITLE_LEN);
1200        let draft = form.submit().expect("valid");
1201        assert_eq!(draft.title, "pay rent");
1202        assert_eq!(draft.due, "2030-01-02");
1203    }
1204
1205    #[test]
1206    fn an_explicit_date_wins_over_the_inline_one() {
1207        let mut form = TaskForm::new();
1208        form.title = TextInput::new("pay rent [2030-01-02]", MAX_TITLE_LEN);
1209        form.due = TextInput::new("09:00", 32);
1210        let draft = form.submit().expect("valid");
1211        assert_eq!(draft.due, "09:00");
1212    }
1213
1214    #[test]
1215    fn undo_restores_title_and_description() {
1216        let mut form = TaskForm::new();
1217        form.before_edit(EditKind::Typing);
1218        form.title = TextInput::new("hello", MAX_TITLE_LEN);
1219        form.before_edit(EditKind::Atomic);
1220        form.description.insert_str("note");
1221        assert!(form.undo());
1222        assert!(form.description.is_empty() || form.description.plain_value().is_empty());
1223        assert_eq!(form.title.value(), "hello");
1224        assert!(form.undo());
1225        assert_eq!(form.title.value(), "");
1226        assert!(form.redo());
1227        assert_eq!(form.title.value(), "hello");
1228    }
1229
1230    #[test]
1231    fn undo_restores_importance() {
1232        let mut form = TaskForm::new();
1233        form.set_importance(2);
1234        assert_eq!(form.importance, 2);
1235        assert!(form.undo());
1236        assert_eq!(form.importance, 0);
1237        assert!(form.redo());
1238        assert_eq!(form.importance, 2);
1239    }
1240
1241    #[test]
1242    fn dirty_state_tracks_content_not_focus() {
1243        let mut task = TaskForm::new();
1244        task.focus_next();
1245        assert!(!task.is_dirty());
1246        task.title.insert('x');
1247        assert!(task.is_dirty());
1248
1249        let mut category = CategoryForm::new();
1250        category.set_description_focus(true);
1251        assert!(!category.is_dirty());
1252        category.name.insert('x');
1253        assert!(category.is_dirty());
1254    }
1255
1256    #[test]
1257    fn category_submit_exposes_a_shared_name_policy_hook() {
1258        let mut form = CategoryForm::new();
1259        form.name.insert_str("Work");
1260        assert!(
1261            form.submit_with(|name, _| {
1262                (name != "Work")
1263                    .then_some(())
1264                    .ok_or_else(|| "A category with that name already exists".to_string())
1265            })
1266            .is_none()
1267        );
1268        assert_eq!(
1269            form.error.as_deref(),
1270            Some("A category with that name already exists")
1271        );
1272    }
1273
1274    #[test]
1275    fn opening_a_gif_never_decodes_on_the_input_path() {
1276        let path = std::env::temp_dir().join(format!("mach-async-{}.gif", std::process::id()));
1277        std::fs::write(&path, b"not a real gif").unwrap();
1278        let mut task = Task::new("gif", 0, None, "");
1279        task.description = vec![Block::Image {
1280            attachment_id: path.display().to_string(),
1281        }];
1282        let mut form = TaskForm::edit(&task);
1283
1284        assert!(form.open_image_preview().is_none());
1285        assert!(form.gif.is_none());
1286        assert!(form.gif_pending.is_some());
1287    }
1288
1289    #[test]
1290    fn category_picker_includes_uncategorized_and_commits_to_the_draft() {
1291        let categories = [
1292            crate::model::Category::all_tasks(),
1293            crate::model::Category {
1294                id: "work".into(),
1295                name: "Work".into(),
1296                description: String::new(),
1297            },
1298        ];
1299        let mut form = TaskForm::new();
1300        form.set_categories(&categories, Some("work"));
1301
1302        assert_eq!(form.category_id(), Some("work"));
1303        assert_eq!(form.category_label(), "Work");
1304        assert!(!form.is_dirty());
1305
1306        form.open_category_picker();
1307        form.select_category_picker(0);
1308        form.commit_category_picker();
1309        assert_eq!(form.category_id(), None);
1310        assert_eq!(form.category_label(), "Uncategorized");
1311        assert!(form.is_dirty());
1312
1313        form.title.insert_str("portable task");
1314        assert_eq!(form.submit().expect("valid draft").category_id, None);
1315        assert!(form.undo());
1316        assert_eq!(form.category_id(), Some("work"));
1317    }
1318
1319    #[test]
1320    fn editing_a_task_keeps_its_category_in_the_dirty_baseline() {
1321        let mut task = Task::new("move me", 0, Some("work".into()), "");
1322        task.id = "task".into();
1323        let categories = [crate::model::Category {
1324            id: "work".into(),
1325            name: "Work".into(),
1326            description: String::new(),
1327        }];
1328        let mut form = TaskForm::edit(&task);
1329        form.set_categories(&categories, task.category_id.as_deref());
1330
1331        assert_eq!(form.category_id(), Some("work"));
1332        assert!(!form.is_dirty());
1333        assert_eq!(
1334            form.submit().expect("valid draft").category_id.as_deref(),
1335            Some("work")
1336        );
1337    }
1338}