Skip to main content

mach/
app.rs

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