Skip to main content

mach/
app.rs

1//! Application state and every operation the UI can trigger.
2
3use std::sync::mpsc::{self, Receiver, TryRecvError};
4use std::time::{Duration, Instant};
5
6use chrono::Utc;
7use ratatui::layout::Rect;
8use ratatui::widgets::{ListState, TableState};
9use unicode_segmentation::UnicodeSegmentation;
10
11use crate::due;
12use crate::form::{CategoryForm, TaskDraft, TaskForm};
13use crate::image::ImageStore;
14use crate::model::{
15    ALL_CATEGORY, Category, MAX_CATEGORY_COUNT, MAX_CATEGORY_NAME_LEN, MAX_TASK_COUNT,
16    MAX_TITLE_LEN, Task, caseless_key,
17};
18use crate::settings::{LaunchState, Settings};
19use crate::store::{
20    Attachment, CategoryPatch, RelativePosition, Store, StoreData, StoreError, TaskPatch,
21};
22use crate::text_input::TextInput;
23use crate::theme::Theme;
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum Focus {
27    Sidebar,
28    Tasks,
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum Mode {
33    Normal,
34    /// The `/` command palette (dropdown above the status bar).
35    Slash,
36    /// Live search after choosing Search from the palette.
37    Search,
38    /// The task dialog (new or edit).
39    TaskForm,
40    /// The category dialog (new or edit).
41    CategoryForm,
42    Help,
43    Settings,
44    Welcome,
45    WhatsNew,
46}
47
48impl Mode {
49    /// Anything drawn on top of the two panels.
50    pub fn is_overlay(self) -> bool {
51        matches!(
52            self,
53            Mode::Help
54                | Mode::Settings
55                | Mode::Welcome
56                | Mode::WhatsNew
57                | Mode::TaskForm
58                | Mode::CategoryForm
59        )
60    }
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum MessageKind {
65    Info,
66    Error,
67}
68
69pub struct Message {
70    pub text: String,
71    pub kind: MessageKind,
72    pub until: Instant,
73}
74
75/// Something destructive waiting on a second press of the same key. Only
76/// one can be armed at a time, so a half-typed delete cannot survive
77/// behind a quit prompt.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub enum Confirm {
80    /// Backspace again deletes this exact task.
81    DeleteTask(String),
82    /// Backspace again deletes this exact category; its tasks become uncategorized.
83    DeleteCategory(String),
84    /// Enter purges this exact set of completed task ids.
85    Purge(Vec<String>),
86    /// Esc again discards the current task/category draft.
87    DiscardTask(Option<String>),
88    DiscardCategory(Option<String>),
89    /// Ctrl+C again leaves mach.
90    Quit,
91}
92
93/// How long a double-press confirm stays armed.
94const CONFIRM_WINDOW: Duration = Duration::from_millis(2000);
95
96/// Idle gap after which type-to-jump starts a new query.
97const TYPEAHEAD_TIMEOUT: Duration = Duration::from_millis(800);
98
99const UPDATE_RESULT_DURATION: Duration = Duration::from_secs(10);
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102enum UpdateJobKind {
103    Automatic,
104    Install,
105}
106
107enum UpdateOutcome {
108    Checked(crate::update::CheckResult),
109    UpToDate(crate::update::CheckResult),
110    Installed(crate::update::InstallResult),
111}
112
113enum UpdateEvent {
114    DownloadProgress(crate::update::DownloadProgress),
115    Finished(Result<UpdateOutcome, String>),
116}
117
118struct UpdateJob {
119    rx: Receiver<UpdateEvent>,
120    kind: UpdateJobKind,
121}
122
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124pub(crate) enum UpdateActivity {
125    Checking,
126    Downloading(crate::update::DownloadProgress),
127}
128
129/// Rects from the last frame, used to hit-test mouse events.
130#[derive(Debug, Default, Clone, Copy)]
131pub struct Areas {
132    pub sidebar: Rect,
133    pub tasks: Rect,
134    /// Bottom-right task preview / docked editor, when the window is tall enough.
135    pub preview: Rect,
136    /// Screen columns of the flag and done markers, as the table laid
137    /// them out. `done_x` is the left edge of the `[ ]`/`[✓]` column
138    /// (see `ui::DONE_MARK_WIDTH`). The flag column is always reserved
139    /// for up to three flags.
140    pub flag_x: Option<u16>,
141    pub done_x: Option<u16>,
142    /// Open top-level command palette, including its border.
143    pub slash_menu: Rect,
144}
145
146pub const SETTINGS_ITEMS: [&str; 4] = ["Sort", "Theme", "Date format", "Preview"];
147
148/// One row of the task table: a real task, or a category section header
149/// (All Tasks / search only). Headers are not selectable.
150#[derive(Debug, Clone, PartialEq, Eq)]
151pub enum TaskListRow {
152    Separator {
153        title: String,
154    },
155    /// Index into [`App::view`].
156    Task(usize),
157}
158
159pub struct App {
160    store: Store,
161    store_revision: u64,
162    pub tasks: Vec<Task>,
163    pub categories: Vec<Category>,
164    pub settings: Settings,
165    pub focus: Focus,
166    pub mode: Mode,
167    /// Index into `categories`.
168    pub cat_index: usize,
169    /// Index into `view`.
170    pub task_index: usize,
171    /// Scroll position of the two panels. Selection is driven by the two
172    /// indices above; ratatui keeps the offsets in these.
173    pub cat_state: ListState,
174    pub task_state: TableState,
175    /// Indices into `tasks`, in display order.
176    pub view: Vec<usize>,
177    /// Table rows including category separators. Parallel to what is drawn;
178    /// selection still uses `task_index` into `view`.
179    pub list_rows: Vec<TaskListRow>,
180    pub searching: bool,
181    pub search_query: String,
182    pub input: TextInput,
183    /// Selected row in the `/` palette dropdown.
184    pub slash_index: usize,
185    /// The open task dialog, if any.
186    pub form: Option<TaskForm>,
187    /// The open category dialog, if any.
188    pub category_form: Option<CategoryForm>,
189    pub settings_index: usize,
190    /// First help content row currently visible.
191    pub help_scroll: usize,
192    pub message: Option<Message>,
193    /// Pending entity-bound destructive action and its deadline.
194    pub pending: Option<(Confirm, Instant)>,
195    /// Last click `(time, panel, row)` for double-click detection.
196    pub last_click: Option<(Instant, Focus, usize)>,
197    pub should_quit: bool,
198    pub areas: Areas,
199    /// Body/preview image store.
200    pub images: ImageStore,
201    pub(crate) attachments: Vec<Attachment>,
202    /// Type-to-jump buffer (Tasks/Sidebar focus); cleared on timeout.
203    typeahead: String,
204    typeahead_at: Option<Instant>,
205    /// Needs a redraw.
206    pub dirty: bool,
207    /// Incremented on task mutation (invalidates preview cache).
208    pub data_gen: u64,
209    /// Per-category `(done, total)`, parallel to `categories`.
210    cat_progress: Vec<(usize, usize)>,
211    /// Cached body editor for the read-only preview pane.
212    pub preview_form: Option<TaskForm>,
213    preview_task_id: Option<String>,
214    preview_gen: u64,
215    /// Entity snapshots captured when an edit dialog opens. Save compares only
216    /// editable fields so unrelated changes (for example, another agent
217    /// toggling `done`) are preserved instead of becoming false conflicts.
218    task_edit_base: Option<Task>,
219    category_edit_base: Option<Category>,
220    /// One in-flight automatic check or explicit install.
221    update_job: Option<UpdateJob>,
222    /// Update notices survive ordinary status messages and clear only when the
223    /// user opens the `/` command palette.
224    update_notice: Option<String>,
225    /// Visible work for an explicit `/update` request.
226    update_activity: Option<UpdateActivity>,
227    /// Whether persistence polling has failed since its last successful pass.
228    /// Repeated failures are quiet so they cannot continuously replace messages
229    /// or disarm destructive confirmations; success rearms reporting.
230    external_poll_failed: bool,
231}
232
233impl App {
234    pub fn new(version: &str) -> Result<Self, StoreError> {
235        Self::with_store(version, Store::open_default(None)?)
236    }
237
238    pub fn with_store(version: &str, mut store: Store) -> Result<Self, StoreError> {
239        // The transaction reads fresh state, so two concurrently-starting
240        // processes cannot both claim the same first run or upgrade.
241        let initial = store.snapshot()?;
242        let (launch, snapshot) = if initial.settings.last_run_version.as_deref() == Some(version) {
243            (LaunchState::Returning, initial)
244        } else {
245            store.update_with_snapshot(|data| Ok(data.settings.record_launch(version)))?
246        };
247        let StoreData {
248            revision,
249            categories: real_cats,
250            tasks,
251            settings,
252            attachments,
253        } = snapshot;
254        // "All Tasks" is a view only — prepended in memory, never saved.
255        let mut categories = vec![Category::all_tasks()];
256        categories.extend(real_cats);
257        let mut images = ImageStore::with_root(store.images_dir().to_path_buf());
258        images.set_attachments(&attachments);
259
260        let mut app = Self {
261            store,
262            store_revision: revision,
263            tasks,
264            categories,
265            settings,
266            focus: Focus::Tasks,
267            mode: match launch {
268                LaunchState::FirstRun => Mode::Welcome,
269                LaunchState::Upgraded => Mode::WhatsNew,
270                LaunchState::Returning => Mode::Normal,
271            },
272            cat_index: 0,
273            task_index: 0,
274            cat_state: ListState::default(),
275            task_state: TableState::default(),
276            view: Vec::new(),
277            list_rows: Vec::new(),
278            searching: false,
279            search_query: String::new(),
280            input: TextInput::default(),
281            slash_index: 0,
282            form: None,
283            category_form: None,
284            settings_index: 0,
285            help_scroll: 0,
286            message: None,
287            pending: None,
288            last_click: None,
289            should_quit: false,
290            areas: Areas::default(),
291            images,
292            attachments,
293            typeahead: String::new(),
294            typeahead_at: None,
295            dirty: true,
296            data_gen: 0,
297            cat_progress: Vec::new(),
298            preview_form: None,
299            preview_task_id: None,
300            preview_gen: 0,
301            task_edit_base: None,
302            category_edit_base: None,
303            update_job: None,
304            update_notice: None,
305            update_activity: None,
306            external_poll_failed: false,
307        };
308        app.rebuild_view();
309        Ok(app)
310    }
311
312    /// Refresh after another process commits. Dialogs deliberately defer the
313    /// visual refresh: their entity snapshot is checked transactionally when
314    /// the user saves, so typed work is never replaced under the cursor.
315    pub fn poll_external_changes(&mut self) -> bool {
316        let revision = match self.store.revision() {
317            Ok(revision) => revision,
318            Err(error) => {
319                return self.report_external_poll_error(format!(
320                    "Could not check for external changes: {error}"
321                ));
322            }
323        };
324        if revision == self.store_revision || self.form.is_some() || self.category_form.is_some() {
325            self.external_poll_failed = false;
326            return false;
327        }
328        match self.reload_store() {
329            Ok(()) => {
330                self.external_poll_failed = false;
331                true
332            }
333            Err(error) => self
334                .report_external_poll_error(format!("Could not reload external changes: {error}")),
335        }
336    }
337
338    fn report_external_poll_error(&mut self, message: String) -> bool {
339        if self.external_poll_failed {
340            return false;
341        }
342        self.external_poll_failed = true;
343        self.error(message);
344        true
345    }
346
347    fn reload_store(&mut self) -> Result<(), StoreError> {
348        let selected_category = self.current_category_id().to_string();
349        let selected_task = self.selected_task().map(|task| task.id.clone());
350        let snapshot = self.store.snapshot()?;
351        self.apply_snapshot(snapshot, &selected_category, selected_task.as_deref());
352        Ok(())
353    }
354
355    fn apply_snapshot(
356        &mut self,
357        snapshot: StoreData,
358        selected_category: &str,
359        selected_task: Option<&str>,
360    ) {
361        let StoreData {
362            revision,
363            categories,
364            tasks,
365            settings,
366            attachments,
367        } = snapshot;
368        self.store_revision = revision;
369        self.tasks = tasks;
370        self.settings = settings;
371        self.attachments = attachments;
372        self.images.set_attachments(&self.attachments);
373        self.categories.clear();
374        self.categories.push(Category::all_tasks());
375        self.categories.extend(categories);
376        self.cat_index = self
377            .categories
378            .iter()
379            .position(|category| category.id == selected_category)
380            .unwrap_or(0);
381        self.cat_progress.clear();
382        self.data_gen = self.data_gen.wrapping_add(1);
383        self.invalidate_preview();
384        self.rebuild_view();
385        if let Some(id) = selected_task {
386            self.select_task_by_id(id);
387        }
388        self.dirty = true;
389    }
390
391    /// Commit against the transaction's fresh snapshot and apply the exact
392    /// normalized state returned after a successful commit.
393    fn update_store<R>(
394        &mut self,
395        operation: impl FnOnce(&mut StoreData) -> Result<R, StoreError>,
396    ) -> Result<R, StoreError> {
397        let selected_category = self.current_category_id().to_string();
398        let selected_task = self.selected_task().map(|task| task.id.clone());
399        let (result, snapshot) = self.store.update_with_snapshot(operation)?;
400        self.apply_snapshot(snapshot, &selected_category, selected_task.as_deref());
401        Ok(result)
402    }
403
404    fn report_store_error(&mut self, action: &str, error: StoreError) {
405        self.error(format!("{action}: {error}"));
406    }
407
408    /// Claim and start the daily background check. Persistence makes the claim
409    /// process-safe across multiple TUI instances sharing one data directory.
410    pub(crate) fn start_automatic_update_check(&mut self) {
411        let now = Utc::now().timestamp();
412        if self.claim_automatic_update_check_at(now) {
413            self.start_update_worker(UpdateJobKind::Automatic);
414        }
415    }
416
417    fn claim_automatic_update_check_at(&mut self, now: i64) -> bool {
418        if !self.settings.automatic_update_check_due(now) {
419            return false;
420        }
421        self.update_store(|data| Ok(data.settings.take_automatic_update_check(now)))
422            .unwrap_or(false)
423    }
424
425    /// Explicitly check for and install the latest verified release (`/update`).
426    pub(crate) fn start_update_install(&mut self) {
427        self.update_notice = None;
428        if self
429            .update_job
430            .as_ref()
431            .is_some_and(|job| job.kind == UpdateJobKind::Install)
432        {
433            self.info("Already updating…");
434            return;
435        }
436
437        // Explicit user intent supersedes an automatic check. Its detached
438        // worker may finish, but dropping the receiver prevents a stale result
439        // from competing with the install result in the UI.
440        self.update_job = None;
441        self.start_update_worker(UpdateJobKind::Install);
442    }
443
444    fn start_update_worker(&mut self, kind: UpdateJobKind) {
445        let (tx, rx) = mpsc::channel();
446        let thread_name = match kind {
447            UpdateJobKind::Automatic => "mach-update-check",
448            UpdateJobKind::Install => "mach-update-install",
449        };
450        match std::thread::Builder::new()
451            .name(thread_name.into())
452            .spawn(move || {
453                let result = crate::update::check().and_then(|info| match kind {
454                    UpdateJobKind::Automatic => Ok(UpdateOutcome::Checked(info)),
455                    UpdateJobKind::Install if info.newer => {
456                        crate::update::install_with_progress(&info, |progress| {
457                            let _ = tx.send(UpdateEvent::DownloadProgress(progress));
458                        })
459                        .map(UpdateOutcome::Installed)
460                    }
461                    UpdateJobKind::Install => Ok(UpdateOutcome::UpToDate(info)),
462                });
463                let _ = tx.send(UpdateEvent::Finished(result));
464            }) {
465            Ok(_) => {
466                self.update_job = Some(UpdateJob { rx, kind });
467                if kind == UpdateJobKind::Install {
468                    self.update_activity = Some(UpdateActivity::Checking);
469                    self.dirty = true;
470                }
471            }
472            Err(error) if kind == UpdateJobKind::Install => {
473                self.update_activity = None;
474                self.error(format!("Could not start update: {error}"));
475            }
476            Err(_) => {}
477        }
478    }
479
480    /// Apply finished update work, if any. Returns true when UI should redraw.
481    pub(crate) fn poll_update(&mut self) -> bool {
482        let mut changed = false;
483        loop {
484            let event = self
485                .update_job
486                .as_ref()
487                .map(|job| (job.kind, job.rx.try_recv()));
488            match event {
489                None => return changed,
490                Some((_, Ok(UpdateEvent::DownloadProgress(progress)))) => {
491                    let activity = UpdateActivity::Downloading(progress);
492                    if self.update_activity != Some(activity) {
493                        self.update_activity = Some(activity);
494                        changed = true;
495                    }
496                }
497                Some((kind, Ok(UpdateEvent::Finished(result)))) => {
498                    self.update_job = None;
499                    changed |= self.update_activity.take().is_some();
500                    return self.finish_update(kind, result) || changed;
501                }
502                Some((_, Err(TryRecvError::Empty))) => return changed,
503                Some((kind, Err(TryRecvError::Disconnected))) => {
504                    self.update_job = None;
505                    changed |= self.update_activity.take().is_some();
506                    return if kind == UpdateJobKind::Install {
507                        self.show_update_message("Update failed".into(), MessageKind::Error);
508                        true
509                    } else {
510                        changed
511                    };
512                }
513            }
514        }
515    }
516
517    fn finish_update(
518        &mut self,
519        kind: UpdateJobKind,
520        result: Result<UpdateOutcome, String>,
521    ) -> bool {
522        match result {
523            Ok(UpdateOutcome::Checked(info)) if info.newer => self.set_update_notice(format!(
524                "v{} → v{} available · run /update to install",
525                info.current, info.latest
526            )),
527            Ok(UpdateOutcome::Checked(_)) => false,
528            Ok(UpdateOutcome::UpToDate(info)) => {
529                self.show_update_message(info.summary(), MessageKind::Info);
530                true
531            }
532            Ok(UpdateOutcome::Installed(result)) => {
533                self.set_update_notice(format!("Installed {} · restart mach", result.tag))
534            }
535            Err(error) if kind == UpdateJobKind::Install => {
536                self.show_update_message(error, MessageKind::Error);
537                true
538            }
539            Err(_) => false,
540        }
541    }
542
543    fn set_update_notice(&mut self, text: String) -> bool {
544        let visible = self.message.is_none();
545        self.update_notice = Some(text);
546        if visible {
547            self.dirty = true;
548        }
549        visible
550    }
551
552    fn show_update_message(&mut self, text: String, kind: MessageKind) {
553        self.set_message_until(text, kind, Instant::now() + UPDATE_RESULT_DURATION);
554    }
555
556    pub fn mark_dirty(&mut self) {
557        self.dirty = true;
558    }
559
560    pub fn invalidate_preview(&mut self) {
561        self.preview_form = None;
562        self.preview_task_id = None;
563        self.preview_gen = 0;
564    }
565
566    /// Rebuild [`Self::preview_form`] if the selection or `data_gen` changed.
567    pub fn ensure_preview(&mut self) {
568        let Some((id, generation)) = self.selected_task().map(|t| (t.id.clone(), self.data_gen))
569        else {
570            self.invalidate_preview();
571            return;
572        };
573        if self.preview_task_id.as_deref() == Some(id.as_str())
574            && self.preview_gen == generation
575            && self.preview_form.is_some()
576        {
577            return;
578        }
579        let Some(task) = self.selected_task().cloned() else {
580            self.invalidate_preview();
581            return;
582        };
583        let mut form = TaskForm::edit(&task);
584        form.set_categories(&self.categories, task.category_id.as_deref());
585        form.set_image_root(self.images.root().to_path_buf());
586        form.set_attachments(&self.attachments);
587        self.preview_form = Some(form);
588        self.preview_task_id = Some(id);
589        self.preview_gen = generation;
590    }
591
592    pub fn theme(&self) -> Theme {
593        Theme::new(&self.settings.selected_color)
594    }
595
596    // ---------------------------------------------------------------- view
597
598    pub fn current_category_id(&self) -> &str {
599        self.categories
600            .get(self.cat_index)
601            .map(|c| c.id.as_str())
602            .unwrap_or(ALL_CATEGORY)
603    }
604
605    pub fn is_all_view(&self) -> bool {
606        self.current_category_id() == ALL_CATEGORY
607    }
608
609    pub fn category_name(&self, id: &str) -> Option<&str> {
610        self.categories
611            .iter()
612            .find(|c| c.id == id)
613            .map(|c| c.name.as_str())
614    }
615
616    /// Recompute which tasks are shown and in what order.
617    ///
618    /// Sort applies **inside** each category. All Tasks (and search) stack
619    /// those already-sorted groups in sidebar order; a single category is
620    /// just one group.
621    pub fn rebuild_view(&mut self) {
622        let selected_id = self.selected_task().map(|task| task.id.clone());
623        self.dirty = true;
624        if self.cat_progress.len() != self.categories.len() {
625            self.recompute_cat_progress();
626        }
627        let cat_id = self.current_category_id();
628        let all = cat_id == ALL_CATEGORY;
629        let hide_done = self.settings.hide_done;
630        let candidates: Vec<usize> = if self.searching {
631            let q = caseless_key(&self.search_query);
632            self.tasks
633                .iter()
634                .enumerate()
635                .filter(|(_, t)| {
636                    !(hide_done && t.done)
637                        && (contains_ignore_case(&t.title, &q) || body_contains(t, &q))
638                })
639                .map(|(i, _)| i)
640                .collect()
641        } else {
642            self.tasks
643                .iter()
644                .enumerate()
645                .filter(|(_, t)| {
646                    (all || t.category_id.as_deref() == Some(cat_id)) && !(hide_done && t.done)
647                })
648                .map(|(i, _)| i)
649                .collect()
650        };
651
652        // Multi-category views: stack each category's sorted slice.
653        let multi = all || self.searching;
654        self.view = if multi {
655            self.stack_by_category(&candidates)
656        } else {
657            let mut view = candidates;
658            self.sort_within(&mut view);
659            view
660        };
661        if let Some(id) = selected_id {
662            self.select_task_by_id(&id);
663        } else if self.task_index >= self.view.len() {
664            self.task_index = self.view.len().saturating_sub(1);
665        }
666        self.list_rows = self.build_list_rows(multi);
667    }
668
669    /// Table rows for the current `view`. Multi-category lists get a
670    /// section header before each group; a single category is tasks only.
671    fn build_list_rows(&self, multi: bool) -> Vec<TaskListRow> {
672        if !multi {
673            return (0..self.view.len()).map(TaskListRow::Task).collect();
674        }
675        let mut rows = Vec::with_capacity(self.view.len() + self.categories.len());
676        let mut prev: Option<Option<&str>> = None;
677        for (vi, &ti) in self.view.iter().enumerate() {
678            let key = self.tasks[ti].category_id.as_deref();
679            if prev != Some(key) {
680                let title = match key {
681                    Some(id) => self.category_name(id).unwrap_or("Unknown").to_string(),
682                    None => "Uncategorized".to_string(),
683                };
684                rows.push(TaskListRow::Separator { title });
685                prev = Some(key);
686            }
687            rows.push(TaskListRow::Task(vi));
688        }
689        rows
690    }
691
692    /// Visual table row for the selected task, if any.
693    pub fn selected_visual_row(&self) -> Option<usize> {
694        self.list_rows
695            .iter()
696            .position(|r| matches!(r, TaskListRow::Task(i) if *i == self.task_index))
697    }
698
699    /// `view` index under a visual table row, or `None` for a separator.
700    pub fn task_at_visual_row(&self, row: usize) -> Option<usize> {
701        match self.list_rows.get(row)? {
702            TaskListRow::Task(i) => Some(*i),
703            TaskListRow::Separator { .. } => None,
704        }
705    }
706
707    /// Sidebar order of real categories, each group sorted; uncategorized last.
708    fn stack_by_category(&self, candidates: &[usize]) -> Vec<usize> {
709        use std::collections::HashMap;
710        let mut buckets: HashMap<Option<&str>, Vec<usize>> = HashMap::new();
711        for &i in candidates {
712            buckets
713                .entry(self.tasks[i].category_id.as_deref())
714                .or_default()
715                .push(i);
716        }
717        let mut view = Vec::with_capacity(candidates.len());
718        for cat in self.categories.iter().filter(|c| !c.is_all()) {
719            if let Some(mut group) = buckets.remove(&Some(cat.id.as_str())) {
720                self.sort_within(&mut group);
721                view.extend(group);
722            }
723        }
724        // Anything not filed under a known category (or left uncategorized).
725        let mut rest: Vec<usize> = buckets.into_values().flatten().collect();
726        self.sort_within(&mut rest);
727        view.extend(rest);
728        view
729    }
730
731    /// Apply the settings sort to one category's rows (or a rest bucket).
732    fn sort_within(&self, view: &mut [usize]) {
733        match self.settings.sort.as_str() {
734            "important" => view.sort_by_key(|i| std::cmp::Reverse(self.tasks[*i].importance)),
735            "done" => view.sort_by_key(|i| self.tasks[*i].done),
736            "due" => view.sort_by_cached_key(|i| {
737                let due = &self.tasks[*i].due;
738                (due.is_empty(), due::sort_key(due))
739            }),
740            _ => {} // manual — keep the store's explicit task order
741        }
742    }
743
744    pub fn task_count(&self) -> usize {
745        self.view.len()
746    }
747
748    pub fn visible_task(&self, pos: usize) -> Option<&Task> {
749        self.view.get(pos).and_then(|index| self.tasks.get(*index))
750    }
751
752    pub fn selected_task(&self) -> Option<&Task> {
753        self.visible_task(self.task_index)
754    }
755
756    pub fn done_count(&self) -> usize {
757        self.view.iter().filter(|i| self.tasks[**i].done).count()
758    }
759
760    // ----------------------------------------------------------- selection
761
762    pub fn move_task_selection(&mut self, delta: isize) {
763        if self.view.is_empty() {
764            return;
765        }
766        let last = self.view.len() - 1;
767        let next = (self.task_index as isize + delta).clamp(0, last as isize) as usize;
768        self.select_task(next);
769    }
770
771    pub fn select_task(&mut self, pos: usize) {
772        if pos < self.view.len() && pos != self.task_index {
773            self.task_index = pos;
774            self.cancel_pending();
775            self.clear_typeahead();
776            self.dirty = true;
777        }
778    }
779
780    pub fn select_first_task(&mut self) {
781        self.select_task(0);
782    }
783
784    pub fn select_last_task(&mut self) {
785        self.select_task(self.view.len().saturating_sub(1));
786    }
787
788    /// Type-to-jump: append `c` and select the best fuzzy match (list unchanged).
789    pub fn typeahead_jump(&mut self, c: char) {
790        let now = Instant::now();
791        if self
792            .typeahead_at
793            .is_none_or(|t| now.duration_since(t) > TYPEAHEAD_TIMEOUT)
794        {
795            self.typeahead.clear();
796        }
797        let limit = match self.focus {
798            Focus::Tasks => MAX_TITLE_LEN,
799            Focus::Sidebar => MAX_CATEGORY_NAME_LEN,
800        };
801        if self.typeahead.graphemes(true).count() < limit {
802            self.typeahead.push(c);
803        }
804        self.typeahead_at = Some(now);
805
806        match self.focus {
807            Focus::Tasks => {
808                let titles = self.view.iter().map(|&i| self.tasks[i].title.as_str());
809                if let Some(pos) = crate::fuzzy::best_index(&self.typeahead, titles) {
810                    self.task_index = pos;
811                    self.cancel_pending();
812                }
813            }
814            Focus::Sidebar => {
815                let names = self.categories.iter().map(|c| c.name.as_str());
816                if let Some(pos) = crate::fuzzy::best_index(&self.typeahead, names)
817                    && pos != self.cat_index
818                {
819                    self.cat_index = pos;
820                    self.cancel_pending();
821                    self.on_category_changed();
822                }
823            }
824        }
825    }
826
827    pub fn move_category_selection(&mut self, delta: isize) {
828        if self.categories.is_empty() {
829            return;
830        }
831        let last = self.categories.len() - 1;
832        let next = (self.cat_index as isize + delta).clamp(0, last as isize) as usize;
833        self.select_category(next);
834    }
835
836    /// ↑/↓ stay inside the focused panel. Cross-panel moves use ←/→ or Tab.
837    pub fn navigate_vertical(&mut self, delta: isize) {
838        if delta == 0 {
839            return;
840        }
841        self.cancel_pending();
842        match self.focus {
843            Focus::Tasks => {
844                if self.view.is_empty() {
845                    return;
846                }
847                self.move_task_selection(delta);
848            }
849            Focus::Sidebar => {
850                self.move_category_selection(delta);
851            }
852        }
853    }
854
855    pub fn select_category(&mut self, index: usize) {
856        if index < self.categories.len() && index != self.cat_index {
857            self.cat_index = index;
858            self.cancel_pending();
859            self.clear_typeahead();
860            self.on_category_changed();
861        }
862    }
863
864    pub fn select_last_category(&mut self) {
865        self.select_category(self.categories.len().saturating_sub(1));
866    }
867
868    fn on_category_changed(&mut self) {
869        self.searching = false;
870        self.search_query.clear();
871        self.task_index = 0;
872        self.rebuild_view();
873    }
874
875    pub fn toggle_focus(&mut self) {
876        let next = match self.focus {
877            Focus::Sidebar => Focus::Tasks,
878            Focus::Tasks => Focus::Sidebar,
879        };
880        let _ = self.set_focus(next);
881    }
882
883    /// Move keyboard focus. A locked search owns the task list until Esc.
884    pub fn set_focus(&mut self, focus: Focus) -> bool {
885        if self.searching && focus == Focus::Sidebar {
886            return false;
887        }
888        if self.focus != focus {
889            self.focus = focus;
890            self.cancel_pending();
891            self.clear_typeahead();
892            self.dirty = true;
893        }
894        true
895    }
896
897    pub fn cancel_pending(&mut self) {
898        if self.pending.take().is_some() && self.message.take().is_some() {
899            self.dirty = true;
900        }
901    }
902
903    fn clear_typeahead(&mut self) {
904        self.typeahead.clear();
905        self.typeahead_at = None;
906    }
907
908    // ------------------------------------------------------------ mutation
909
910    fn recompute_cat_progress(&mut self) {
911        let mut all_done = 0usize;
912        let mut all_total = 0usize;
913        let mut per: Vec<(usize, usize)> = self.categories.iter().map(|_| (0, 0)).collect();
914        for t in &self.tasks {
915            all_total += 1;
916            if t.done {
917                all_done += 1;
918            }
919            if let Some(cid) = t.category_id.as_deref()
920                && let Some(idx) = self.categories.iter().position(|c| c.id == cid)
921            {
922                per[idx].1 += 1;
923                if t.done {
924                    per[idx].0 += 1;
925                }
926            }
927        }
928        for (i, cat) in self.categories.iter().enumerate() {
929            if cat.is_all() {
930                per[i] = (all_done, all_total);
931            }
932        }
933        self.cat_progress = per;
934    }
935
936    /// Keep the same task selected after the view is rebuilt and rows move.
937    fn select_task_by_id(&mut self, id: &str) {
938        if let Some(pos) = self.view.iter().position(|i| self.tasks[*i].id == id) {
939            self.task_index = pos;
940        } else if self.task_index >= self.view.len() {
941            self.task_index = self.view.len().saturating_sub(1);
942        }
943    }
944
945    pub fn toggle_done(&mut self, pos: usize) {
946        if let Some(&i) = self.view.get(pos) {
947            let id = self.tasks[i].id.clone();
948            match self.update_store(|data| data.toggle_task_done(&id)) {
949                Ok(_) => self.select_task_by_id(&id),
950                Err(error) => self.report_store_error("Could not update task", error),
951            }
952        }
953    }
954
955    /// Steps a task's importance up, wrapping back to none after three.
956    pub fn cycle_importance(&mut self, pos: usize) {
957        if let Some(&i) = self.view.get(pos) {
958            let id = self.tasks[i].id.clone();
959            match self.update_store(|data| {
960                let importance =
961                    (data.task(&id)?.importance + 1) % (crate::model::MAX_IMPORTANCE + 1);
962                data.set_task_importance(&id, importance)
963            }) {
964                Ok(_) => self.select_task_by_id(&id),
965                Err(error) => self.report_store_error("Could not update task", error),
966            }
967        }
968    }
969
970    /// Reorder the selected task inside its category when using manual sort.
971    /// In All Tasks, crossing a category section boundary is intentionally
972    /// blocked; changing category belongs in the task form.
973    pub fn move_task_order(&mut self, delta: isize) -> bool {
974        if delta == 0 || self.settings.sort != "manual" || self.searching {
975            return false;
976        }
977        let Some(current) = self.selected_task().cloned() else {
978            return false;
979        };
980        let target_view = self.task_index as isize + delta.signum();
981        if !(0..self.view.len() as isize).contains(&target_view) {
982            return false;
983        }
984        let Some(target) = self.visible_task(target_view as usize) else {
985            return false;
986        };
987        if target.category_id != current.category_id {
988            return false;
989        }
990        let target_id = target.id.clone();
991        let id = current.id;
992        let position = if delta.is_negative() {
993            RelativePosition::Before
994        } else {
995            RelativePosition::After
996        };
997        match self.update_store(|data| data.move_task_relative(&id, &target_id, position)) {
998            Ok(_) => {
999                self.select_task_by_id(&id);
1000                true
1001            }
1002            Err(error) => {
1003                self.report_store_error("Could not reorder task", error);
1004                false
1005            }
1006        }
1007    }
1008
1009    /// Opens the dialog for a new task, unless the list is full.
1010    pub fn open_new_task(&mut self) {
1011        if self.tasks.len() >= MAX_TASK_COUNT {
1012            self.error(format!(
1013                "You already have {MAX_TASK_COUNT} tasks in hand. Maybe deal with them first :)"
1014            ));
1015            return;
1016        }
1017        let mut form = TaskForm::new();
1018        let category = (!self.is_all_view()).then(|| self.current_category_id());
1019        form.set_categories(&self.categories, category);
1020        form.set_image_root(self.images.root().to_path_buf());
1021        form.set_attachments(&self.attachments);
1022        self.task_edit_base = None;
1023        self.form = Some(form);
1024        self.mode = Mode::TaskForm;
1025    }
1026
1027    /// Opens the dialog on the selected task.
1028    pub fn open_edit_task(&mut self) {
1029        if let Some(task) = self.selected_task().cloned() {
1030            let mut form = TaskForm::edit(&task);
1031            form.set_categories(&self.categories, task.category_id.as_deref());
1032            form.set_image_root(self.images.root().to_path_buf());
1033            form.set_attachments(&self.attachments);
1034            // Decode body pictures off the UI thread so the dialog opens
1035            // immediately; they fill in on the next frames.
1036            self.images.prefetch(form.body.images());
1037            self.task_edit_base = Some(task);
1038            self.form = Some(form);
1039            self.mode = Mode::TaskForm;
1040        }
1041    }
1042
1043    pub fn close_form(&mut self) {
1044        self.form = None;
1045        self.task_edit_base = None;
1046        self.mode = Mode::Normal;
1047        self.focus = Focus::Tasks;
1048        // Drop placed graphics so they do not float over the list; pixels
1049        // stay in RAM for a fast reopen. GIF frames are dropped with the form.
1050        self.images.release_form_graphics();
1051        self.images.clear_preview();
1052        self.cancel_pending();
1053    }
1054
1055    /// Validates the open form and writes it back to the task list.
1056    pub fn submit_form(&mut self) {
1057        let Some(form) = &mut self.form else { return };
1058        let Some(draft) = form.submit() else { return };
1059        let saved = match form.editing.clone() {
1060            Some(uuid) => self.update_task(&uuid, &draft),
1061            None => self.create_task(&draft).is_some(),
1062        };
1063        if saved {
1064            self.close_form();
1065        }
1066    }
1067
1068    /// Creates a task in the chosen category and selects it. A `[date]`
1069    /// left in the title is moved into `due` when `due` is empty.
1070    pub fn create_task(&mut self, draft: &TaskDraft) -> Option<String> {
1071        let (inline_due, title) = due::parse(draft.title.trim());
1072        if title.is_empty() || self.tasks.len() >= MAX_TASK_COUNT {
1073            return None;
1074        }
1075        let due = if draft.due.is_empty() {
1076            &inline_due
1077        } else {
1078            &draft.due
1079        };
1080        let body = draft.body.clone();
1081        let category_id = draft.category_id.clone();
1082        let importance = draft.importance;
1083        let task = match self.update_store(|data| {
1084            data.create_task(title, body, due.to_string(), importance, category_id)
1085        }) {
1086            Ok(task) => task,
1087            Err(error) => {
1088                let message = error.to_string();
1089                if let Some(form) = &mut self.form {
1090                    form.error = Some(message.clone());
1091                }
1092                self.report_store_error("Could not create task", error);
1093                return None;
1094            }
1095        };
1096        let id = task.id;
1097        self.searching = false;
1098        self.search_query.clear();
1099        self.rebuild_view();
1100        self.select_task_by_id(&id);
1101        Some(id)
1102    }
1103
1104    pub fn update_task(&mut self, id: &str, draft: &TaskDraft) -> bool {
1105        let (inline_due, title) = due::parse(draft.title.trim());
1106        if title.is_empty() {
1107            return false;
1108        }
1109        let due = if draft.due.is_empty() {
1110            &inline_due
1111        } else {
1112            &draft.due
1113        };
1114        let expected = self.task_edit_base.clone();
1115        let id = id.to_string();
1116        let due = due.to_string();
1117        let patch = match expected.as_ref() {
1118            Some(base) => TaskPatch {
1119                title: (title != base.title).then_some(title),
1120                body: (draft.body != base.body).then(|| draft.body.clone()),
1121                due: (due != base.due).then_some(due),
1122                importance: (draft.importance != base.importance).then_some(draft.importance),
1123                category_id: (draft.category_id != base.category_id)
1124                    .then(|| draft.category_id.clone()),
1125                ..TaskPatch::default()
1126            },
1127            None => TaskPatch {
1128                title: Some(title),
1129                body: Some(draft.body.clone()),
1130                due: Some(due),
1131                importance: Some(draft.importance),
1132                category_id: Some(draft.category_id.clone()),
1133                ..TaskPatch::default()
1134            },
1135        };
1136        match self.update_store(|data| {
1137            if let Some(expected) = &expected {
1138                data.edit_task_if_unchanged(expected, patch)
1139            } else {
1140                data.edit_task(&id, patch)
1141            }
1142        }) {
1143            Ok(_) => {
1144                self.select_task_by_id(&id);
1145                true
1146            }
1147            Err(error) => {
1148                let message = edit_error_message(&error);
1149                if let Some(form) = &mut self.form {
1150                    form.error = Some(message);
1151                }
1152                self.report_store_error("Could not update task", error);
1153                false
1154            }
1155        }
1156    }
1157
1158    pub fn delete_task(&mut self, pos: usize) {
1159        let Some(id) = self.visible_task(pos).map(|task| task.id.clone()) else {
1160            return;
1161        };
1162        self.delete_task_by_id(&id);
1163    }
1164
1165    pub fn delete_task_by_id(&mut self, id: &str) -> bool {
1166        let id = id.to_string();
1167        if let Err(error) = self.update_store(|data| data.delete_task(&id)) {
1168            self.report_store_error("Could not delete task", error);
1169            return false;
1170        }
1171        self.cancel_pending();
1172        true
1173    }
1174
1175    /// Permanently remove done tasks. In All Tasks → every done task; in a
1176    /// category → only that category's done tasks. Nothing is archived.
1177    pub fn purge(&mut self) -> usize {
1178        let ids = self.purge_candidate_ids();
1179        self.purge_ids(&ids)
1180    }
1181
1182    /// Completed task ids in the current purge scope, captured for confirmation.
1183    pub fn purge_candidate_ids(&self) -> Vec<String> {
1184        let everywhere = self.is_all_view();
1185        let category = self.current_category_id();
1186        self.tasks
1187            .iter()
1188            .filter(|task| {
1189                task.done && (everywhere || task.category_id.as_deref() == Some(category))
1190            })
1191            .map(|task| task.id.clone())
1192            .collect()
1193    }
1194
1195    /// Purge exactly the confirmed ids; newly completed tasks are never swept in.
1196    pub fn purge_ids(&mut self, ids: &[String]) -> usize {
1197        let ids = ids.to_vec();
1198        match self.update_store(|data| data.purge_completed_ids(&ids)) {
1199            Ok(removed) => {
1200                self.cancel_pending();
1201                removed.len()
1202            }
1203            Err(error) => {
1204                self.report_store_error("Could not purge completed tasks", error);
1205                0
1206            }
1207        }
1208    }
1209
1210    /// `/done` — show or hide completed tasks in the list (still on disk).
1211    pub fn toggle_hide_done(&mut self) -> Option<bool> {
1212        match self.update_store(|data| {
1213            data.update_settings(|settings| settings.hide_done = !settings.hide_done)
1214        }) {
1215            Ok(settings) => Some(settings.hide_done),
1216            Err(error) => {
1217                self.report_store_error("Could not update settings", error);
1218                None
1219            }
1220        }
1221    }
1222
1223    // ---------------------------------------------------------- categories
1224
1225    /// Opens the dialog for a new category.
1226    pub fn open_new_category(&mut self) {
1227        // Count real categories (exclude the virtual All row).
1228        let real = self.categories.iter().filter(|c| !c.is_all()).count();
1229        if real >= MAX_CATEGORY_COUNT {
1230            self.error(format!("At most {MAX_CATEGORY_COUNT} categories"));
1231            return;
1232        }
1233        self.category_edit_base = None;
1234        self.category_form = Some(CategoryForm::new());
1235        self.mode = Mode::CategoryForm;
1236    }
1237
1238    /// Opens the dialog on the selected category. "All Tasks" is not a
1239    /// real category and cannot be edited.
1240    pub fn open_edit_category(&mut self) {
1241        if self.is_all_view() {
1242            return;
1243        }
1244        if let Some(category) = self.categories.get(self.cat_index).cloned() {
1245            self.category_form = Some(CategoryForm::edit(&category));
1246            self.category_edit_base = Some(category);
1247            self.mode = Mode::CategoryForm;
1248        }
1249    }
1250
1251    pub fn close_category_form(&mut self) {
1252        self.category_form = None;
1253        self.category_edit_base = None;
1254        self.mode = Mode::Normal;
1255        self.cancel_pending();
1256    }
1257
1258    pub fn submit_category_form(&mut self) {
1259        let existing: Vec<(String, String)> = self
1260            .categories
1261            .iter()
1262            .filter(|category| !category.is_all())
1263            .map(|category| (category.id.clone(), category.name.clone()))
1264            .collect();
1265        let Some(form) = &mut self.category_form else {
1266            return;
1267        };
1268        let Some((name, description)) = form.submit_with(|name, editing| {
1269            let duplicate = existing.iter().any(|(id, existing_name)| {
1270                Some(id.as_str()) != editing
1271                    && caseless_key(existing_name.trim()) == caseless_key(name.trim())
1272            });
1273            if duplicate {
1274                Err("A category with that name already exists".to_string())
1275            } else {
1276                Ok(())
1277            }
1278        }) else {
1279            return;
1280        };
1281        let name = truncate_chars(&name, MAX_CATEGORY_NAME_LEN);
1282        let editing = form.editing.clone();
1283        let expected = self.category_edit_base.clone();
1284        let saved = match editing {
1285            Some(id) => {
1286                let patch = match expected.as_ref() {
1287                    Some(base) => CategoryPatch {
1288                        name: (name != base.name).then_some(name),
1289                        description: (description != base.description).then_some(description),
1290                    },
1291                    None => CategoryPatch {
1292                        name: Some(name),
1293                        description: Some(description),
1294                    },
1295                };
1296                match self.update_store(|data| {
1297                    if let Some(expected) = &expected {
1298                        data.edit_category_if_unchanged(expected, patch)
1299                    } else {
1300                        data.edit_category(&id, patch)
1301                    }
1302                }) {
1303                    Ok(_) => true,
1304                    Err(error) => {
1305                        let message = edit_error_message(&error);
1306                        if let Some(form) = &mut self.category_form {
1307                            form.error = Some(message);
1308                        }
1309                        self.report_store_error("Could not update category", error);
1310                        false
1311                    }
1312                }
1313            }
1314            None => match self.update_store(|data| data.create_category(name, description)) {
1315                Ok(category) => {
1316                    self.cat_index = self
1317                        .categories
1318                        .iter()
1319                        .position(|item| item.id == category.id)
1320                        .unwrap_or(0);
1321                    self.on_category_changed();
1322                    true
1323                }
1324                Err(error) => {
1325                    let message = error.to_string();
1326                    if let Some(form) = &mut self.category_form {
1327                        form.error = Some(message);
1328                    }
1329                    self.report_store_error("Could not create category", error);
1330                    false
1331                }
1332            },
1333        };
1334        if saved {
1335            self.close_category_form();
1336        }
1337    }
1338
1339    /// Deletes the category while preserving its tasks as Uncategorized.
1340    /// Category ids are stable UUIDs — no renumbering.
1341    pub fn delete_category(&mut self) {
1342        if self.is_all_view() {
1343            return;
1344        }
1345        let id = self.current_category_id().to_string();
1346        let _ = self.delete_category_by_id(&id);
1347    }
1348
1349    pub fn delete_category_by_id(&mut self, id: &str) -> bool {
1350        let Some(category) = self.categories.iter().find(|category| category.id == id) else {
1351            return false;
1352        };
1353        if category.is_all() {
1354            return false;
1355        }
1356        let id = id.to_string();
1357        match self.update_store(|data| data.delete_category(&id)) {
1358            Ok(_) => {
1359                self.cancel_pending();
1360                self.cat_index = 0;
1361                self.on_category_changed();
1362                true
1363            }
1364            Err(error) => {
1365                self.report_store_error("Could not delete category", error);
1366                false
1367            }
1368        }
1369    }
1370
1371    /// Reorder real categories while keeping the virtual All Tasks row fixed.
1372    pub fn move_category_order(&mut self, delta: isize) -> bool {
1373        if delta == 0 || self.is_all_view() || self.searching {
1374            return false;
1375        }
1376        let target_display = self.cat_index as isize + delta.signum();
1377        if !(1..self.categories.len() as isize).contains(&target_display) {
1378            return false;
1379        }
1380        let id = self.current_category_id().to_string();
1381        let target_id = self.categories[target_display as usize].id.clone();
1382        let position = if delta.is_negative() {
1383            RelativePosition::Before
1384        } else {
1385            RelativePosition::After
1386        };
1387        match self.update_store(|data| data.move_category_relative(&id, &target_id, position)) {
1388            Ok(_) => {
1389                self.cat_index = self
1390                    .categories
1391                    .iter()
1392                    .position(|category| category.id == id)
1393                    .unwrap_or(0);
1394                self.on_category_changed();
1395                true
1396            }
1397            Err(error) => {
1398                self.report_store_error("Could not reorder category", error);
1399                false
1400            }
1401        }
1402    }
1403
1404    /// `(done, total)` for a category. All Tasks counts every task.
1405    pub fn category_progress(&self, id: &str) -> (usize, usize) {
1406        if let Some(idx) = self.categories.iter().position(|c| c.id == id)
1407            && let Some(&p) = self.cat_progress.get(idx)
1408        {
1409            return p;
1410        }
1411        (0, 0)
1412    }
1413
1414    // -------------------------------------------------------------- slash / search
1415
1416    /// Open the `/` command palette.
1417    pub fn open_slash(&mut self) {
1418        if self.searching {
1419            self.end_search();
1420        }
1421        self.update_notice = None;
1422        self.mode = Mode::Slash;
1423        self.input = TextInput::new("", 128);
1424        self.slash_index = 0;
1425        self.dirty = true;
1426    }
1427
1428    /// Enter live search, optionally with an initial query.
1429    pub fn start_search(&mut self, query: &str) {
1430        self.mode = Mode::Search;
1431        self.focus = Focus::Tasks;
1432        self.input = TextInput::new(query, MAX_TITLE_LEN);
1433        self.search_query = query.to_string();
1434        self.searching = true;
1435        self.task_index = 0;
1436        self.rebuild_view();
1437    }
1438
1439    pub fn update_search(&mut self) {
1440        self.search_query = self.input.value();
1441        self.searching = true;
1442        self.task_index = 0;
1443        self.rebuild_view();
1444    }
1445
1446    pub fn end_search(&mut self) {
1447        self.searching = false;
1448        self.search_query.clear();
1449        self.task_index = 0;
1450        self.mode = Mode::Normal;
1451        self.rebuild_view();
1452    }
1453
1454    pub fn clamp_slash_index(&mut self) {
1455        let n = crate::slash::matching(&self.input.value()).len();
1456        if n == 0 {
1457            self.slash_index = 0;
1458        } else {
1459            self.slash_index = self.slash_index.min(n - 1);
1460        }
1461    }
1462
1463    // ------------------------------------------------------------ messages
1464
1465    pub fn info(&mut self, text: impl Into<String>) {
1466        self.set_message(text.into(), MessageKind::Info, 2000);
1467    }
1468
1469    pub fn error(&mut self, text: impl Into<String>) {
1470        self.set_message(text.into(), MessageKind::Error, 2500);
1471    }
1472
1473    pub(crate) fn status_message(&self) -> Option<(&str, MessageKind)> {
1474        self.message
1475            .as_ref()
1476            .map(|message| (message.text.as_str(), message.kind))
1477            .or_else(|| {
1478                self.update_notice
1479                    .as_deref()
1480                    .map(|text| (text, MessageKind::Info))
1481            })
1482    }
1483
1484    pub(crate) fn update_activity(&self) -> Option<UpdateActivity> {
1485        self.update_activity
1486    }
1487
1488    pub(crate) fn update_work_active(&self) -> bool {
1489        self.update_job
1490            .as_ref()
1491            .is_some_and(|job| job.kind == UpdateJobKind::Install)
1492    }
1493
1494    fn set_message(&mut self, text: String, kind: MessageKind, millis: u64) {
1495        self.set_message_until(text, kind, Instant::now() + Duration::from_millis(millis));
1496    }
1497
1498    fn set_message_until(&mut self, text: String, kind: MessageKind, until: Instant) {
1499        // A confirmation is only safe while its matching prompt is visible.
1500        // Any independent status replaces that prompt and therefore disarms
1501        // the pending destructive action as part of the same state change.
1502        self.pending = None;
1503        self.message = Some(Message { text, kind, until });
1504        self.dirty = true;
1505    }
1506
1507    /// Drop expired status messages. Returns true when the UI should redraw.
1508    pub fn expire_message(&mut self) -> bool {
1509        if let Some(m) = &self.message
1510            && Instant::now() >= m.until
1511        {
1512            self.pending = None;
1513            self.message = None;
1514            self.dirty = true;
1515            return true;
1516        }
1517        false
1518    }
1519
1520    /// Arm a destructive key on its second press, and say so.
1521    pub fn ask_confirm(&mut self, confirm: Confirm, prompt: impl Into<String>) {
1522        let until = Instant::now() + CONFIRM_WINDOW;
1523        self.set_message_until(prompt.into(), MessageKind::Info, until);
1524        self.pending = Some((confirm, until));
1525    }
1526
1527    /// Whether `confirm` is armed and still inside its window.
1528    pub fn awaiting(&self, confirm: Confirm) -> bool {
1529        matches!(&self.pending, Some((armed, until)) if *armed == confirm && Instant::now() < *until)
1530    }
1531
1532    pub fn pending_confirmation(&self) -> Option<&Confirm> {
1533        self.pending
1534            .as_ref()
1535            .filter(|(_, until)| Instant::now() < *until)
1536            .map(|(confirm, _)| confirm)
1537    }
1538
1539    // ----------------------------------------------------------- settings
1540
1541    /// Step a settings row by `delta` (+1 forward, −1 back), wrapping.
1542    pub fn cycle_setting(&mut self, index: usize, delta: isize) {
1543        use crate::settings::{DATE_FORMATS, PREVIEW_POSITIONS, SORTS, THEMES, cycle_by};
1544        if index >= SETTINGS_ITEMS.len() {
1545            return;
1546        }
1547        if let Err(error) = self.update_store(|data| {
1548            data.update_settings(|settings| match index {
1549                0 => settings.sort = cycle_by(&SORTS, &settings.sort, delta),
1550                1 => settings.selected_color = cycle_by(&THEMES, &settings.selected_color, delta),
1551                2 => settings.date_format = cycle_by(&DATE_FORMATS, &settings.date_format, delta),
1552                3 => {
1553                    settings.preview_position =
1554                        cycle_by(&PREVIEW_POSITIONS, &settings.preview_position, delta)
1555                }
1556                _ => {}
1557            })
1558        }) {
1559            self.report_store_error("Could not update settings", error);
1560        }
1561    }
1562
1563    pub fn setting_value(&self, index: usize) -> String {
1564        match index {
1565            0 => crate::settings::sort_label(&self.settings.sort).to_string(),
1566            1 => crate::settings::theme_label(&self.settings.selected_color),
1567            2 => self.settings.date_format.clone(),
1568            3 => {
1569                crate::settings::preview_position_label(&self.settings.preview_position).to_string()
1570            }
1571            _ => String::new(),
1572        }
1573    }
1574}
1575
1576/// Unicode-caseless contains. `folded_needle` must already be normalized.
1577/// ASCII path avoids allocating.
1578fn contains_ignore_case(haystack: &str, folded_needle: &str) -> bool {
1579    if folded_needle.is_empty() {
1580        return true;
1581    }
1582    if haystack.is_ascii() && folded_needle.is_ascii() {
1583        return haystack
1584            .as_bytes()
1585            .windows(folded_needle.len())
1586            .any(|w| w.eq_ignore_ascii_case(folded_needle.as_bytes()));
1587    }
1588    caseless_key(haystack).contains(folded_needle)
1589}
1590
1591/// Whether any prose or to-do in the body mentions `query`.
1592fn body_contains(task: &Task, query: &str) -> bool {
1593    task.body.iter().any(|block| match block {
1594        crate::model::Block::Text { text }
1595        | crate::model::Block::Todo { text, .. }
1596        | crate::model::Block::Bullet { text }
1597        | crate::model::Block::Number { text }
1598        | crate::model::Block::Link { url: text } => contains_ignore_case(text, query),
1599        crate::model::Block::Image { .. } => false,
1600    })
1601}
1602
1603fn edit_error_message(error: &StoreError) -> String {
1604    match error {
1605        StoreError::StaleEntity { .. } => {
1606            format!("{error}; close and reopen the editor to load the latest values")
1607        }
1608        _ => error.to_string(),
1609    }
1610}
1611
1612pub fn truncate_chars(s: &str, max: usize) -> String {
1613    s.graphemes(true).take(max).collect()
1614}
1615
1616#[cfg(test)]
1617mod tests {
1618    use super::*;
1619
1620    fn update_result(newer: bool) -> crate::update::CheckResult {
1621        crate::update::CheckResult {
1622            current: "0.2.0".into(),
1623            latest: if newer { "0.3.0" } else { "0.2.0" }.into(),
1624            tag: if newer { "v0.3.0" } else { "v0.2.0" }.into(),
1625            newer,
1626            prerelease: false,
1627            release_url: "https://example.test/release".into(),
1628            asset_name: "mach-aarch64-apple-darwin".into(),
1629            asset_url: "https://example.test/binary".into(),
1630            checksums_url: "https://example.test/SHA256SUMS".into(),
1631        }
1632    }
1633
1634    #[test]
1635    fn typeahead_buffer_is_bounded_by_the_longest_searchable_title() {
1636        let store = Store::open_in_memory_with_paths("/tmp/mach-typeahead-test")
1637            .expect("open in-memory store");
1638        let mut app = App::with_store("test", store).expect("build app");
1639        app.mode = Mode::Normal;
1640
1641        for _ in 0..(MAX_TITLE_LEN * 2) {
1642            app.typeahead_jump('x');
1643        }
1644
1645        assert!(
1646            app.typeahead.graphemes(true).count() <= MAX_TITLE_LEN,
1647            "a held key must not grow the navigation query without bound"
1648        );
1649    }
1650
1651    #[test]
1652    fn automatic_update_claim_is_persisted_across_app_instances() {
1653        let dir = std::env::temp_dir().join(format!(
1654            "mach-update-claim-{}-{}",
1655            std::process::id(),
1656            uuid::Uuid::new_v4()
1657        ));
1658        let now = 1_800_000_000;
1659        let mut first = App::with_store("test", Store::open(&dir).unwrap()).unwrap();
1660
1661        assert!(first.claim_automatic_update_check_at(now));
1662        drop(first);
1663
1664        let mut second = App::with_store("test", Store::open(&dir).unwrap()).unwrap();
1665        assert!(!second.claim_automatic_update_check_at(now));
1666        assert_eq!(second.settings.last_update_check_at, Some(now));
1667        drop(second);
1668        std::fs::remove_dir_all(dir).unwrap();
1669    }
1670
1671    #[test]
1672    fn upgraded_version_shows_whats_new_once() {
1673        let dir = std::env::temp_dir().join(format!(
1674            "mach-whats-new-{}-{}",
1675            std::process::id(),
1676            uuid::Uuid::new_v4()
1677        ));
1678        let mut store = Store::open(&dir).unwrap();
1679        store
1680            .update(|data| {
1681                data.settings.last_run_version = Some("0.1.9".into());
1682                Ok(())
1683            })
1684            .unwrap();
1685
1686        let first = App::with_store("0.2.0", store).unwrap();
1687        assert_eq!(first.mode, Mode::WhatsNew);
1688        drop(first);
1689
1690        let second = App::with_store("0.2.0", Store::open(&dir).unwrap()).unwrap();
1691        assert_eq!(second.mode, Mode::Normal);
1692        drop(second);
1693        std::fs::remove_dir_all(dir).unwrap();
1694    }
1695
1696    #[test]
1697    fn tui_update_install_success_requests_restart() {
1698        let store = Store::open_in_memory_with_paths("/tmp/mach-install-success-test").unwrap();
1699        let mut app = App::with_store("test", store).unwrap();
1700        let (tx, rx) = mpsc::channel();
1701        app.update_job = Some(UpdateJob {
1702            rx,
1703            kind: UpdateJobKind::Install,
1704        });
1705        app.update_activity = Some(UpdateActivity::Checking);
1706        tx.send(UpdateEvent::Finished(Ok(UpdateOutcome::Installed(
1707            crate::update::InstallResult {
1708                destination: "/tmp/mach-bin/mach".into(),
1709                tag: "v0.3.0".into(),
1710            },
1711        ))))
1712        .unwrap();
1713
1714        assert!(app.poll_update());
1715        assert_eq!(
1716            app.status_message().map(|(text, _)| text),
1717            Some("Installed v0.3.0 · restart mach")
1718        );
1719        assert!(app.update_activity().is_none());
1720
1721        assert!(!app.expire_message());
1722        assert_eq!(
1723            app.status_message().map(|(text, _)| text),
1724            Some("Installed v0.3.0 · restart mach")
1725        );
1726
1727        app.open_slash();
1728        assert!(app.status_message().is_none());
1729    }
1730
1731    #[test]
1732    fn update_download_progress_is_applied_before_the_final_result() {
1733        let store = Store::open_in_memory_with_paths("/tmp/mach-install-progress-test").unwrap();
1734        let mut app = App::with_store("test", store).unwrap();
1735        let (tx, rx) = mpsc::channel();
1736        app.update_job = Some(UpdateJob {
1737            rx,
1738            kind: UpdateJobKind::Install,
1739        });
1740        app.update_activity = Some(UpdateActivity::Checking);
1741        tx.send(UpdateEvent::DownloadProgress(
1742            crate::update::DownloadProgress {
1743                downloaded: 512,
1744                total: Some(1024),
1745            },
1746        ))
1747        .unwrap();
1748
1749        assert!(app.poll_update());
1750        assert_eq!(
1751            app.update_activity(),
1752            Some(UpdateActivity::Downloading(
1753                crate::update::DownloadProgress {
1754                    downloaded: 512,
1755                    total: Some(1024),
1756                }
1757            ))
1758        );
1759    }
1760
1761    #[test]
1762    fn tui_update_install_error_keeps_the_recovery_command() {
1763        let store = Store::open_in_memory_with_paths("/tmp/mach-install-error-test").unwrap();
1764        let mut app = App::with_store("test", store).unwrap();
1765        let (tx, rx) = mpsc::channel();
1766        app.update_job = Some(UpdateJob {
1767            rx,
1768            kind: UpdateJobKind::Install,
1769        });
1770        tx.send(UpdateEvent::Finished(Err(
1771            "this mach executable is managed by Cargo; run cargo install --locked mach-tui".into(),
1772        )))
1773        .unwrap();
1774
1775        assert!(app.poll_update());
1776        let message = app.message.as_ref().expect("visible install error");
1777        assert_eq!(message.kind, MessageKind::Error);
1778        assert!(message.text.contains("cargo install --locked mach-tui"));
1779    }
1780
1781    #[test]
1782    fn automatic_update_results_are_silent_unless_a_new_version_exists() {
1783        let store = Store::open_in_memory_with_paths("/tmp/mach-auto-update-test").unwrap();
1784        let mut app = App::with_store("test", store).unwrap();
1785        let (tx, rx) = mpsc::channel();
1786        app.update_job = Some(UpdateJob {
1787            rx,
1788            kind: UpdateJobKind::Automatic,
1789        });
1790        tx.send(UpdateEvent::Finished(Ok(UpdateOutcome::Checked(
1791            update_result(false),
1792        ))))
1793        .unwrap();
1794
1795        assert!(!app.poll_update());
1796        assert!(app.message.is_none());
1797
1798        let (tx, rx) = mpsc::channel();
1799        app.update_job = Some(UpdateJob {
1800            rx,
1801            kind: UpdateJobKind::Automatic,
1802        });
1803        tx.send(UpdateEvent::Finished(Err("offline".into())))
1804            .unwrap();
1805
1806        assert!(!app.poll_update());
1807        assert!(app.message.is_none());
1808    }
1809
1810    #[test]
1811    fn automatic_update_notice_waits_for_an_active_confirmation() {
1812        let store = Store::open_in_memory_with_paths("/tmp/mach-deferred-update-test").unwrap();
1813        let mut app = App::with_store("test", store).unwrap();
1814        app.ask_confirm(Confirm::Quit, "Press Ctrl+C again to quit");
1815        let (tx, rx) = mpsc::channel();
1816        app.update_job = Some(UpdateJob {
1817            rx,
1818            kind: UpdateJobKind::Automatic,
1819        });
1820        tx.send(UpdateEvent::Finished(Ok(UpdateOutcome::Checked(
1821            update_result(true),
1822        ))))
1823        .unwrap();
1824
1825        assert!(!app.poll_update());
1826        assert_eq!(app.pending_confirmation(), Some(&Confirm::Quit));
1827        assert_eq!(
1828            app.message.as_ref().map(|message| message.text.as_str()),
1829            Some("Press Ctrl+C again to quit")
1830        );
1831
1832        app.cancel_pending();
1833        assert!(app.status_message().is_some_and(|(text, _)| {
1834            text.contains("v0.2.0 → v0.3.0 available · run /update to install")
1835        }));
1836
1837        app.info("Temporary action result");
1838        assert_eq!(
1839            app.status_message().map(|(text, _)| text),
1840            Some("Temporary action result")
1841        );
1842        app.message.as_mut().unwrap().until = Instant::now();
1843        assert!(app.expire_message());
1844        assert!(app.status_message().is_some_and(|(text, _)| {
1845            text.contains("v0.2.0 → v0.3.0 available · run /update to install")
1846        }));
1847
1848        app.open_slash();
1849        assert!(app.status_message().is_none());
1850    }
1851}