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