Skip to main content

mach/
app.rs

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