1use std::sync::mpsc::{self, Receiver, TryRecvError};
4use std::time::{Duration, Instant};
5
6use ratatui::layout::Rect;
7use ratatui::widgets::{ListState, TableState};
8use unicode_segmentation::UnicodeSegmentation;
9
10use crate::due;
11use crate::form::{CategoryForm, TaskDraft, TaskForm};
12use crate::image::ImageStore;
13use crate::model::{
14 ALL_CATEGORY, Category, MAX_CATEGORY_COUNT, MAX_CATEGORY_NAME_LEN, MAX_TASK_COUNT,
15 MAX_TITLE_LEN, Task, caseless_key,
16};
17use crate::settings::Settings;
18use crate::store::{
19 Attachment, CategoryPatch, RelativePosition, Store, StoreData, StoreError, TaskPatch,
20};
21use crate::text_input::TextInput;
22use crate::theme::Theme;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum Focus {
26 Sidebar,
27 Tasks,
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum Mode {
32 Normal,
33 Slash,
35 Search,
37 TaskForm,
39 CategoryForm,
41 Help,
42 Settings,
43 Welcome,
44}
45
46impl Mode {
47 pub fn is_overlay(self) -> bool {
49 matches!(
50 self,
51 Mode::Help | Mode::Settings | Mode::Welcome | Mode::TaskForm | Mode::CategoryForm
52 )
53 }
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum MessageKind {
58 Info,
59 Error,
60}
61
62pub struct Message {
63 pub text: String,
64 pub kind: MessageKind,
65 pub until: Instant,
66}
67
68#[derive(Debug, Clone, PartialEq, Eq)]
72pub enum Confirm {
73 DeleteTask(String),
75 DeleteCategory(String),
77 Purge(Vec<String>),
79 DiscardTask(Option<String>),
81 DiscardCategory(Option<String>),
82 Quit,
84}
85
86const CONFIRM_WINDOW: Duration = Duration::from_millis(2000);
88
89const TYPEAHEAD_TIMEOUT: Duration = Duration::from_millis(800);
91
92#[derive(Debug, Default, Clone, Copy)]
94pub struct Areas {
95 pub sidebar: Rect,
96 pub tasks: Rect,
97 pub preview: Rect,
99 pub flag_x: Option<u16>,
104 pub done_x: Option<u16>,
105 pub slash_menu: Rect,
107}
108
109pub const SETTINGS_ITEMS: [&str; 4] = ["Sort", "Theme", "Date format", "Preview"];
110
111#[derive(Debug, Clone, PartialEq, Eq)]
114pub enum TaskListRow {
115 Separator {
116 title: String,
117 },
118 Task(usize),
120}
121
122pub struct App {
123 store: Store,
124 store_revision: u64,
125 pub tasks: Vec<Task>,
126 pub categories: Vec<Category>,
127 pub settings: Settings,
128 pub focus: Focus,
129 pub mode: Mode,
130 pub cat_index: usize,
132 pub task_index: usize,
134 pub cat_state: ListState,
137 pub task_state: TableState,
138 pub view: Vec<usize>,
140 pub list_rows: Vec<TaskListRow>,
143 pub searching: bool,
144 pub search_query: String,
145 pub input: TextInput,
146 pub slash_index: usize,
148 pub form: Option<TaskForm>,
150 pub category_form: Option<CategoryForm>,
152 pub settings_index: usize,
153 pub help_scroll: usize,
155 pub message: Option<Message>,
156 pub pending: Option<(Confirm, Instant)>,
158 pub last_click: Option<(Instant, Focus, usize)>,
160 pub should_quit: bool,
161 pub areas: Areas,
162 pub images: ImageStore,
164 pub(crate) attachments: Vec<Attachment>,
165 typeahead: String,
167 typeahead_at: Option<Instant>,
168 pub dirty: bool,
170 pub data_gen: u64,
172 cat_progress: Vec<(usize, usize)>,
174 pub preview_form: Option<TaskForm>,
176 preview_task_id: Option<String>,
177 preview_gen: u64,
178 task_edit_base: Option<Task>,
182 category_edit_base: Option<Category>,
183 update_rx: Option<Receiver<Result<crate::update::CheckResult, String>>>,
185 external_poll_failed: bool,
189}
190
191impl App {
192 pub fn new(version: &str) -> Result<Self, StoreError> {
193 Self::with_store(version, Store::open_default(None)?)
194 }
195
196 pub fn with_store(version: &str, mut store: Store) -> Result<Self, StoreError> {
197 let initial = store.snapshot()?;
201 let (first_run, snapshot) = if initial.settings.last_run_version.as_deref() == Some(version)
202 {
203 (false, initial)
204 } else {
205 store.update_with_snapshot(|data| Ok(data.settings.take_first_run(version)))?
206 };
207 let StoreData {
208 revision,
209 categories: real_cats,
210 tasks,
211 settings,
212 attachments,
213 } = snapshot;
214 let mut categories = vec![Category::all_tasks()];
216 categories.extend(real_cats);
217 let mut images = ImageStore::with_root(store.images_dir().to_path_buf());
218 images.set_attachments(&attachments);
219
220 let mut app = Self {
221 store,
222 store_revision: revision,
223 tasks,
224 categories,
225 settings,
226 focus: Focus::Tasks,
227 mode: if first_run {
228 Mode::Welcome
229 } else {
230 Mode::Normal
231 },
232 cat_index: 0,
233 task_index: 0,
234 cat_state: ListState::default(),
235 task_state: TableState::default(),
236 view: Vec::new(),
237 list_rows: Vec::new(),
238 searching: false,
239 search_query: String::new(),
240 input: TextInput::default(),
241 slash_index: 0,
242 form: None,
243 category_form: None,
244 settings_index: 0,
245 help_scroll: 0,
246 message: None,
247 pending: None,
248 last_click: None,
249 should_quit: false,
250 areas: Areas::default(),
251 images,
252 attachments,
253 typeahead: String::new(),
254 typeahead_at: None,
255 dirty: true,
256 data_gen: 0,
257 cat_progress: Vec::new(),
258 preview_form: None,
259 preview_task_id: None,
260 preview_gen: 0,
261 task_edit_base: None,
262 category_edit_base: None,
263 update_rx: None,
264 external_poll_failed: false,
265 };
266 app.rebuild_view();
267 Ok(app)
268 }
269
270 pub fn poll_external_changes(&mut self) -> bool {
274 let revision = match self.store.revision() {
275 Ok(revision) => revision,
276 Err(error) => {
277 return self.report_external_poll_error(format!(
278 "Could not check for external changes: {error}"
279 ));
280 }
281 };
282 if revision == self.store_revision || self.form.is_some() || self.category_form.is_some() {
283 self.external_poll_failed = false;
284 return false;
285 }
286 match self.reload_store() {
287 Ok(()) => {
288 self.external_poll_failed = false;
289 true
290 }
291 Err(error) => self
292 .report_external_poll_error(format!("Could not reload external changes: {error}")),
293 }
294 }
295
296 fn report_external_poll_error(&mut self, message: String) -> bool {
297 if self.external_poll_failed {
298 return false;
299 }
300 self.external_poll_failed = true;
301 self.error(message);
302 true
303 }
304
305 fn reload_store(&mut self) -> Result<(), StoreError> {
306 let selected_category = self.current_category_id().to_string();
307 let selected_task = self.selected_task().map(|task| task.id.clone());
308 let snapshot = self.store.snapshot()?;
309 self.apply_snapshot(snapshot, &selected_category, selected_task.as_deref());
310 Ok(())
311 }
312
313 fn apply_snapshot(
314 &mut self,
315 snapshot: StoreData,
316 selected_category: &str,
317 selected_task: Option<&str>,
318 ) {
319 let StoreData {
320 revision,
321 categories,
322 tasks,
323 settings,
324 attachments,
325 } = snapshot;
326 self.store_revision = revision;
327 self.tasks = tasks;
328 self.settings = settings;
329 self.attachments = attachments;
330 self.images.set_attachments(&self.attachments);
331 self.categories.clear();
332 self.categories.push(Category::all_tasks());
333 self.categories.extend(categories);
334 self.cat_index = self
335 .categories
336 .iter()
337 .position(|category| category.id == selected_category)
338 .unwrap_or(0);
339 self.cat_progress.clear();
340 self.data_gen = self.data_gen.wrapping_add(1);
341 self.invalidate_preview();
342 self.rebuild_view();
343 if let Some(id) = selected_task {
344 self.select_task_by_id(id);
345 }
346 self.dirty = true;
347 }
348
349 fn update_store<R>(
352 &mut self,
353 operation: impl FnOnce(&mut StoreData) -> Result<R, StoreError>,
354 ) -> Result<R, StoreError> {
355 let selected_category = self.current_category_id().to_string();
356 let selected_task = self.selected_task().map(|task| task.id.clone());
357 let (result, snapshot) = self.store.update_with_snapshot(operation)?;
358 self.apply_snapshot(snapshot, &selected_category, selected_task.as_deref());
359 Ok(result)
360 }
361
362 fn report_store_error(&mut self, action: &str, error: StoreError) {
363 self.error(format!("{action}: {error}"));
364 }
365
366 pub fn start_update_check(&mut self) {
368 if self.update_rx.is_some() {
369 self.info("Already checking for updates…");
370 return;
371 }
372 let (tx, rx) = mpsc::channel();
373 match std::thread::Builder::new()
374 .name("mach-update-check".into())
375 .spawn(move || {
376 let _ = tx.send(crate::update::check());
377 }) {
378 Ok(_) => {
379 self.update_rx = Some(rx);
380 self.info("Checking for updates…");
381 }
382 Err(error) => self.error(format!("Could not start update check: {error}")),
383 }
384 }
385
386 pub fn poll_update_check(&mut self) -> bool {
388 let Some(rx) = &self.update_rx else {
389 return false;
390 };
391 match rx.try_recv() {
392 Ok(Ok(info)) => {
393 self.update_rx = None;
394 if info.newer {
395 self.info(format!(
396 "v{} → v{} available · run: mach update --install",
397 info.current, info.latest
398 ));
399 } else {
400 self.info(info.summary());
401 }
402 true
403 }
404 Ok(Err(err)) => {
405 self.update_rx = None;
406 self.error(err);
407 true
408 }
409 Err(TryRecvError::Empty) => false,
410 Err(TryRecvError::Disconnected) => {
411 self.update_rx = None;
412 self.error("Update check failed");
413 true
414 }
415 }
416 }
417
418 pub fn mark_dirty(&mut self) {
419 self.dirty = true;
420 }
421
422 pub fn invalidate_preview(&mut self) {
423 self.preview_form = None;
424 self.preview_task_id = None;
425 self.preview_gen = 0;
426 }
427
428 pub fn ensure_preview(&mut self) {
430 let Some((id, generation)) = self.selected_task().map(|t| (t.id.clone(), self.data_gen))
431 else {
432 self.invalidate_preview();
433 return;
434 };
435 if self.preview_task_id.as_deref() == Some(id.as_str())
436 && self.preview_gen == generation
437 && self.preview_form.is_some()
438 {
439 return;
440 }
441 let Some(task) = self.selected_task().cloned() else {
442 self.invalidate_preview();
443 return;
444 };
445 let mut form = TaskForm::edit(&task);
446 form.set_categories(&self.categories, task.category_id.as_deref());
447 form.set_image_root(self.images.root().to_path_buf());
448 form.set_attachments(&self.attachments);
449 self.preview_form = Some(form);
450 self.preview_task_id = Some(id);
451 self.preview_gen = generation;
452 }
453
454 pub fn theme(&self) -> Theme {
455 Theme::new(&self.settings.selected_color)
456 }
457
458 pub fn current_category_id(&self) -> &str {
461 self.categories
462 .get(self.cat_index)
463 .map(|c| c.id.as_str())
464 .unwrap_or(ALL_CATEGORY)
465 }
466
467 pub fn is_all_view(&self) -> bool {
468 self.current_category_id() == ALL_CATEGORY
469 }
470
471 pub fn category_name(&self, id: &str) -> Option<&str> {
472 self.categories
473 .iter()
474 .find(|c| c.id == id)
475 .map(|c| c.name.as_str())
476 }
477
478 pub fn rebuild_view(&mut self) {
484 let selected_id = self.selected_task().map(|task| task.id.clone());
485 self.dirty = true;
486 if self.cat_progress.len() != self.categories.len() {
487 self.recompute_cat_progress();
488 }
489 let cat_id = self.current_category_id();
490 let all = cat_id == ALL_CATEGORY;
491 let hide_done = self.settings.hide_done;
492 let candidates: Vec<usize> = if self.searching {
493 let q = caseless_key(&self.search_query);
494 self.tasks
495 .iter()
496 .enumerate()
497 .filter(|(_, t)| {
498 !(hide_done && t.done)
499 && (contains_ignore_case(&t.title, &q) || body_contains(t, &q))
500 })
501 .map(|(i, _)| i)
502 .collect()
503 } else {
504 self.tasks
505 .iter()
506 .enumerate()
507 .filter(|(_, t)| {
508 (all || t.category_id.as_deref() == Some(cat_id)) && !(hide_done && t.done)
509 })
510 .map(|(i, _)| i)
511 .collect()
512 };
513
514 let multi = all || self.searching;
516 self.view = if multi {
517 self.stack_by_category(&candidates)
518 } else {
519 let mut view = candidates;
520 self.sort_within(&mut view);
521 view
522 };
523 if let Some(id) = selected_id {
524 self.select_task_by_id(&id);
525 } else if self.task_index >= self.view.len() {
526 self.task_index = self.view.len().saturating_sub(1);
527 }
528 self.list_rows = self.build_list_rows(multi);
529 }
530
531 fn build_list_rows(&self, multi: bool) -> Vec<TaskListRow> {
534 if !multi {
535 return (0..self.view.len()).map(TaskListRow::Task).collect();
536 }
537 let mut rows = Vec::with_capacity(self.view.len() + self.categories.len());
538 let mut prev: Option<Option<&str>> = None;
539 for (vi, &ti) in self.view.iter().enumerate() {
540 let key = self.tasks[ti].category_id.as_deref();
541 if prev != Some(key) {
542 let title = match key {
543 Some(id) => self.category_name(id).unwrap_or("Unknown").to_string(),
544 None => "Uncategorized".to_string(),
545 };
546 rows.push(TaskListRow::Separator { title });
547 prev = Some(key);
548 }
549 rows.push(TaskListRow::Task(vi));
550 }
551 rows
552 }
553
554 pub fn selected_visual_row(&self) -> Option<usize> {
556 self.list_rows
557 .iter()
558 .position(|r| matches!(r, TaskListRow::Task(i) if *i == self.task_index))
559 }
560
561 pub fn task_at_visual_row(&self, row: usize) -> Option<usize> {
563 match self.list_rows.get(row)? {
564 TaskListRow::Task(i) => Some(*i),
565 TaskListRow::Separator { .. } => None,
566 }
567 }
568
569 fn stack_by_category(&self, candidates: &[usize]) -> Vec<usize> {
571 use std::collections::HashMap;
572 let mut buckets: HashMap<Option<&str>, Vec<usize>> = HashMap::new();
573 for &i in candidates {
574 buckets
575 .entry(self.tasks[i].category_id.as_deref())
576 .or_default()
577 .push(i);
578 }
579 let mut view = Vec::with_capacity(candidates.len());
580 for cat in self.categories.iter().filter(|c| !c.is_all()) {
581 if let Some(mut group) = buckets.remove(&Some(cat.id.as_str())) {
582 self.sort_within(&mut group);
583 view.extend(group);
584 }
585 }
586 let mut rest: Vec<usize> = buckets.into_values().flatten().collect();
588 self.sort_within(&mut rest);
589 view.extend(rest);
590 view
591 }
592
593 fn sort_within(&self, view: &mut [usize]) {
595 match self.settings.sort.as_str() {
596 "important" => view.sort_by_key(|i| std::cmp::Reverse(self.tasks[*i].importance)),
597 "done" => view.sort_by_key(|i| self.tasks[*i].done),
598 "due" => view.sort_by_cached_key(|i| {
599 let due = &self.tasks[*i].due;
600 (due.is_empty(), due::sort_key(due))
601 }),
602 _ => {} }
604 }
605
606 pub fn task_count(&self) -> usize {
607 self.view.len()
608 }
609
610 pub fn visible_task(&self, pos: usize) -> Option<&Task> {
611 self.view.get(pos).and_then(|index| self.tasks.get(*index))
612 }
613
614 pub fn selected_task(&self) -> Option<&Task> {
615 self.visible_task(self.task_index)
616 }
617
618 pub fn done_count(&self) -> usize {
619 self.view.iter().filter(|i| self.tasks[**i].done).count()
620 }
621
622 pub fn move_task_selection(&mut self, delta: isize) {
625 if self.view.is_empty() {
626 return;
627 }
628 let last = self.view.len() - 1;
629 let next = (self.task_index as isize + delta).clamp(0, last as isize) as usize;
630 self.select_task(next);
631 }
632
633 pub fn select_task(&mut self, pos: usize) {
634 if pos < self.view.len() && pos != self.task_index {
635 self.task_index = pos;
636 self.cancel_pending();
637 self.clear_typeahead();
638 self.dirty = true;
639 }
640 }
641
642 pub fn select_first_task(&mut self) {
643 self.select_task(0);
644 }
645
646 pub fn select_last_task(&mut self) {
647 self.select_task(self.view.len().saturating_sub(1));
648 }
649
650 pub fn typeahead_jump(&mut self, c: char) {
652 let now = Instant::now();
653 if self
654 .typeahead_at
655 .is_none_or(|t| now.duration_since(t) > TYPEAHEAD_TIMEOUT)
656 {
657 self.typeahead.clear();
658 }
659 let limit = match self.focus {
660 Focus::Tasks => MAX_TITLE_LEN,
661 Focus::Sidebar => MAX_CATEGORY_NAME_LEN,
662 };
663 if self.typeahead.graphemes(true).count() < limit {
664 self.typeahead.push(c);
665 }
666 self.typeahead_at = Some(now);
667
668 match self.focus {
669 Focus::Tasks => {
670 let titles = self.view.iter().map(|&i| self.tasks[i].title.as_str());
671 if let Some(pos) = crate::fuzzy::best_index(&self.typeahead, titles) {
672 self.task_index = pos;
673 self.cancel_pending();
674 }
675 }
676 Focus::Sidebar => {
677 let names = self.categories.iter().map(|c| c.name.as_str());
678 if let Some(pos) = crate::fuzzy::best_index(&self.typeahead, names)
679 && pos != self.cat_index
680 {
681 self.cat_index = pos;
682 self.cancel_pending();
683 self.on_category_changed();
684 }
685 }
686 }
687 }
688
689 pub fn move_category_selection(&mut self, delta: isize) {
690 if self.categories.is_empty() {
691 return;
692 }
693 let last = self.categories.len() - 1;
694 let next = (self.cat_index as isize + delta).clamp(0, last as isize) as usize;
695 self.select_category(next);
696 }
697
698 pub fn navigate_vertical(&mut self, delta: isize) {
700 if delta == 0 {
701 return;
702 }
703 self.cancel_pending();
704 match self.focus {
705 Focus::Tasks => {
706 if self.view.is_empty() {
707 return;
708 }
709 self.move_task_selection(delta);
710 }
711 Focus::Sidebar => {
712 self.move_category_selection(delta);
713 }
714 }
715 }
716
717 pub fn select_category(&mut self, index: usize) {
718 if index < self.categories.len() && index != self.cat_index {
719 self.cat_index = index;
720 self.cancel_pending();
721 self.clear_typeahead();
722 self.on_category_changed();
723 }
724 }
725
726 pub fn select_last_category(&mut self) {
727 self.select_category(self.categories.len().saturating_sub(1));
728 }
729
730 fn on_category_changed(&mut self) {
731 self.searching = false;
732 self.search_query.clear();
733 self.task_index = 0;
734 self.rebuild_view();
735 }
736
737 pub fn toggle_focus(&mut self) {
738 let next = match self.focus {
739 Focus::Sidebar => Focus::Tasks,
740 Focus::Tasks => Focus::Sidebar,
741 };
742 let _ = self.set_focus(next);
743 }
744
745 pub fn set_focus(&mut self, focus: Focus) -> bool {
747 if self.searching && focus == Focus::Sidebar {
748 return false;
749 }
750 if self.focus != focus {
751 self.focus = focus;
752 self.cancel_pending();
753 self.clear_typeahead();
754 self.dirty = true;
755 }
756 true
757 }
758
759 pub fn cancel_pending(&mut self) {
760 if self.pending.take().is_some() && self.message.take().is_some() {
761 self.dirty = true;
762 }
763 }
764
765 fn clear_typeahead(&mut self) {
766 self.typeahead.clear();
767 self.typeahead_at = None;
768 }
769
770 fn recompute_cat_progress(&mut self) {
773 let mut all_done = 0usize;
774 let mut all_total = 0usize;
775 let mut per: Vec<(usize, usize)> = self.categories.iter().map(|_| (0, 0)).collect();
776 for t in &self.tasks {
777 all_total += 1;
778 if t.done {
779 all_done += 1;
780 }
781 if let Some(cid) = t.category_id.as_deref()
782 && let Some(idx) = self.categories.iter().position(|c| c.id == cid)
783 {
784 per[idx].1 += 1;
785 if t.done {
786 per[idx].0 += 1;
787 }
788 }
789 }
790 for (i, cat) in self.categories.iter().enumerate() {
791 if cat.is_all() {
792 per[i] = (all_done, all_total);
793 }
794 }
795 self.cat_progress = per;
796 }
797
798 fn select_task_by_id(&mut self, id: &str) {
800 if let Some(pos) = self.view.iter().position(|i| self.tasks[*i].id == id) {
801 self.task_index = pos;
802 } else if self.task_index >= self.view.len() {
803 self.task_index = self.view.len().saturating_sub(1);
804 }
805 }
806
807 pub fn toggle_done(&mut self, pos: usize) {
808 if let Some(&i) = self.view.get(pos) {
809 let id = self.tasks[i].id.clone();
810 match self.update_store(|data| data.toggle_task_done(&id)) {
811 Ok(_) => self.select_task_by_id(&id),
812 Err(error) => self.report_store_error("Could not update task", error),
813 }
814 }
815 }
816
817 pub fn cycle_importance(&mut self, pos: usize) {
819 if let Some(&i) = self.view.get(pos) {
820 let id = self.tasks[i].id.clone();
821 match self.update_store(|data| {
822 let importance =
823 (data.task(&id)?.importance + 1) % (crate::model::MAX_IMPORTANCE + 1);
824 data.set_task_importance(&id, importance)
825 }) {
826 Ok(_) => self.select_task_by_id(&id),
827 Err(error) => self.report_store_error("Could not update task", error),
828 }
829 }
830 }
831
832 pub fn move_task_order(&mut self, delta: isize) -> bool {
836 if delta == 0 || self.settings.sort != "manual" || self.searching {
837 return false;
838 }
839 let Some(current) = self.selected_task().cloned() else {
840 return false;
841 };
842 let target_view = self.task_index as isize + delta.signum();
843 if !(0..self.view.len() as isize).contains(&target_view) {
844 return false;
845 }
846 let Some(target) = self.visible_task(target_view as usize) else {
847 return false;
848 };
849 if target.category_id != current.category_id {
850 return false;
851 }
852 let target_id = target.id.clone();
853 let id = current.id;
854 let position = if delta.is_negative() {
855 RelativePosition::Before
856 } else {
857 RelativePosition::After
858 };
859 match self.update_store(|data| data.move_task_relative(&id, &target_id, position)) {
860 Ok(_) => {
861 self.select_task_by_id(&id);
862 true
863 }
864 Err(error) => {
865 self.report_store_error("Could not reorder task", error);
866 false
867 }
868 }
869 }
870
871 pub fn open_new_task(&mut self) {
873 if self.tasks.len() >= MAX_TASK_COUNT {
874 self.error(format!(
875 "You already have {MAX_TASK_COUNT} tasks in hand. Maybe deal with them first :)"
876 ));
877 return;
878 }
879 let mut form = TaskForm::new();
880 let category = (!self.is_all_view()).then(|| self.current_category_id());
881 form.set_categories(&self.categories, category);
882 form.set_image_root(self.images.root().to_path_buf());
883 form.set_attachments(&self.attachments);
884 self.task_edit_base = None;
885 self.form = Some(form);
886 self.mode = Mode::TaskForm;
887 }
888
889 pub fn open_edit_task(&mut self) {
891 if let Some(task) = self.selected_task().cloned() {
892 let mut form = TaskForm::edit(&task);
893 form.set_categories(&self.categories, task.category_id.as_deref());
894 form.set_image_root(self.images.root().to_path_buf());
895 form.set_attachments(&self.attachments);
896 self.images.prefetch(form.body.images());
899 self.task_edit_base = Some(task);
900 self.form = Some(form);
901 self.mode = Mode::TaskForm;
902 }
903 }
904
905 pub fn close_form(&mut self) {
906 self.form = None;
907 self.task_edit_base = None;
908 self.mode = Mode::Normal;
909 self.focus = Focus::Tasks;
910 self.images.release_form_graphics();
913 self.images.clear_preview();
914 self.cancel_pending();
915 }
916
917 pub fn submit_form(&mut self) {
919 let Some(form) = &mut self.form else { return };
920 let Some(draft) = form.submit() else { return };
921 let saved = match form.editing.clone() {
922 Some(uuid) => self.update_task(&uuid, &draft),
923 None => self.create_task(&draft).is_some(),
924 };
925 if saved {
926 self.close_form();
927 }
928 }
929
930 pub fn create_task(&mut self, draft: &TaskDraft) -> Option<String> {
933 let (inline_due, title) = due::parse(draft.title.trim());
934 if title.is_empty() || self.tasks.len() >= MAX_TASK_COUNT {
935 return None;
936 }
937 let due = if draft.due.is_empty() {
938 &inline_due
939 } else {
940 &draft.due
941 };
942 let body = draft.body.clone();
943 let category_id = draft.category_id.clone();
944 let importance = draft.importance;
945 let task = match self.update_store(|data| {
946 data.create_task(title, body, due.to_string(), importance, category_id)
947 }) {
948 Ok(task) => task,
949 Err(error) => {
950 let message = error.to_string();
951 if let Some(form) = &mut self.form {
952 form.error = Some(message.clone());
953 }
954 self.report_store_error("Could not create task", error);
955 return None;
956 }
957 };
958 let id = task.id;
959 self.searching = false;
960 self.search_query.clear();
961 self.rebuild_view();
962 self.select_task_by_id(&id);
963 Some(id)
964 }
965
966 pub fn update_task(&mut self, id: &str, draft: &TaskDraft) -> bool {
967 let (inline_due, title) = due::parse(draft.title.trim());
968 if title.is_empty() {
969 return false;
970 }
971 let due = if draft.due.is_empty() {
972 &inline_due
973 } else {
974 &draft.due
975 };
976 let expected = self.task_edit_base.clone();
977 let id = id.to_string();
978 let due = due.to_string();
979 let patch = match expected.as_ref() {
980 Some(base) => TaskPatch {
981 title: (title != base.title).then_some(title),
982 body: (draft.body != base.body).then(|| draft.body.clone()),
983 due: (due != base.due).then_some(due),
984 importance: (draft.importance != base.importance).then_some(draft.importance),
985 category_id: (draft.category_id != base.category_id)
986 .then(|| draft.category_id.clone()),
987 ..TaskPatch::default()
988 },
989 None => TaskPatch {
990 title: Some(title),
991 body: Some(draft.body.clone()),
992 due: Some(due),
993 importance: Some(draft.importance),
994 category_id: Some(draft.category_id.clone()),
995 ..TaskPatch::default()
996 },
997 };
998 match self.update_store(|data| {
999 if let Some(expected) = &expected {
1000 data.edit_task_if_unchanged(expected, patch)
1001 } else {
1002 data.edit_task(&id, patch)
1003 }
1004 }) {
1005 Ok(_) => {
1006 self.select_task_by_id(&id);
1007 true
1008 }
1009 Err(error) => {
1010 let message = edit_error_message(&error);
1011 if let Some(form) = &mut self.form {
1012 form.error = Some(message);
1013 }
1014 self.report_store_error("Could not update task", error);
1015 false
1016 }
1017 }
1018 }
1019
1020 pub fn delete_task(&mut self, pos: usize) {
1021 let Some(id) = self.visible_task(pos).map(|task| task.id.clone()) else {
1022 return;
1023 };
1024 self.delete_task_by_id(&id);
1025 }
1026
1027 pub fn delete_task_by_id(&mut self, id: &str) -> bool {
1028 let id = id.to_string();
1029 if let Err(error) = self.update_store(|data| data.delete_task(&id)) {
1030 self.report_store_error("Could not delete task", error);
1031 return false;
1032 }
1033 self.cancel_pending();
1034 true
1035 }
1036
1037 pub fn purge(&mut self) -> usize {
1040 let ids = self.purge_candidate_ids();
1041 self.purge_ids(&ids)
1042 }
1043
1044 pub fn purge_candidate_ids(&self) -> Vec<String> {
1046 let everywhere = self.is_all_view();
1047 let category = self.current_category_id();
1048 self.tasks
1049 .iter()
1050 .filter(|task| {
1051 task.done && (everywhere || task.category_id.as_deref() == Some(category))
1052 })
1053 .map(|task| task.id.clone())
1054 .collect()
1055 }
1056
1057 pub fn purge_ids(&mut self, ids: &[String]) -> usize {
1059 let ids = ids.to_vec();
1060 match self.update_store(|data| data.purge_completed_ids(&ids)) {
1061 Ok(removed) => {
1062 self.cancel_pending();
1063 removed.len()
1064 }
1065 Err(error) => {
1066 self.report_store_error("Could not purge completed tasks", error);
1067 0
1068 }
1069 }
1070 }
1071
1072 pub fn toggle_hide_done(&mut self) -> Option<bool> {
1074 match self.update_store(|data| {
1075 data.update_settings(|settings| settings.hide_done = !settings.hide_done)
1076 }) {
1077 Ok(settings) => Some(settings.hide_done),
1078 Err(error) => {
1079 self.report_store_error("Could not update settings", error);
1080 None
1081 }
1082 }
1083 }
1084
1085 pub fn open_new_category(&mut self) {
1089 let real = self.categories.iter().filter(|c| !c.is_all()).count();
1091 if real >= MAX_CATEGORY_COUNT {
1092 self.error(format!("At most {MAX_CATEGORY_COUNT} categories"));
1093 return;
1094 }
1095 self.category_edit_base = None;
1096 self.category_form = Some(CategoryForm::new());
1097 self.mode = Mode::CategoryForm;
1098 }
1099
1100 pub fn open_edit_category(&mut self) {
1103 if self.is_all_view() {
1104 return;
1105 }
1106 if let Some(category) = self.categories.get(self.cat_index).cloned() {
1107 self.category_form = Some(CategoryForm::edit(&category));
1108 self.category_edit_base = Some(category);
1109 self.mode = Mode::CategoryForm;
1110 }
1111 }
1112
1113 pub fn close_category_form(&mut self) {
1114 self.category_form = None;
1115 self.category_edit_base = None;
1116 self.mode = Mode::Normal;
1117 self.cancel_pending();
1118 }
1119
1120 pub fn submit_category_form(&mut self) {
1121 let existing: Vec<(String, String)> = self
1122 .categories
1123 .iter()
1124 .filter(|category| !category.is_all())
1125 .map(|category| (category.id.clone(), category.name.clone()))
1126 .collect();
1127 let Some(form) = &mut self.category_form else {
1128 return;
1129 };
1130 let Some((name, description)) = form.submit_with(|name, editing| {
1131 let duplicate = existing.iter().any(|(id, existing_name)| {
1132 Some(id.as_str()) != editing
1133 && caseless_key(existing_name.trim()) == caseless_key(name.trim())
1134 });
1135 if duplicate {
1136 Err("A category with that name already exists".to_string())
1137 } else {
1138 Ok(())
1139 }
1140 }) else {
1141 return;
1142 };
1143 let name = truncate_chars(&name, MAX_CATEGORY_NAME_LEN);
1144 let editing = form.editing.clone();
1145 let expected = self.category_edit_base.clone();
1146 let saved = match editing {
1147 Some(id) => {
1148 let patch = match expected.as_ref() {
1149 Some(base) => CategoryPatch {
1150 name: (name != base.name).then_some(name),
1151 description: (description != base.description).then_some(description),
1152 },
1153 None => CategoryPatch {
1154 name: Some(name),
1155 description: Some(description),
1156 },
1157 };
1158 match self.update_store(|data| {
1159 if let Some(expected) = &expected {
1160 data.edit_category_if_unchanged(expected, patch)
1161 } else {
1162 data.edit_category(&id, patch)
1163 }
1164 }) {
1165 Ok(_) => true,
1166 Err(error) => {
1167 let message = edit_error_message(&error);
1168 if let Some(form) = &mut self.category_form {
1169 form.error = Some(message);
1170 }
1171 self.report_store_error("Could not update category", error);
1172 false
1173 }
1174 }
1175 }
1176 None => match self.update_store(|data| data.create_category(name, description)) {
1177 Ok(category) => {
1178 self.cat_index = self
1179 .categories
1180 .iter()
1181 .position(|item| item.id == category.id)
1182 .unwrap_or(0);
1183 self.on_category_changed();
1184 true
1185 }
1186 Err(error) => {
1187 let message = error.to_string();
1188 if let Some(form) = &mut self.category_form {
1189 form.error = Some(message);
1190 }
1191 self.report_store_error("Could not create category", error);
1192 false
1193 }
1194 },
1195 };
1196 if saved {
1197 self.close_category_form();
1198 }
1199 }
1200
1201 pub fn delete_category(&mut self) {
1204 if self.is_all_view() {
1205 return;
1206 }
1207 let id = self.current_category_id().to_string();
1208 let _ = self.delete_category_by_id(&id);
1209 }
1210
1211 pub fn delete_category_by_id(&mut self, id: &str) -> bool {
1212 let Some(category) = self.categories.iter().find(|category| category.id == id) else {
1213 return false;
1214 };
1215 if category.is_all() {
1216 return false;
1217 }
1218 let id = id.to_string();
1219 match self.update_store(|data| data.delete_category(&id)) {
1220 Ok(_) => {
1221 self.cancel_pending();
1222 self.cat_index = 0;
1223 self.on_category_changed();
1224 true
1225 }
1226 Err(error) => {
1227 self.report_store_error("Could not delete category", error);
1228 false
1229 }
1230 }
1231 }
1232
1233 pub fn move_category_order(&mut self, delta: isize) -> bool {
1235 if delta == 0 || self.is_all_view() || self.searching {
1236 return false;
1237 }
1238 let target_display = self.cat_index as isize + delta.signum();
1239 if !(1..self.categories.len() as isize).contains(&target_display) {
1240 return false;
1241 }
1242 let id = self.current_category_id().to_string();
1243 let target_id = self.categories[target_display as usize].id.clone();
1244 let position = if delta.is_negative() {
1245 RelativePosition::Before
1246 } else {
1247 RelativePosition::After
1248 };
1249 match self.update_store(|data| data.move_category_relative(&id, &target_id, position)) {
1250 Ok(_) => {
1251 self.cat_index = self
1252 .categories
1253 .iter()
1254 .position(|category| category.id == id)
1255 .unwrap_or(0);
1256 self.on_category_changed();
1257 true
1258 }
1259 Err(error) => {
1260 self.report_store_error("Could not reorder category", error);
1261 false
1262 }
1263 }
1264 }
1265
1266 pub fn category_progress(&self, id: &str) -> (usize, usize) {
1268 if let Some(idx) = self.categories.iter().position(|c| c.id == id)
1269 && let Some(&p) = self.cat_progress.get(idx)
1270 {
1271 return p;
1272 }
1273 (0, 0)
1274 }
1275
1276 pub fn open_slash(&mut self) {
1280 if self.searching {
1281 self.end_search();
1282 }
1283 self.mode = Mode::Slash;
1284 self.input = TextInput::new("", 128);
1285 self.slash_index = 0;
1286 }
1287
1288 pub fn start_search(&mut self, query: &str) {
1290 self.mode = Mode::Search;
1291 self.focus = Focus::Tasks;
1292 self.input = TextInput::new(query, MAX_TITLE_LEN);
1293 self.search_query = query.to_string();
1294 self.searching = true;
1295 self.task_index = 0;
1296 self.rebuild_view();
1297 }
1298
1299 pub fn update_search(&mut self) {
1300 self.search_query = self.input.value();
1301 self.searching = true;
1302 self.task_index = 0;
1303 self.rebuild_view();
1304 }
1305
1306 pub fn end_search(&mut self) {
1307 self.searching = false;
1308 self.search_query.clear();
1309 self.task_index = 0;
1310 self.mode = Mode::Normal;
1311 self.rebuild_view();
1312 }
1313
1314 pub fn clamp_slash_index(&mut self) {
1315 let n = crate::slash::matching(&self.input.value()).len();
1316 if n == 0 {
1317 self.slash_index = 0;
1318 } else {
1319 self.slash_index = self.slash_index.min(n - 1);
1320 }
1321 }
1322
1323 pub fn info(&mut self, text: impl Into<String>) {
1326 self.set_message(text.into(), MessageKind::Info, 2000);
1327 }
1328
1329 pub fn error(&mut self, text: impl Into<String>) {
1330 self.set_message(text.into(), MessageKind::Error, 2500);
1331 }
1332
1333 fn set_message(&mut self, text: String, kind: MessageKind, millis: u64) {
1334 self.set_message_until(text, kind, Instant::now() + Duration::from_millis(millis));
1335 }
1336
1337 fn set_message_until(&mut self, text: String, kind: MessageKind, until: Instant) {
1338 self.pending = None;
1342 self.message = Some(Message { text, kind, until });
1343 self.dirty = true;
1344 }
1345
1346 pub fn expire_message(&mut self) -> bool {
1348 if let Some(m) = &self.message
1349 && Instant::now() >= m.until
1350 {
1351 self.pending = None;
1352 self.message = None;
1353 self.dirty = true;
1354 return true;
1355 }
1356 false
1357 }
1358
1359 pub fn ask_confirm(&mut self, confirm: Confirm, prompt: impl Into<String>) {
1361 let until = Instant::now() + CONFIRM_WINDOW;
1362 self.set_message_until(prompt.into(), MessageKind::Info, until);
1363 self.pending = Some((confirm, until));
1364 }
1365
1366 pub fn awaiting(&self, confirm: Confirm) -> bool {
1368 matches!(&self.pending, Some((armed, until)) if *armed == confirm && Instant::now() < *until)
1369 }
1370
1371 pub fn pending_confirmation(&self) -> Option<&Confirm> {
1372 self.pending
1373 .as_ref()
1374 .filter(|(_, until)| Instant::now() < *until)
1375 .map(|(confirm, _)| confirm)
1376 }
1377
1378 pub fn cycle_setting(&mut self, index: usize, delta: isize) {
1382 use crate::settings::{DATE_FORMATS, PREVIEW_POSITIONS, SORTS, THEMES, cycle_by};
1383 if index >= SETTINGS_ITEMS.len() {
1384 return;
1385 }
1386 if let Err(error) = self.update_store(|data| {
1387 data.update_settings(|settings| match index {
1388 0 => settings.sort = cycle_by(&SORTS, &settings.sort, delta),
1389 1 => settings.selected_color = cycle_by(&THEMES, &settings.selected_color, delta),
1390 2 => settings.date_format = cycle_by(&DATE_FORMATS, &settings.date_format, delta),
1391 3 => {
1392 settings.preview_position =
1393 cycle_by(&PREVIEW_POSITIONS, &settings.preview_position, delta)
1394 }
1395 _ => {}
1396 })
1397 }) {
1398 self.report_store_error("Could not update settings", error);
1399 }
1400 }
1401
1402 pub fn setting_value(&self, index: usize) -> String {
1403 match index {
1404 0 => crate::settings::sort_label(&self.settings.sort).to_string(),
1405 1 => crate::settings::theme_label(&self.settings.selected_color),
1406 2 => self.settings.date_format.clone(),
1407 3 => {
1408 crate::settings::preview_position_label(&self.settings.preview_position).to_string()
1409 }
1410 _ => String::new(),
1411 }
1412 }
1413}
1414
1415fn contains_ignore_case(haystack: &str, folded_needle: &str) -> bool {
1418 if folded_needle.is_empty() {
1419 return true;
1420 }
1421 if haystack.is_ascii() && folded_needle.is_ascii() {
1422 return haystack
1423 .as_bytes()
1424 .windows(folded_needle.len())
1425 .any(|w| w.eq_ignore_ascii_case(folded_needle.as_bytes()));
1426 }
1427 caseless_key(haystack).contains(folded_needle)
1428}
1429
1430fn body_contains(task: &Task, query: &str) -> bool {
1432 task.body.iter().any(|block| match block {
1433 crate::model::Block::Text { text }
1434 | crate::model::Block::Todo { text, .. }
1435 | crate::model::Block::Bullet { text }
1436 | crate::model::Block::Number { text }
1437 | crate::model::Block::Link { url: text } => contains_ignore_case(text, query),
1438 crate::model::Block::Image { .. } => false,
1439 })
1440}
1441
1442fn edit_error_message(error: &StoreError) -> String {
1443 match error {
1444 StoreError::StaleEntity { .. } => {
1445 format!("{error}; close and reopen the editor to load the latest values")
1446 }
1447 _ => error.to_string(),
1448 }
1449}
1450
1451pub fn truncate_chars(s: &str, max: usize) -> String {
1452 s.graphemes(true).take(max).collect()
1453}
1454
1455#[cfg(test)]
1456mod tests {
1457 use super::*;
1458
1459 #[test]
1460 fn typeahead_buffer_is_bounded_by_the_longest_searchable_title() {
1461 let store = Store::open_in_memory_with_paths("/tmp/mach-typeahead-test")
1462 .expect("open in-memory store");
1463 let mut app = App::with_store("test", store).expect("build app");
1464 app.mode = Mode::Normal;
1465
1466 for _ in 0..(MAX_TITLE_LEN * 2) {
1467 app.typeahead_jump('x');
1468 }
1469
1470 assert!(
1471 app.typeahead.graphemes(true).count() <= MAX_TITLE_LEN,
1472 "a held key must not grow the navigation query without bound"
1473 );
1474 }
1475}