1use 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;
25use crate::update::{CheckFailure, CheckResponse};
26use crate::update_state::{
27 AutomaticClaim, FAILURE_RETRY_SECONDS, LEASE_SECONDS, UpdateLease, UpdateState,
28 UpdateStateStore,
29};
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum Focus {
33 Sidebar,
34 Tasks,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum Mode {
39 Normal,
40 Slash,
42 Search,
44 TaskForm,
46 CategoryForm,
48 Help,
49 Settings,
50 Welcome,
51 WhatsNew,
52}
53
54impl Mode {
55 pub fn command_bar_focused(self) -> bool {
57 matches!(self, Mode::Slash | Mode::Search)
58 }
59
60 pub fn is_overlay(self) -> bool {
62 matches!(
63 self,
64 Mode::Help
65 | Mode::Settings
66 | Mode::Welcome
67 | Mode::WhatsNew
68 | Mode::TaskForm
69 | Mode::CategoryForm
70 )
71 }
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub enum MessageKind {
76 Info,
77 Error,
78}
79
80pub struct Message {
81 pub text: String,
82 pub kind: MessageKind,
83 pub until: Instant,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq)]
90pub enum Confirm {
91 DeleteTask(String),
93 DeleteCategory(String),
95 Purge(Vec<String>),
97 DiscardTask(Option<String>),
99 DiscardCategory(Option<String>),
100 Quit,
102}
103
104const CONFIRM_WINDOW: Duration = Duration::from_millis(2000);
106
107const TYPEAHEAD_TIMEOUT: Duration = Duration::from_millis(800);
109
110const UPDATE_RESULT_DURATION: Duration = Duration::from_secs(10);
111
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113enum UpdateJobKind {
114 Automatic,
115 Install,
116}
117
118enum UpdateOutcome {
119 Automatic(CheckResponse),
120 UpToDate {
121 info: crate::update::CheckResult,
122 etag: Option<String>,
123 },
124 Installed {
125 result: crate::update::InstallResult,
126 info: crate::update::CheckResult,
127 etag: Option<String>,
128 },
129 InstallFailed {
130 message: String,
131 info: crate::update::CheckResult,
132 etag: Option<String>,
133 },
134}
135
136enum UpdateEvent {
137 DownloadProgress(crate::update::DownloadProgress),
138 Finished(Box<Result<UpdateOutcome, CheckFailure>>),
139}
140
141struct UpdateJob {
142 rx: Receiver<UpdateEvent>,
143 kind: UpdateJobKind,
144 lease: Option<UpdateLease>,
145}
146
147struct UpdateNotice {
148 text: String,
149 available_version: Option<String>,
150}
151
152#[derive(Debug, Clone, Copy, PartialEq, Eq)]
153pub(crate) enum UpdateActivity {
154 Checking,
155 Downloading(crate::update::DownloadProgress),
156}
157
158#[derive(Debug, Default, Clone, Copy)]
160pub struct Areas {
161 pub sidebar: Rect,
162 pub tasks: Rect,
163 pub command_bar: Rect,
165 pub preview: Rect,
167 pub flag_x: Option<u16>,
172 pub done_x: Option<u16>,
173 pub slash_menu: Rect,
175}
176
177pub const SETTINGS_ITEMS: [&str; 4] = ["Sort", "Theme", "Date format", "Task preview"];
178
179#[derive(Debug, Clone, PartialEq, Eq)]
182pub enum TaskListRow {
183 Separator {
184 title: String,
185 },
186 Task(usize),
188}
189
190pub struct App {
191 store: Store,
192 version: String,
193 store_revision: u64,
194 pub tasks: Vec<Task>,
195 pub categories: Vec<Category>,
196 pub settings: Settings,
197 pub focus: Focus,
198 pub mode: Mode,
199 pub cat_index: usize,
201 pub task_index: usize,
203 pub cat_state: ListState,
206 pub task_state: TableState,
207 pub view: Vec<usize>,
209 pub list_rows: Vec<TaskListRow>,
212 pub searching: bool,
213 pub search_query: String,
214 pub input: TextInput,
215 pub slash_index: usize,
217 pub form: Option<TaskForm>,
219 pub category_form: Option<CategoryForm>,
221 pub settings_index: usize,
222 pub help_scroll: usize,
224 pub message: Option<Message>,
225 pub pending: Option<(Confirm, Instant)>,
227 pub last_click: Option<(Instant, Focus, usize)>,
229 pub should_quit: bool,
230 pub areas: Areas,
231 pub images: ImageStore,
233 pub(crate) attachments: Vec<Attachment>,
234 typeahead: String,
236 typeahead_at: Option<Instant>,
237 pub dirty: bool,
239 pub data_gen: u64,
241 cat_progress: Vec<(usize, usize)>,
243 pub preview_form: Option<TaskForm>,
245 preview_task_id: Option<String>,
246 preview_gen: u64,
247 task_edit_base: Option<Task>,
251 category_edit_base: Option<Category>,
252 update_job: Option<UpdateJob>,
254 update_state: Option<UpdateStateStore>,
256 next_update_state_poll_at: i64,
258 update_notice: Option<UpdateNotice>,
261 dismissed_update_version: Option<String>,
264 update_activity: Option<UpdateActivity>,
266 external_poll_failed: bool,
270}
271
272impl App {
273 pub fn new(version: &str) -> Result<Self, StoreError> {
274 Self::with_store_and_update_state(
275 version,
276 Store::open_default(None)?,
277 UpdateStateStore::open_default(),
278 )
279 }
280
281 pub fn with_store(version: &str, store: Store) -> Result<Self, StoreError> {
282 Self::with_store_and_update_state(version, store, UpdateStateStore::open_in_memory())
283 }
284
285 pub(crate) fn with_store_and_update_state(
286 version: &str,
287 mut store: Store,
288 update_state: Result<UpdateStateStore, StoreError>,
289 ) -> Result<Self, StoreError> {
290 let initial = store.snapshot()?;
293 let (launch, snapshot) = if initial.settings.last_run_version.as_deref() == Some(version) {
294 (LaunchState::Returning, initial)
295 } else {
296 store.update_with_snapshot(|data| Ok(data.settings.record_launch(version)))?
297 };
298 let StoreData {
299 revision,
300 categories: real_cats,
301 tasks,
302 settings,
303 attachments,
304 } = snapshot;
305 let mut categories = vec![Category::all_tasks()];
307 categories.extend(real_cats);
308 let mut images = ImageStore::with_root(store.images_dir().to_path_buf());
309 images.set_attachments(&attachments);
310 let (update_state, update_state_error) = match update_state {
311 Ok(store) => (Some(store), None),
312 Err(error) => (None, Some(error.to_string())),
313 };
314
315 let mut app = Self {
316 store,
317 version: version.to_string(),
318 store_revision: revision,
319 tasks,
320 categories,
321 settings,
322 focus: Focus::Tasks,
323 mode: match launch {
324 LaunchState::FirstRun => Mode::Welcome,
325 LaunchState::Upgraded => Mode::WhatsNew,
326 LaunchState::Returning => Mode::Normal,
327 },
328 cat_index: 0,
329 task_index: 0,
330 cat_state: ListState::default(),
331 task_state: TableState::default(),
332 view: Vec::new(),
333 list_rows: Vec::new(),
334 searching: false,
335 search_query: String::new(),
336 input: TextInput::default(),
337 slash_index: 0,
338 form: None,
339 category_form: None,
340 settings_index: 0,
341 help_scroll: 0,
342 message: None,
343 pending: None,
344 last_click: None,
345 should_quit: false,
346 areas: Areas::default(),
347 images,
348 attachments,
349 typeahead: String::new(),
350 typeahead_at: None,
351 dirty: true,
352 data_gen: 0,
353 cat_progress: Vec::new(),
354 preview_form: None,
355 preview_task_id: None,
356 preview_gen: 0,
357 task_edit_base: None,
358 category_edit_base: None,
359 update_job: None,
360 update_state,
361 next_update_state_poll_at: 0,
362 update_notice: None,
363 dismissed_update_version: None,
364 update_activity: None,
365 external_poll_failed: false,
366 };
367 app.rebuild_view();
368 if let Some(error) = update_state_error {
369 app.error(format!("Automatic update checks unavailable: {error}"));
370 } else {
371 app.refresh_update_state(Utc::now().timestamp());
372 }
373 Ok(app)
374 }
375
376 pub fn data_dir(&self) -> &Path {
377 self.store.data_dir()
378 }
379
380 pub fn poll_external_changes(&mut self) -> bool {
384 let revision = match self.store.revision() {
385 Ok(revision) => revision,
386 Err(error) => {
387 return self.report_external_poll_error(format!(
388 "Could not check for external changes: {error}"
389 ));
390 }
391 };
392 if revision == self.store_revision || self.form.is_some() || self.category_form.is_some() {
393 self.external_poll_failed = false;
394 return false;
395 }
396 match self.reload_store() {
397 Ok(()) => {
398 self.external_poll_failed = false;
399 true
400 }
401 Err(error) => self
402 .report_external_poll_error(format!("Could not reload external changes: {error}")),
403 }
404 }
405
406 fn report_external_poll_error(&mut self, message: String) -> bool {
407 if self.external_poll_failed {
408 return false;
409 }
410 self.external_poll_failed = true;
411 self.error(message);
412 true
413 }
414
415 fn reload_store(&mut self) -> Result<(), StoreError> {
416 let selected_category = self.current_category_id().to_string();
417 let selected_task = self.selected_task().map(|task| task.id.clone());
418 let snapshot = self.store.snapshot()?;
419 self.apply_snapshot(snapshot, &selected_category, selected_task.as_deref());
420 Ok(())
421 }
422
423 fn apply_snapshot(
424 &mut self,
425 snapshot: StoreData,
426 selected_category: &str,
427 selected_task: Option<&str>,
428 ) {
429 let StoreData {
430 revision,
431 categories,
432 tasks,
433 settings,
434 attachments,
435 } = snapshot;
436 self.store_revision = revision;
437 self.tasks = tasks;
438 self.settings = settings;
439 self.attachments = attachments;
440 self.images.set_attachments(&self.attachments);
441 self.categories.clear();
442 self.categories.push(Category::all_tasks());
443 self.categories.extend(categories);
444 self.cat_index = self
445 .categories
446 .iter()
447 .position(|category| category.id == selected_category)
448 .unwrap_or(0);
449 self.cat_progress.clear();
450 self.data_gen = self.data_gen.wrapping_add(1);
451 self.invalidate_preview();
452 self.rebuild_view();
453 if let Some(id) = selected_task {
454 self.select_task_by_id(id);
455 }
456 self.dirty = true;
457 }
458
459 fn update_store<R>(
462 &mut self,
463 operation: impl FnOnce(&mut StoreData) -> Result<R, StoreError>,
464 ) -> Result<R, StoreError> {
465 let selected_category = self.current_category_id().to_string();
466 let selected_task = self.selected_task().map(|task| task.id.clone());
467 let (result, snapshot) = self.store.update_with_snapshot(operation)?;
468 self.apply_snapshot(snapshot, &selected_category, selected_task.as_deref());
469 Ok(result)
470 }
471
472 fn report_store_error(&mut self, action: &str, error: StoreError) {
473 self.error(format!("{action}: {error}"));
474 }
475
476 pub(crate) fn poll_automatic_update_schedule(&mut self) -> bool {
480 self.poll_automatic_update_schedule_at(Utc::now().timestamp())
481 }
482
483 fn poll_automatic_update_schedule_at(&mut self, now: i64) -> bool {
484 if now < self.next_update_state_poll_at {
485 return false;
486 }
487 if self.update_job.is_some() {
488 self.next_update_state_poll_at = now.saturating_add(1);
489 return false;
490 }
491 let claim = match self
492 .update_state
493 .as_mut()
494 .map(|store| store.try_claim_automatic(now))
495 {
496 Some(Ok(claim)) => claim,
497 Some(Err(_)) => {
498 self.next_update_state_poll_at = now.saturating_add(FAILURE_RETRY_SECONDS);
499 return false;
500 }
501 None => {
502 self.next_update_state_poll_at = i64::MAX;
503 return false;
504 }
505 };
506 match claim {
507 AutomaticClaim::Claimed(lease) => {
508 self.next_update_state_poll_at = now.saturating_add(LEASE_SECONDS);
509 self.start_update_worker(UpdateJobKind::Automatic, Some(lease));
510 false
511 }
512 AutomaticClaim::Waiting(state) => self.apply_update_state(state, now),
513 }
514 }
515
516 fn refresh_update_state(&mut self, now: i64) -> bool {
517 let state = match self.update_state.as_ref().map(UpdateStateStore::snapshot) {
518 Some(Ok(state)) => state,
519 Some(Err(_)) => {
520 self.next_update_state_poll_at = now.saturating_add(FAILURE_RETRY_SECONDS);
521 return false;
522 }
523 None => {
524 self.next_update_state_poll_at = i64::MAX;
525 return false;
526 }
527 };
528 self.apply_update_state(state, now)
529 }
530
531 fn apply_update_state(&mut self, state: UpdateState, now: i64) -> bool {
532 let changed = self.sync_available_update(state.latest_version.as_deref());
533 self.schedule_update_state_poll(&state, now);
534 changed
535 }
536
537 fn schedule_update_state_poll(&mut self, state: &UpdateState, now: i64) {
538 const STATE_REFRESH_SECONDS: i64 = 60;
539 let deadline = if state.automatic_check_due(now) {
540 state
541 .lease_until
542 .filter(|_| state.lease_active(now))
543 .unwrap_or(now)
544 } else {
545 state.next_check_at.unwrap_or(now)
546 };
547 self.next_update_state_poll_at = deadline.min(now.saturating_add(STATE_REFRESH_SECONDS));
548 }
549
550 pub(crate) fn start_update_install(&mut self) {
552 self.update_notice = None;
553 if self
554 .update_job
555 .as_ref()
556 .is_some_and(|job| job.kind == UpdateJobKind::Install)
557 {
558 self.info("Already updating…");
559 return;
560 }
561
562 self.update_job = None;
566 let now = Utc::now().timestamp();
567 let lease = self
568 .update_state
569 .as_mut()
570 .and_then(|store| store.claim_manual(now).ok());
571 self.start_update_worker(UpdateJobKind::Install, lease);
572 }
573
574 fn start_update_worker(&mut self, kind: UpdateJobKind, lease: Option<UpdateLease>) {
575 let (tx, rx) = mpsc::channel();
576 let thread_name = match kind {
577 UpdateJobKind::Automatic => "mach-update-check",
578 UpdateJobKind::Install => "mach-update-install",
579 };
580 let conditional_etag = lease.as_ref().and_then(|lease| lease.etag.clone());
581 match std::thread::Builder::new()
582 .name(thread_name.into())
583 .spawn(move || {
584 let result = (|| -> Result<UpdateOutcome, CheckFailure> {
585 match kind {
586 UpdateJobKind::Automatic => {
587 crate::update::check_with_etag(conditional_etag.as_deref())
588 .map(UpdateOutcome::Automatic)
589 }
590 UpdateJobKind::Install => {
591 let CheckResponse::Modified { info, etag } =
592 crate::update::check_with_etag(None)?
593 else {
594 return Err(CheckFailure {
595 message: "GitHub returned 304 without a conditional request"
596 .into(),
597 retry_at: None,
598 });
599 };
600 if !info.newer {
601 return Ok(UpdateOutcome::UpToDate { info, etag });
602 }
603 let install = crate::update::install_with_progress(&info, |progress| {
604 let _ = tx.send(UpdateEvent::DownloadProgress(progress));
605 });
606 Ok(match install {
607 Ok(result) => UpdateOutcome::Installed { result, info, etag },
608 Err(message) => UpdateOutcome::InstallFailed {
609 message,
610 info,
611 etag,
612 },
613 })
614 }
615 }
616 })();
617 let _ = tx.send(UpdateEvent::Finished(Box::new(result)));
618 }) {
619 Ok(_) => {
620 self.update_job = Some(UpdateJob { rx, kind, lease });
621 if kind == UpdateJobKind::Install {
622 self.update_activity = Some(UpdateActivity::Checking);
623 self.dirty = true;
624 }
625 }
626 Err(error) => {
627 self.finish_update_state_failure(lease.as_ref(), Utc::now().timestamp(), None);
628 if kind == UpdateJobKind::Install {
629 self.update_activity = None;
630 self.error(format!("Could not start update: {error}"));
631 }
632 }
633 }
634 }
635
636 pub(crate) fn poll_update(&mut self) -> bool {
638 let mut changed = false;
639 loop {
640 let event = self
641 .update_job
642 .as_ref()
643 .map(|job| (job.kind, job.rx.try_recv()));
644 match event {
645 None => return changed,
646 Some((_, Ok(UpdateEvent::DownloadProgress(progress)))) => {
647 let activity = UpdateActivity::Downloading(progress);
648 if self.update_activity != Some(activity) {
649 self.update_activity = Some(activity);
650 changed = true;
651 }
652 }
653 Some((kind, Ok(UpdateEvent::Finished(result)))) => {
654 let lease = self.update_job.take().and_then(|job| job.lease);
655 changed |= self.update_activity.take().is_some();
656 return self.finish_update(kind, lease.as_ref(), *result) || changed;
657 }
658 Some((_, Err(TryRecvError::Empty))) => return changed,
659 Some((kind, Err(TryRecvError::Disconnected))) => {
660 let lease = self.update_job.take().and_then(|job| job.lease);
661 changed |= self.update_activity.take().is_some();
662 self.finish_update_state_failure(lease.as_ref(), Utc::now().timestamp(), None);
663 return if kind == UpdateJobKind::Install {
664 self.show_update_message("Update failed".into(), MessageKind::Error);
665 true
666 } else {
667 changed
668 };
669 }
670 }
671 }
672 }
673
674 fn finish_update(
675 &mut self,
676 kind: UpdateJobKind,
677 lease: Option<&UpdateLease>,
678 result: Result<UpdateOutcome, CheckFailure>,
679 ) -> bool {
680 let now = Utc::now().timestamp();
681 match result {
682 Ok(UpdateOutcome::Automatic(CheckResponse::Modified { info, etag })) => {
683 let (committed, changed) =
684 self.finish_update_state_modified(lease, now, etag.as_deref(), &info.latest);
685 if !committed {
686 return changed;
687 }
688 if info.newer {
689 self.set_available_update_notice(&info.latest) || changed
690 } else {
691 self.sync_available_update(None) || changed
692 }
693 }
694 Ok(UpdateOutcome::Automatic(CheckResponse::NotModified)) => {
695 if let (Some(store), Some(lease)) = (self.update_state.as_mut(), lease) {
696 let _ = store.finish_not_modified(lease, now);
697 }
698 self.refresh_update_state(now)
699 }
700 Ok(UpdateOutcome::UpToDate { info, etag }) => {
701 self.finish_update_state_modified(lease, now, etag.as_deref(), &info.latest);
702 self.show_update_message(info.summary(), MessageKind::Info);
703 true
704 }
705 Ok(UpdateOutcome::Installed { result, info, etag }) => {
706 self.finish_update_state_modified(lease, now, etag.as_deref(), &info.latest);
707 self.set_update_notice(UpdateNotice {
708 text: format!("Installed {} · restart mach", result.tag),
709 available_version: None,
710 })
711 }
712 Ok(UpdateOutcome::InstallFailed {
713 message,
714 info,
715 etag,
716 }) => {
717 self.finish_update_state_modified(lease, now, etag.as_deref(), &info.latest);
718 self.show_update_message(message, MessageKind::Error);
719 true
720 }
721 Err(error) if kind == UpdateJobKind::Install => {
722 self.finish_update_state_failure(lease, now, error.retry_at);
723 self.show_update_message(error.message, MessageKind::Error);
724 true
725 }
726 Err(error) => {
727 self.finish_update_state_failure(lease, now, error.retry_at);
728 false
729 }
730 }
731 }
732
733 fn finish_update_state_modified(
734 &mut self,
735 lease: Option<&UpdateLease>,
736 now: i64,
737 etag: Option<&str>,
738 latest_version: &str,
739 ) -> (bool, bool) {
740 let committed = match (self.update_state.as_mut(), lease) {
741 (Some(store), Some(lease)) => store
742 .finish_modified(lease, now, etag, latest_version)
743 .unwrap_or(false),
744 _ => true,
747 };
748 (committed, self.refresh_update_state(now))
749 }
750
751 fn finish_update_state_failure(
752 &mut self,
753 lease: Option<&UpdateLease>,
754 now: i64,
755 retry_at: Option<i64>,
756 ) {
757 if let (Some(store), Some(lease)) = (self.update_state.as_mut(), lease) {
758 let _ = store.finish_failure(lease, now, retry_at);
759 }
760 self.refresh_update_state(now);
761 }
762
763 fn sync_available_update(&mut self, available_version: Option<&str>) -> bool {
764 let available_version = available_version
765 .filter(|version| crate::update::is_newer(version, &self.version) == Some(true));
766 let Some(version) = available_version else {
767 if self
768 .update_notice
769 .as_ref()
770 .is_some_and(|notice| notice.available_version.is_some())
771 {
772 self.update_notice = None;
773 self.dirty = true;
774 return true;
775 }
776 return false;
777 };
778 if self.dismissed_update_version.as_deref() == Some(version)
779 || self
780 .update_notice
781 .as_ref()
782 .is_some_and(|notice| notice.available_version.is_none())
783 {
784 return false;
785 }
786 self.set_available_update_notice(version)
787 }
788
789 fn set_available_update_notice(&mut self, latest: &str) -> bool {
790 if self
791 .update_notice
792 .as_ref()
793 .and_then(|notice| notice.available_version.as_deref())
794 == Some(latest)
795 {
796 return false;
797 }
798 self.set_update_notice(UpdateNotice {
799 text: format!(
800 "v{} → v{} available · run /update to install",
801 self.version, latest
802 ),
803 available_version: Some(latest.to_string()),
804 })
805 }
806
807 fn set_update_notice(&mut self, notice: UpdateNotice) -> bool {
808 let visible = self.message.is_none();
809 self.update_notice = Some(notice);
810 if visible {
811 self.dirty = true;
812 }
813 visible
814 }
815
816 fn show_update_message(&mut self, text: String, kind: MessageKind) {
817 self.set_message_until(text, kind, Instant::now() + UPDATE_RESULT_DURATION);
818 }
819
820 pub fn mark_dirty(&mut self) {
821 self.dirty = true;
822 }
823
824 pub fn invalidate_preview(&mut self) {
825 self.preview_form = None;
826 self.preview_task_id = None;
827 self.preview_gen = 0;
828 }
829
830 pub fn ensure_preview(&mut self) {
832 let Some((id, generation)) = self.selected_task().map(|t| (t.id.clone(), self.data_gen))
833 else {
834 self.invalidate_preview();
835 return;
836 };
837 if self.preview_task_id.as_deref() == Some(id.as_str())
838 && self.preview_gen == generation
839 && self.preview_form.is_some()
840 {
841 return;
842 }
843 let Some(task) = self.selected_task().cloned() else {
844 self.invalidate_preview();
845 return;
846 };
847 let mut form = TaskForm::edit(&task);
848 form.set_categories(&self.categories, task.category_id.as_deref());
849 form.set_image_root(self.images.root().to_path_buf());
850 form.set_attachments(&self.attachments);
851 self.preview_form = Some(form);
852 self.preview_task_id = Some(id);
853 self.preview_gen = generation;
854 }
855
856 pub fn theme(&self) -> Theme {
857 Theme::new(&self.settings.selected_color)
858 }
859
860 pub fn current_category_id(&self) -> &str {
863 self.categories
864 .get(self.cat_index)
865 .map(|c| c.id.as_str())
866 .unwrap_or(ALL_CATEGORY)
867 }
868
869 pub fn is_all_view(&self) -> bool {
870 self.current_category_id() == ALL_CATEGORY
871 }
872
873 pub fn category_name(&self, id: &str) -> Option<&str> {
874 self.categories
875 .iter()
876 .find(|c| c.id == id)
877 .map(|c| c.name.as_str())
878 }
879
880 pub fn rebuild_view(&mut self) {
886 let selected_id = self.selected_task().map(|task| task.id.clone());
887 self.dirty = true;
888 if self.cat_progress.len() != self.categories.len() {
889 self.recompute_cat_progress();
890 }
891 let cat_id = self.current_category_id();
892 let all = cat_id == ALL_CATEGORY;
893 let hide_done = self.settings.hide_done;
894 let candidates: Vec<usize> = if self.searching {
895 let q = caseless_key(&self.search_query);
896 self.tasks
897 .iter()
898 .enumerate()
899 .filter(|(_, t)| {
900 !(hide_done && t.done)
901 && (contains_ignore_case(&t.title, &q) || body_contains(t, &q))
902 })
903 .map(|(i, _)| i)
904 .collect()
905 } else {
906 self.tasks
907 .iter()
908 .enumerate()
909 .filter(|(_, t)| {
910 (all || t.category_id.as_deref() == Some(cat_id)) && !(hide_done && t.done)
911 })
912 .map(|(i, _)| i)
913 .collect()
914 };
915
916 let multi = all || self.searching;
918 self.view = if multi {
919 self.stack_by_category(&candidates)
920 } else {
921 let mut view = candidates;
922 self.sort_within(&mut view);
923 view
924 };
925 if let Some(id) = selected_id {
926 self.select_task_by_id(&id);
927 } else if self.task_index >= self.view.len() {
928 self.task_index = self.view.len().saturating_sub(1);
929 }
930 self.list_rows = self.build_list_rows(multi);
931 }
932
933 fn build_list_rows(&self, multi: bool) -> Vec<TaskListRow> {
936 if !multi {
937 return (0..self.view.len()).map(TaskListRow::Task).collect();
938 }
939 let mut rows = Vec::with_capacity(self.view.len() + self.categories.len());
940 let mut prev: Option<Option<&str>> = None;
941 for (vi, &ti) in self.view.iter().enumerate() {
942 let key = self.tasks[ti].category_id.as_deref();
943 if prev != Some(key) {
944 let title = match key {
945 Some(id) => self.category_name(id).unwrap_or("Unknown").to_string(),
946 None => "Uncategorized".to_string(),
947 };
948 rows.push(TaskListRow::Separator { title });
949 prev = Some(key);
950 }
951 rows.push(TaskListRow::Task(vi));
952 }
953 rows
954 }
955
956 pub fn selected_visual_row(&self) -> Option<usize> {
958 self.list_rows
959 .iter()
960 .position(|r| matches!(r, TaskListRow::Task(i) if *i == self.task_index))
961 }
962
963 pub fn task_at_visual_row(&self, row: usize) -> Option<usize> {
965 match self.list_rows.get(row)? {
966 TaskListRow::Task(i) => Some(*i),
967 TaskListRow::Separator { .. } => None,
968 }
969 }
970
971 fn stack_by_category(&self, candidates: &[usize]) -> Vec<usize> {
973 use std::collections::HashMap;
974 let mut buckets: HashMap<Option<&str>, Vec<usize>> = HashMap::new();
975 for &i in candidates {
976 buckets
977 .entry(self.tasks[i].category_id.as_deref())
978 .or_default()
979 .push(i);
980 }
981 let mut view = Vec::with_capacity(candidates.len());
982 for cat in self.categories.iter().filter(|c| !c.is_all()) {
983 if let Some(mut group) = buckets.remove(&Some(cat.id.as_str())) {
984 self.sort_within(&mut group);
985 view.extend(group);
986 }
987 }
988 let mut rest: Vec<usize> = buckets.into_values().flatten().collect();
990 self.sort_within(&mut rest);
991 view.extend(rest);
992 view
993 }
994
995 fn sort_within(&self, view: &mut [usize]) {
997 match self.settings.sort.as_str() {
998 "important" => view.sort_by_key(|i| std::cmp::Reverse(self.tasks[*i].importance)),
999 "done" => view.sort_by_key(|i| self.tasks[*i].done),
1000 "due" => view.sort_by_cached_key(|i| {
1001 let due = &self.tasks[*i].due;
1002 (due.is_empty(), due::sort_key(due))
1003 }),
1004 _ => {} }
1006 }
1007
1008 pub fn task_count(&self) -> usize {
1009 self.view.len()
1010 }
1011
1012 pub fn visible_task(&self, pos: usize) -> Option<&Task> {
1013 self.view.get(pos).and_then(|index| self.tasks.get(*index))
1014 }
1015
1016 pub fn selected_task(&self) -> Option<&Task> {
1017 self.visible_task(self.task_index)
1018 }
1019
1020 pub fn done_count(&self) -> usize {
1021 self.view.iter().filter(|i| self.tasks[**i].done).count()
1022 }
1023
1024 pub fn move_task_selection(&mut self, delta: isize) {
1027 if self.view.is_empty() {
1028 return;
1029 }
1030 let last = self.view.len() - 1;
1031 let next = (self.task_index as isize + delta).clamp(0, last as isize) as usize;
1032 self.select_task(next);
1033 }
1034
1035 pub fn select_task(&mut self, pos: usize) {
1036 if pos < self.view.len() && pos != self.task_index {
1037 self.task_index = pos;
1038 self.cancel_pending();
1039 self.clear_typeahead();
1040 self.dirty = true;
1041 }
1042 }
1043
1044 pub fn select_first_task(&mut self) {
1045 self.select_task(0);
1046 }
1047
1048 pub fn select_last_task(&mut self) {
1049 self.select_task(self.view.len().saturating_sub(1));
1050 }
1051
1052 pub fn typeahead_jump(&mut self, c: char) {
1054 let now = Instant::now();
1055 if self
1056 .typeahead_at
1057 .is_none_or(|t| now.duration_since(t) > TYPEAHEAD_TIMEOUT)
1058 {
1059 self.typeahead.clear();
1060 }
1061 let limit = match self.focus {
1062 Focus::Tasks => MAX_TITLE_LEN,
1063 Focus::Sidebar => MAX_CATEGORY_NAME_LEN,
1064 };
1065 if self.typeahead.graphemes(true).count() < limit {
1066 self.typeahead.push(c);
1067 }
1068 self.typeahead_at = Some(now);
1069
1070 match self.focus {
1071 Focus::Tasks => {
1072 let titles = self.view.iter().map(|&i| self.tasks[i].title.as_str());
1073 if let Some(pos) = crate::fuzzy::best_index(&self.typeahead, titles) {
1074 self.task_index = pos;
1075 self.cancel_pending();
1076 }
1077 }
1078 Focus::Sidebar => {
1079 let names = self.categories.iter().map(|c| c.name.as_str());
1080 if let Some(pos) = crate::fuzzy::best_index(&self.typeahead, names)
1081 && pos != self.cat_index
1082 {
1083 self.cat_index = pos;
1084 self.cancel_pending();
1085 self.on_category_changed();
1086 }
1087 }
1088 }
1089 }
1090
1091 pub fn move_category_selection(&mut self, delta: isize) {
1092 if self.categories.is_empty() {
1093 return;
1094 }
1095 let last = self.categories.len() - 1;
1096 let next = (self.cat_index as isize + delta).clamp(0, last as isize) as usize;
1097 self.select_category(next);
1098 }
1099
1100 pub fn navigate_vertical(&mut self, delta: isize) {
1102 if delta == 0 {
1103 return;
1104 }
1105 self.cancel_pending();
1106 match self.focus {
1107 Focus::Tasks => {
1108 if self.view.is_empty() {
1109 return;
1110 }
1111 self.move_task_selection(delta);
1112 }
1113 Focus::Sidebar => {
1114 self.move_category_selection(delta);
1115 }
1116 }
1117 }
1118
1119 pub fn select_category(&mut self, index: usize) {
1120 if index < self.categories.len() && index != self.cat_index {
1121 self.cat_index = index;
1122 self.cancel_pending();
1123 self.clear_typeahead();
1124 self.on_category_changed();
1125 }
1126 }
1127
1128 pub fn select_last_category(&mut self) {
1129 self.select_category(self.categories.len().saturating_sub(1));
1130 }
1131
1132 fn on_category_changed(&mut self) {
1133 self.searching = false;
1134 self.search_query.clear();
1135 self.task_index = 0;
1136 self.rebuild_view();
1137 }
1138
1139 pub fn toggle_focus(&mut self) {
1140 let next = match self.focus {
1141 Focus::Sidebar => Focus::Tasks,
1142 Focus::Tasks => Focus::Sidebar,
1143 };
1144 let _ = self.set_focus(next);
1145 }
1146
1147 pub fn set_focus(&mut self, focus: Focus) -> bool {
1149 if self.searching && focus == Focus::Sidebar {
1150 return false;
1151 }
1152 if self.focus != focus {
1153 self.focus = focus;
1154 self.cancel_pending();
1155 self.clear_typeahead();
1156 self.dirty = true;
1157 }
1158 true
1159 }
1160
1161 pub fn cancel_pending(&mut self) {
1162 if self.pending.take().is_some() && self.message.take().is_some() {
1163 self.dirty = true;
1164 }
1165 }
1166
1167 fn clear_typeahead(&mut self) {
1168 self.typeahead.clear();
1169 self.typeahead_at = None;
1170 }
1171
1172 fn recompute_cat_progress(&mut self) {
1175 let mut all_done = 0usize;
1176 let mut all_total = 0usize;
1177 let mut per: Vec<(usize, usize)> = self.categories.iter().map(|_| (0, 0)).collect();
1178 for t in &self.tasks {
1179 all_total += 1;
1180 if t.done {
1181 all_done += 1;
1182 }
1183 if let Some(cid) = t.category_id.as_deref()
1184 && let Some(idx) = self.categories.iter().position(|c| c.id == cid)
1185 {
1186 per[idx].1 += 1;
1187 if t.done {
1188 per[idx].0 += 1;
1189 }
1190 }
1191 }
1192 for (i, cat) in self.categories.iter().enumerate() {
1193 if cat.is_all() {
1194 per[i] = (all_done, all_total);
1195 }
1196 }
1197 self.cat_progress = per;
1198 }
1199
1200 fn select_task_by_id(&mut self, id: &str) {
1202 if let Some(pos) = self.view.iter().position(|i| self.tasks[*i].id == id) {
1203 self.task_index = pos;
1204 } else if self.task_index >= self.view.len() {
1205 self.task_index = self.view.len().saturating_sub(1);
1206 }
1207 }
1208
1209 pub fn toggle_done(&mut self, pos: usize) {
1210 if let Some(&i) = self.view.get(pos) {
1211 let id = self.tasks[i].id.clone();
1212 match self.update_store(|data| data.toggle_task_done(&id)) {
1213 Ok(_) => self.select_task_by_id(&id),
1214 Err(error) => self.report_store_error("Could not update task", error),
1215 }
1216 }
1217 }
1218
1219 pub fn cycle_importance(&mut self, pos: usize) {
1221 if let Some(&i) = self.view.get(pos) {
1222 let id = self.tasks[i].id.clone();
1223 match self.update_store(|data| {
1224 let importance =
1225 (data.task(&id)?.importance + 1) % (crate::model::MAX_IMPORTANCE + 1);
1226 data.set_task_importance(&id, importance)
1227 }) {
1228 Ok(_) => self.select_task_by_id(&id),
1229 Err(error) => self.report_store_error("Could not update task", error),
1230 }
1231 }
1232 }
1233
1234 pub fn move_task_order(&mut self, delta: isize) -> bool {
1238 if delta == 0 || self.settings.sort != "manual" || self.searching {
1239 return false;
1240 }
1241 let Some(current) = self.selected_task().cloned() else {
1242 return false;
1243 };
1244 let target_view = self.task_index as isize + delta.signum();
1245 if !(0..self.view.len() as isize).contains(&target_view) {
1246 return false;
1247 }
1248 let Some(target) = self.visible_task(target_view as usize) else {
1249 return false;
1250 };
1251 if target.category_id != current.category_id {
1252 return false;
1253 }
1254 let target_id = target.id.clone();
1255 let id = current.id;
1256 let position = if delta.is_negative() {
1257 RelativePosition::Before
1258 } else {
1259 RelativePosition::After
1260 };
1261 match self.update_store(|data| data.move_task_relative(&id, &target_id, position)) {
1262 Ok(_) => {
1263 self.select_task_by_id(&id);
1264 true
1265 }
1266 Err(error) => {
1267 self.report_store_error("Could not reorder task", error);
1268 false
1269 }
1270 }
1271 }
1272
1273 pub fn open_new_task(&mut self) {
1275 if self.tasks.len() >= MAX_TASK_COUNT {
1276 self.error(format!(
1277 "You already have {MAX_TASK_COUNT} tasks in hand. Maybe deal with them first :)"
1278 ));
1279 return;
1280 }
1281 let mut form = TaskForm::new();
1282 let category = (!self.is_all_view()).then(|| self.current_category_id());
1283 form.set_categories(&self.categories, category);
1284 form.set_image_root(self.images.root().to_path_buf());
1285 form.set_attachments(&self.attachments);
1286 self.task_edit_base = None;
1287 self.form = Some(form);
1288 self.mode = Mode::TaskForm;
1289 }
1290
1291 pub fn open_edit_task(&mut self) {
1293 if let Some(task) = self.selected_task().cloned() {
1294 let mut form = TaskForm::edit(&task);
1295 form.set_categories(&self.categories, task.category_id.as_deref());
1296 form.set_image_root(self.images.root().to_path_buf());
1297 form.set_attachments(&self.attachments);
1298 self.images.prefetch(form.body.images());
1301 self.task_edit_base = Some(task);
1302 self.form = Some(form);
1303 self.mode = Mode::TaskForm;
1304 }
1305 }
1306
1307 pub fn close_form(&mut self) {
1308 self.form = None;
1309 self.task_edit_base = None;
1310 self.mode = Mode::Normal;
1311 self.focus = Focus::Tasks;
1312 self.images.release_form_graphics();
1315 self.images.clear_preview();
1316 self.cancel_pending();
1317 }
1318
1319 pub fn submit_form(&mut self) {
1321 let Some(form) = &mut self.form else { return };
1322 let Some(draft) = form.submit() else { return };
1323 let saved = match form.editing.clone() {
1324 Some(uuid) => self.update_task(&uuid, &draft),
1325 None => self.create_task(&draft).is_some(),
1326 };
1327 if saved {
1328 self.close_form();
1329 }
1330 }
1331
1332 pub fn create_task(&mut self, draft: &TaskDraft) -> Option<String> {
1335 let (inline_due, title) = due::parse(draft.title.trim());
1336 if title.is_empty() || self.tasks.len() >= MAX_TASK_COUNT {
1337 return None;
1338 }
1339 let due = if draft.due.is_empty() {
1340 &inline_due
1341 } else {
1342 &draft.due
1343 };
1344 let body = draft.body.clone();
1345 let category_id = draft.category_id.clone();
1346 let importance = draft.importance;
1347 let task = match self.update_store(|data| {
1348 data.create_task(title, body, due.to_string(), importance, category_id)
1349 }) {
1350 Ok(task) => task,
1351 Err(error) => {
1352 let message = error.to_string();
1353 if let Some(form) = &mut self.form {
1354 form.error = Some(message.clone());
1355 }
1356 self.report_store_error("Could not create task", error);
1357 return None;
1358 }
1359 };
1360 let id = task.id;
1361 self.searching = false;
1362 self.search_query.clear();
1363 self.rebuild_view();
1364 self.select_task_by_id(&id);
1365 Some(id)
1366 }
1367
1368 pub fn update_task(&mut self, id: &str, draft: &TaskDraft) -> bool {
1369 let (inline_due, title) = due::parse(draft.title.trim());
1370 if title.is_empty() {
1371 return false;
1372 }
1373 let due = if draft.due.is_empty() {
1374 &inline_due
1375 } else {
1376 &draft.due
1377 };
1378 let expected = self.task_edit_base.clone();
1379 let id = id.to_string();
1380 let due = due.to_string();
1381 let patch = match expected.as_ref() {
1382 Some(base) => TaskPatch {
1383 title: (title != base.title).then_some(title),
1384 body: (draft.body != base.body).then(|| draft.body.clone()),
1385 due: (due != base.due).then_some(due),
1386 importance: (draft.importance != base.importance).then_some(draft.importance),
1387 category_id: (draft.category_id != base.category_id)
1388 .then(|| draft.category_id.clone()),
1389 ..TaskPatch::default()
1390 },
1391 None => TaskPatch {
1392 title: Some(title),
1393 body: Some(draft.body.clone()),
1394 due: Some(due),
1395 importance: Some(draft.importance),
1396 category_id: Some(draft.category_id.clone()),
1397 ..TaskPatch::default()
1398 },
1399 };
1400 match self.update_store(|data| {
1401 if let Some(expected) = &expected {
1402 data.edit_task_if_unchanged(expected, patch)
1403 } else {
1404 data.edit_task(&id, patch)
1405 }
1406 }) {
1407 Ok(_) => {
1408 self.select_task_by_id(&id);
1409 true
1410 }
1411 Err(error) => {
1412 let message = edit_error_message(&error);
1413 if let Some(form) = &mut self.form {
1414 form.error = Some(message);
1415 }
1416 self.report_store_error("Could not update task", error);
1417 false
1418 }
1419 }
1420 }
1421
1422 pub fn delete_task(&mut self, pos: usize) {
1423 let Some(id) = self.visible_task(pos).map(|task| task.id.clone()) else {
1424 return;
1425 };
1426 self.delete_task_by_id(&id);
1427 }
1428
1429 pub fn delete_task_by_id(&mut self, id: &str) -> bool {
1430 let id = id.to_string();
1431 if let Err(error) = self.update_store(|data| data.delete_task(&id)) {
1432 self.report_store_error("Could not delete task", error);
1433 return false;
1434 }
1435 self.cancel_pending();
1436 true
1437 }
1438
1439 pub fn purge(&mut self) -> usize {
1442 let ids = self.purge_candidate_ids();
1443 self.purge_ids(&ids)
1444 }
1445
1446 pub fn purge_candidate_ids(&self) -> Vec<String> {
1448 let everywhere = self.is_all_view();
1449 let category = self.current_category_id();
1450 self.tasks
1451 .iter()
1452 .filter(|task| {
1453 task.done && (everywhere || task.category_id.as_deref() == Some(category))
1454 })
1455 .map(|task| task.id.clone())
1456 .collect()
1457 }
1458
1459 pub fn purge_ids(&mut self, ids: &[String]) -> usize {
1461 let ids = ids.to_vec();
1462 match self.update_store(|data| data.purge_completed_ids(&ids)) {
1463 Ok(removed) => {
1464 self.cancel_pending();
1465 removed.len()
1466 }
1467 Err(error) => {
1468 self.report_store_error("Could not purge completed tasks", error);
1469 0
1470 }
1471 }
1472 }
1473
1474 pub fn toggle_hide_done(&mut self) -> Option<bool> {
1476 match self.update_store(|data| {
1477 data.update_settings(|settings| settings.hide_done = !settings.hide_done)
1478 }) {
1479 Ok(settings) => Some(settings.hide_done),
1480 Err(error) => {
1481 self.report_store_error("Could not update settings", error);
1482 None
1483 }
1484 }
1485 }
1486
1487 pub fn open_new_category(&mut self) {
1491 let real = self.categories.iter().filter(|c| !c.is_all()).count();
1493 if real >= MAX_CATEGORY_COUNT {
1494 self.error(format!("At most {MAX_CATEGORY_COUNT} categories"));
1495 return;
1496 }
1497 self.category_edit_base = None;
1498 self.category_form = Some(CategoryForm::new());
1499 self.mode = Mode::CategoryForm;
1500 }
1501
1502 pub fn open_edit_category(&mut self) {
1505 if self.is_all_view() {
1506 return;
1507 }
1508 if let Some(category) = self.categories.get(self.cat_index).cloned() {
1509 self.category_form = Some(CategoryForm::edit(&category));
1510 self.category_edit_base = Some(category);
1511 self.mode = Mode::CategoryForm;
1512 }
1513 }
1514
1515 pub fn close_category_form(&mut self) {
1516 self.category_form = None;
1517 self.category_edit_base = None;
1518 self.mode = Mode::Normal;
1519 self.cancel_pending();
1520 }
1521
1522 pub fn submit_category_form(&mut self) {
1523 let existing: Vec<(String, String)> = self
1524 .categories
1525 .iter()
1526 .filter(|category| !category.is_all())
1527 .map(|category| (category.id.clone(), category.name.clone()))
1528 .collect();
1529 let Some(form) = &mut self.category_form else {
1530 return;
1531 };
1532 let Some((name, description)) = form.submit_with(|name, editing| {
1533 let duplicate = existing.iter().any(|(id, existing_name)| {
1534 Some(id.as_str()) != editing
1535 && caseless_key(existing_name.trim()) == caseless_key(name.trim())
1536 });
1537 if duplicate {
1538 Err("A category with that name already exists".to_string())
1539 } else {
1540 Ok(())
1541 }
1542 }) else {
1543 return;
1544 };
1545 let name = truncate_chars(&name, MAX_CATEGORY_NAME_LEN);
1546 let editing = form.editing.clone();
1547 let expected = self.category_edit_base.clone();
1548 let saved = match editing {
1549 Some(id) => {
1550 let patch = match expected.as_ref() {
1551 Some(base) => CategoryPatch {
1552 name: (name != base.name).then_some(name),
1553 description: (description != base.description).then_some(description),
1554 },
1555 None => CategoryPatch {
1556 name: Some(name),
1557 description: Some(description),
1558 },
1559 };
1560 match self.update_store(|data| {
1561 if let Some(expected) = &expected {
1562 data.edit_category_if_unchanged(expected, patch)
1563 } else {
1564 data.edit_category(&id, patch)
1565 }
1566 }) {
1567 Ok(_) => true,
1568 Err(error) => {
1569 let message = edit_error_message(&error);
1570 if let Some(form) = &mut self.category_form {
1571 form.error = Some(message);
1572 }
1573 self.report_store_error("Could not update category", error);
1574 false
1575 }
1576 }
1577 }
1578 None => match self.update_store(|data| data.create_category(name, description)) {
1579 Ok(category) => {
1580 self.cat_index = self
1581 .categories
1582 .iter()
1583 .position(|item| item.id == category.id)
1584 .unwrap_or(0);
1585 self.on_category_changed();
1586 true
1587 }
1588 Err(error) => {
1589 let message = error.to_string();
1590 if let Some(form) = &mut self.category_form {
1591 form.error = Some(message);
1592 }
1593 self.report_store_error("Could not create category", error);
1594 false
1595 }
1596 },
1597 };
1598 if saved {
1599 self.close_category_form();
1600 }
1601 }
1602
1603 pub fn delete_category(&mut self) {
1606 if self.is_all_view() {
1607 return;
1608 }
1609 let id = self.current_category_id().to_string();
1610 let _ = self.delete_category_by_id(&id);
1611 }
1612
1613 pub fn delete_category_by_id(&mut self, id: &str) -> bool {
1614 let Some(category) = self.categories.iter().find(|category| category.id == id) else {
1615 return false;
1616 };
1617 if category.is_all() {
1618 return false;
1619 }
1620 let id = id.to_string();
1621 match self.update_store(|data| data.delete_category(&id)) {
1622 Ok(_) => {
1623 self.cancel_pending();
1624 self.cat_index = 0;
1625 self.on_category_changed();
1626 true
1627 }
1628 Err(error) => {
1629 self.report_store_error("Could not delete category", error);
1630 false
1631 }
1632 }
1633 }
1634
1635 pub fn move_category_order(&mut self, delta: isize) -> bool {
1637 if delta == 0 || self.is_all_view() || self.searching {
1638 return false;
1639 }
1640 let target_display = self.cat_index as isize + delta.signum();
1641 if !(1..self.categories.len() as isize).contains(&target_display) {
1642 return false;
1643 }
1644 let id = self.current_category_id().to_string();
1645 let target_id = self.categories[target_display as usize].id.clone();
1646 let position = if delta.is_negative() {
1647 RelativePosition::Before
1648 } else {
1649 RelativePosition::After
1650 };
1651 match self.update_store(|data| data.move_category_relative(&id, &target_id, position)) {
1652 Ok(_) => {
1653 self.cat_index = self
1654 .categories
1655 .iter()
1656 .position(|category| category.id == id)
1657 .unwrap_or(0);
1658 self.on_category_changed();
1659 true
1660 }
1661 Err(error) => {
1662 self.report_store_error("Could not reorder category", error);
1663 false
1664 }
1665 }
1666 }
1667
1668 pub fn category_progress(&self, id: &str) -> (usize, usize) {
1670 if let Some(idx) = self.categories.iter().position(|c| c.id == id)
1671 && let Some(&p) = self.cat_progress.get(idx)
1672 {
1673 return p;
1674 }
1675 (0, 0)
1676 }
1677
1678 pub fn open_slash(&mut self) {
1682 if self.searching {
1683 self.end_search();
1684 }
1685 if let Some(version) = self
1686 .update_notice
1687 .as_ref()
1688 .and_then(|notice| notice.available_version.clone())
1689 {
1690 self.dismissed_update_version = Some(version);
1691 }
1692 self.update_notice = None;
1693 self.mode = Mode::Slash;
1694 self.input = TextInput::new("", 128);
1695 self.slash_index = 0;
1696 self.dirty = true;
1697 }
1698
1699 pub fn start_search(&mut self, query: &str) {
1701 self.mode = Mode::Search;
1702 self.focus = Focus::Tasks;
1703 self.input = TextInput::new(query, MAX_TITLE_LEN);
1704 self.search_query = query.to_string();
1705 self.searching = true;
1706 self.task_index = 0;
1707 self.rebuild_view();
1708 }
1709
1710 pub fn update_search(&mut self) {
1711 self.search_query = self.input.value();
1712 self.searching = true;
1713 self.task_index = 0;
1714 self.rebuild_view();
1715 }
1716
1717 pub fn resume_search(&mut self) {
1720 if !self.searching {
1721 return;
1722 }
1723 self.mode = Mode::Search;
1724 self.input = TextInput::new(&self.search_query, MAX_TITLE_LEN);
1725 self.dirty = true;
1726 }
1727
1728 pub fn end_search(&mut self) {
1729 self.searching = false;
1730 self.search_query.clear();
1731 self.task_index = 0;
1732 self.mode = Mode::Normal;
1733 self.rebuild_view();
1734 }
1735
1736 pub fn clamp_slash_index(&mut self) {
1737 let n = crate::slash::matching(&self.input.value()).len();
1738 if n == 0 {
1739 self.slash_index = 0;
1740 } else {
1741 self.slash_index = self.slash_index.min(n - 1);
1742 }
1743 }
1744
1745 pub fn info(&mut self, text: impl Into<String>) {
1748 self.set_message(text.into(), MessageKind::Info, 2000);
1749 }
1750
1751 pub fn error(&mut self, text: impl Into<String>) {
1752 self.set_message(text.into(), MessageKind::Error, 2500);
1753 }
1754
1755 pub(crate) fn status_message(&self) -> Option<(&str, MessageKind)> {
1756 self.message
1757 .as_ref()
1758 .map(|message| (message.text.as_str(), message.kind))
1759 .or_else(|| {
1760 self.update_notice
1761 .as_ref()
1762 .map(|notice| (notice.text.as_str(), MessageKind::Info))
1763 })
1764 }
1765
1766 pub(crate) fn update_activity(&self) -> Option<UpdateActivity> {
1767 self.update_activity
1768 }
1769
1770 pub(crate) fn update_work_active(&self) -> bool {
1771 self.update_job
1772 .as_ref()
1773 .is_some_and(|job| job.kind == UpdateJobKind::Install)
1774 }
1775
1776 fn set_message(&mut self, text: String, kind: MessageKind, millis: u64) {
1777 self.set_message_until(text, kind, Instant::now() + Duration::from_millis(millis));
1778 }
1779
1780 fn set_message_until(&mut self, text: String, kind: MessageKind, until: Instant) {
1781 self.pending = None;
1785 self.message = Some(Message { text, kind, until });
1786 self.dirty = true;
1787 }
1788
1789 pub fn expire_message(&mut self) -> bool {
1791 if let Some(m) = &self.message
1792 && Instant::now() >= m.until
1793 {
1794 self.pending = None;
1795 self.message = None;
1796 self.dirty = true;
1797 return true;
1798 }
1799 false
1800 }
1801
1802 pub fn ask_confirm(&mut self, confirm: Confirm, prompt: impl Into<String>) {
1804 let until = Instant::now() + CONFIRM_WINDOW;
1805 self.set_message_until(prompt.into(), MessageKind::Info, until);
1806 self.pending = Some((confirm, until));
1807 }
1808
1809 pub fn awaiting(&self, confirm: Confirm) -> bool {
1811 matches!(&self.pending, Some((armed, until)) if *armed == confirm && Instant::now() < *until)
1812 }
1813
1814 pub fn pending_confirmation(&self) -> Option<&Confirm> {
1815 self.pending
1816 .as_ref()
1817 .filter(|(_, until)| Instant::now() < *until)
1818 .map(|(confirm, _)| confirm)
1819 }
1820
1821 pub fn cycle_setting(&mut self, index: usize, delta: isize) {
1825 use crate::settings::{DATE_FORMATS, PREVIEW_POSITIONS, SORTS, THEMES, cycle_by};
1826 if index >= SETTINGS_ITEMS.len() {
1827 return;
1828 }
1829 if let Err(error) = self.update_store(|data| {
1830 data.update_settings(|settings| match index {
1831 0 => settings.sort = cycle_by(&SORTS, &settings.sort, delta),
1832 1 => settings.selected_color = cycle_by(&THEMES, &settings.selected_color, delta),
1833 2 => settings.date_format = cycle_by(&DATE_FORMATS, &settings.date_format, delta),
1834 3 => {
1835 settings.preview_position =
1836 cycle_by(&PREVIEW_POSITIONS, &settings.preview_position, delta)
1837 }
1838 _ => {}
1839 })
1840 }) {
1841 self.report_store_error("Could not update settings", error);
1842 }
1843 }
1844
1845 pub fn setting_value(&self, index: usize) -> String {
1846 match index {
1847 0 => crate::settings::sort_label(&self.settings.sort).to_string(),
1848 1 => crate::settings::theme_label(&self.settings.selected_color),
1849 2 => self.settings.date_format.clone(),
1850 3 => {
1851 crate::settings::preview_position_label(&self.settings.preview_position).to_string()
1852 }
1853 _ => String::new(),
1854 }
1855 }
1856}
1857
1858fn contains_ignore_case(haystack: &str, folded_needle: &str) -> bool {
1861 if folded_needle.is_empty() {
1862 return true;
1863 }
1864 if haystack.is_ascii() && folded_needle.is_ascii() {
1865 return haystack
1866 .as_bytes()
1867 .windows(folded_needle.len())
1868 .any(|w| w.eq_ignore_ascii_case(folded_needle.as_bytes()));
1869 }
1870 caseless_key(haystack).contains(folded_needle)
1871}
1872
1873fn body_contains(task: &Task, query: &str) -> bool {
1875 task.body.iter().any(|block| match block {
1876 crate::model::Block::Text { text }
1877 | crate::model::Block::Todo { text, .. }
1878 | crate::model::Block::Bullet { text }
1879 | crate::model::Block::Number { text }
1880 | crate::model::Block::Link { url: text } => contains_ignore_case(text, query),
1881 crate::model::Block::Image { .. } => false,
1882 })
1883}
1884
1885fn edit_error_message(error: &StoreError) -> String {
1886 match error {
1887 StoreError::StaleEntity { .. } => {
1888 format!("{error}; close and reopen the editor to load the latest values")
1889 }
1890 _ => error.to_string(),
1891 }
1892}
1893
1894pub fn truncate_chars(s: &str, max: usize) -> String {
1895 s.graphemes(true).take(max).collect()
1896}
1897
1898#[cfg(test)]
1899mod tests {
1900 use super::*;
1901
1902 fn update_result(newer: bool) -> crate::update::CheckResult {
1903 crate::update::CheckResult {
1904 current: "0.2.0".into(),
1905 latest: if newer { "0.3.0" } else { "0.2.0" }.into(),
1906 tag: if newer { "v0.3.0" } else { "v0.2.0" }.into(),
1907 newer,
1908 prerelease: false,
1909 release_url: "https://example.test/release".into(),
1910 asset_name: "mach-aarch64-apple-darwin".into(),
1911 asset_url: "https://example.test/binary".into(),
1912 checksums_url: "https://example.test/SHA256SUMS".into(),
1913 }
1914 }
1915
1916 fn automatic_outcome(newer: bool) -> UpdateOutcome {
1917 UpdateOutcome::Automatic(CheckResponse::Modified {
1918 info: update_result(newer),
1919 etag: None,
1920 })
1921 }
1922
1923 fn update_failure(message: &str) -> CheckFailure {
1924 CheckFailure {
1925 message: message.into(),
1926 retry_at: None,
1927 }
1928 }
1929
1930 fn finished(result: Result<UpdateOutcome, CheckFailure>) -> UpdateEvent {
1931 UpdateEvent::Finished(Box::new(result))
1932 }
1933
1934 fn claim_automatic_lease(app: &mut App, now: i64) -> UpdateLease {
1935 let AutomaticClaim::Claimed(lease) = app
1936 .update_state
1937 .as_mut()
1938 .expect("test update state")
1939 .try_claim_automatic(now)
1940 .unwrap()
1941 else {
1942 panic!("automatic update should be due");
1943 };
1944 lease
1945 }
1946
1947 #[test]
1948 fn typeahead_buffer_is_bounded_by_the_longest_searchable_title() {
1949 let store = Store::open_in_memory_with_paths("/tmp/mach-typeahead-test")
1950 .expect("open in-memory store");
1951 let mut app = App::with_store("test", store).expect("build app");
1952 app.mode = Mode::Normal;
1953
1954 for _ in 0..(MAX_TITLE_LEN * 2) {
1955 app.typeahead_jump('x');
1956 }
1957
1958 assert!(
1959 app.typeahead.graphemes(true).count() <= MAX_TITLE_LEN,
1960 "a held key must not grow the navigation query without bound"
1961 );
1962 }
1963
1964 #[test]
1965 fn automatic_update_claim_is_shared_across_task_stores() {
1966 let root = std::env::temp_dir().join(format!(
1967 "mach-update-claim-{}-{}",
1968 std::process::id(),
1969 uuid::Uuid::new_v4()
1970 ));
1971 let now = 1_800_000_000;
1972 let state_path = root.join("global").join("update.db");
1973 let mut first = App::with_store_and_update_state(
1974 "test",
1975 Store::open(root.join("one")).unwrap(),
1976 UpdateStateStore::open(&state_path),
1977 )
1978 .unwrap();
1979
1980 assert!(matches!(
1981 first
1982 .update_state
1983 .as_mut()
1984 .unwrap()
1985 .try_claim_automatic(now)
1986 .unwrap(),
1987 AutomaticClaim::Claimed(_)
1988 ));
1989 drop(first);
1990
1991 let mut second = App::with_store_and_update_state(
1992 "test",
1993 Store::open(root.join("two")).unwrap(),
1994 UpdateStateStore::open(&state_path),
1995 )
1996 .unwrap();
1997 assert!(matches!(
1998 second
1999 .update_state
2000 .as_mut()
2001 .unwrap()
2002 .try_claim_automatic(now)
2003 .unwrap(),
2004 AutomaticClaim::Waiting(_)
2005 ));
2006 drop(second);
2007 std::fs::remove_dir_all(root).unwrap();
2008 }
2009
2010 #[test]
2011 fn failed_automatic_update_check_retries_before_the_daily_interval() {
2012 let store = Store::open_in_memory_with_paths("/tmp/mach-update-retry-test").unwrap();
2013 let mut app = App::with_store("test", store).unwrap();
2014 let now = Utc::now().timestamp();
2015
2016 let lease = claim_automatic_lease(&mut app, now);
2017 let (tx, rx) = mpsc::channel();
2018 app.update_job = Some(UpdateJob {
2019 rx,
2020 kind: UpdateJobKind::Automatic,
2021 lease: Some(lease),
2022 });
2023 tx.send(finished(Err(update_failure("offline")))).unwrap();
2024
2025 assert!(!app.poll_update());
2026 let retry_at = app
2027 .update_state
2028 .as_ref()
2029 .unwrap()
2030 .snapshot()
2031 .unwrap()
2032 .next_check_at
2033 .expect("failed check schedules a retry");
2034 assert!(retry_at < now + 24 * 60 * 60);
2035 assert!(matches!(
2036 app.update_state
2037 .as_mut()
2038 .unwrap()
2039 .try_claim_automatic(retry_at)
2040 .unwrap(),
2041 AutomaticClaim::Claimed(_)
2042 ));
2043 }
2044
2045 #[test]
2046 fn available_update_notice_survives_reopening_the_app() {
2047 let dir = std::env::temp_dir().join(format!(
2048 "mach-update-notice-{}-{}",
2049 std::process::id(),
2050 uuid::Uuid::new_v4()
2051 ));
2052 let state_path = dir.join("global-update.db");
2053 let mut app = App::with_store_and_update_state(
2054 "0.2.0",
2055 Store::open(dir.join("tasks")).unwrap(),
2056 UpdateStateStore::open(&state_path),
2057 )
2058 .unwrap();
2059 let lease = claim_automatic_lease(&mut app, Utc::now().timestamp());
2060 let (tx, rx) = mpsc::channel();
2061 app.update_job = Some(UpdateJob {
2062 rx,
2063 kind: UpdateJobKind::Automatic,
2064 lease: Some(lease),
2065 });
2066 tx.send(finished(Ok(automatic_outcome(true)))).unwrap();
2067
2068 assert!(app.poll_update());
2069 drop(app);
2070
2071 let reopened = App::with_store_and_update_state(
2072 "0.2.0",
2073 Store::open(dir.join("tasks")).unwrap(),
2074 UpdateStateStore::open(&state_path),
2075 )
2076 .unwrap();
2077 assert!(
2078 reopened
2079 .status_message()
2080 .is_some_and(|(text, _)| { text.contains("v0.2.0 → v0.3.0 available") })
2081 );
2082 drop(reopened);
2083 std::fs::remove_dir_all(dir).unwrap();
2084 }
2085
2086 #[test]
2087 fn available_update_notice_reaches_an_already_running_instance() {
2088 let root = std::env::temp_dir().join(format!(
2089 "mach-running-update-notice-{}-{}",
2090 std::process::id(),
2091 uuid::Uuid::new_v4()
2092 ));
2093 let state_path = root.join("global-update.db");
2094 let mut checker = App::with_store_and_update_state(
2095 "0.2.0",
2096 Store::open(root.join("tasks-one")).unwrap(),
2097 UpdateStateStore::open(&state_path),
2098 )
2099 .unwrap();
2100 let mut observer = App::with_store_and_update_state(
2101 "0.2.0",
2102 Store::open(root.join("tasks-two")).unwrap(),
2103 UpdateStateStore::open(&state_path),
2104 )
2105 .unwrap();
2106 let now = Utc::now().timestamp();
2107 let lease = claim_automatic_lease(&mut checker, now);
2108 let (tx, rx) = mpsc::channel();
2109 checker.update_job = Some(UpdateJob {
2110 rx,
2111 kind: UpdateJobKind::Automatic,
2112 lease: Some(lease),
2113 });
2114 tx.send(finished(Ok(automatic_outcome(true)))).unwrap();
2115
2116 assert!(checker.poll_update());
2117 assert!(observer.poll_automatic_update_schedule_at(now + 1));
2118 assert!(
2119 observer
2120 .status_message()
2121 .is_some_and(|(text, _)| { text.contains("v0.2.0 → v0.3.0 available") })
2122 );
2123 drop(checker);
2124 drop(observer);
2125 std::fs::remove_dir_all(root).unwrap();
2126 }
2127
2128 #[test]
2129 fn cached_latest_release_is_compared_per_running_binary() {
2130 let root = std::env::temp_dir().join(format!(
2131 "mach-multiple-binaries-{}-{}",
2132 std::process::id(),
2133 uuid::Uuid::new_v4()
2134 ));
2135 let state_path = root.join("global-update.db");
2136 let now = 1_800_000_000;
2137 let mut state = UpdateStateStore::open(&state_path).unwrap();
2138 let AutomaticClaim::Claimed(lease) = state.try_claim_automatic(now).unwrap() else {
2139 panic!("first check should be due");
2140 };
2141 state.finish_modified(&lease, now, None, "0.3.0").unwrap();
2142 drop(state);
2143
2144 let current = App::with_store_and_update_state(
2145 "0.3.0",
2146 Store::open(root.join("current-tasks")).unwrap(),
2147 UpdateStateStore::open(&state_path),
2148 )
2149 .unwrap();
2150 assert!(current.status_message().is_none());
2151 drop(current);
2152
2153 let older = App::with_store_and_update_state(
2154 "0.2.0",
2155 Store::open(root.join("older-tasks")).unwrap(),
2156 UpdateStateStore::open(&state_path),
2157 )
2158 .unwrap();
2159 assert!(
2160 older
2161 .status_message()
2162 .is_some_and(|(text, _)| { text.contains("v0.2.0 → v0.3.0 available") })
2163 );
2164 drop(older);
2165 std::fs::remove_dir_all(root).unwrap();
2166 }
2167
2168 #[test]
2169 fn upgraded_version_shows_whats_new_once() {
2170 let dir = std::env::temp_dir().join(format!(
2171 "mach-whats-new-{}-{}",
2172 std::process::id(),
2173 uuid::Uuid::new_v4()
2174 ));
2175 let mut store = Store::open(&dir).unwrap();
2176 store
2177 .update(|data| {
2178 data.settings.last_run_version = Some("0.1.9".into());
2179 Ok(())
2180 })
2181 .unwrap();
2182
2183 let first = App::with_store("0.2.0", store).unwrap();
2184 assert_eq!(first.mode, Mode::WhatsNew);
2185 drop(first);
2186
2187 let second = App::with_store("0.2.0", Store::open(&dir).unwrap()).unwrap();
2188 assert_eq!(second.mode, Mode::Normal);
2189 drop(second);
2190 std::fs::remove_dir_all(dir).unwrap();
2191 }
2192
2193 #[test]
2194 fn tui_update_install_success_requests_restart() {
2195 let store = Store::open_in_memory_with_paths("/tmp/mach-install-success-test").unwrap();
2196 let mut app = App::with_store("test", store).unwrap();
2197 let (tx, rx) = mpsc::channel();
2198 app.update_job = Some(UpdateJob {
2199 rx,
2200 kind: UpdateJobKind::Install,
2201 lease: None,
2202 });
2203 app.update_activity = Some(UpdateActivity::Checking);
2204 tx.send(finished(Ok(UpdateOutcome::Installed {
2205 result: crate::update::InstallResult {
2206 destination: "/tmp/mach-bin/mach".into(),
2207 tag: "v0.3.0".into(),
2208 },
2209 info: update_result(true),
2210 etag: None,
2211 })))
2212 .unwrap();
2213
2214 assert!(app.poll_update());
2215 assert_eq!(
2216 app.status_message().map(|(text, _)| text),
2217 Some("Installed v0.3.0 · restart mach")
2218 );
2219 assert!(app.update_activity().is_none());
2220
2221 assert!(!app.expire_message());
2222 assert_eq!(
2223 app.status_message().map(|(text, _)| text),
2224 Some("Installed v0.3.0 · restart mach")
2225 );
2226
2227 app.open_slash();
2228 assert!(app.status_message().is_none());
2229 }
2230
2231 #[test]
2232 fn update_download_progress_is_applied_before_the_final_result() {
2233 let store = Store::open_in_memory_with_paths("/tmp/mach-install-progress-test").unwrap();
2234 let mut app = App::with_store("test", store).unwrap();
2235 let (tx, rx) = mpsc::channel();
2236 app.update_job = Some(UpdateJob {
2237 rx,
2238 kind: UpdateJobKind::Install,
2239 lease: None,
2240 });
2241 app.update_activity = Some(UpdateActivity::Checking);
2242 tx.send(UpdateEvent::DownloadProgress(
2243 crate::update::DownloadProgress {
2244 downloaded: 512,
2245 total: Some(1024),
2246 },
2247 ))
2248 .unwrap();
2249
2250 assert!(app.poll_update());
2251 assert_eq!(
2252 app.update_activity(),
2253 Some(UpdateActivity::Downloading(
2254 crate::update::DownloadProgress {
2255 downloaded: 512,
2256 total: Some(1024),
2257 }
2258 ))
2259 );
2260 }
2261
2262 #[test]
2263 fn tui_update_install_error_keeps_the_recovery_command() {
2264 let store = Store::open_in_memory_with_paths("/tmp/mach-install-error-test").unwrap();
2265 let mut app = App::with_store("test", store).unwrap();
2266 let (tx, rx) = mpsc::channel();
2267 app.update_job = Some(UpdateJob {
2268 rx,
2269 kind: UpdateJobKind::Install,
2270 lease: None,
2271 });
2272 tx.send(finished(Ok(UpdateOutcome::InstallFailed {
2273 message:
2274 "this mach executable is managed by Cargo; run cargo install --locked mach-tui"
2275 .into(),
2276 info: update_result(true),
2277 etag: None,
2278 })))
2279 .unwrap();
2280
2281 assert!(app.poll_update());
2282 let message = app.message.as_ref().expect("visible install error");
2283 assert_eq!(message.kind, MessageKind::Error);
2284 assert!(message.text.contains("cargo install --locked mach-tui"));
2285 }
2286
2287 #[test]
2288 fn automatic_update_results_are_silent_unless_a_new_version_exists() {
2289 let store = Store::open_in_memory_with_paths("/tmp/mach-auto-update-test").unwrap();
2290 let mut app = App::with_store("0.2.0", store).unwrap();
2291 let (tx, rx) = mpsc::channel();
2292 app.update_job = Some(UpdateJob {
2293 rx,
2294 kind: UpdateJobKind::Automatic,
2295 lease: None,
2296 });
2297 tx.send(finished(Ok(automatic_outcome(false)))).unwrap();
2298
2299 assert!(!app.poll_update());
2300 assert!(app.message.is_none());
2301
2302 let (tx, rx) = mpsc::channel();
2303 app.update_job = Some(UpdateJob {
2304 rx,
2305 kind: UpdateJobKind::Automatic,
2306 lease: None,
2307 });
2308 tx.send(finished(Err(update_failure("offline")))).unwrap();
2309
2310 assert!(!app.poll_update());
2311 assert!(app.message.is_none());
2312 }
2313
2314 #[test]
2315 fn automatic_update_notice_waits_for_an_active_confirmation() {
2316 let store = Store::open_in_memory_with_paths("/tmp/mach-deferred-update-test").unwrap();
2317 let mut app = App::with_store("0.2.0", store).unwrap();
2318 app.ask_confirm(Confirm::Quit, "Press Ctrl+C again to quit");
2319 let (tx, rx) = mpsc::channel();
2320 app.update_job = Some(UpdateJob {
2321 rx,
2322 kind: UpdateJobKind::Automatic,
2323 lease: None,
2324 });
2325 tx.send(finished(Ok(automatic_outcome(true)))).unwrap();
2326
2327 assert!(!app.poll_update());
2328 assert_eq!(app.pending_confirmation(), Some(&Confirm::Quit));
2329 assert_eq!(
2330 app.message.as_ref().map(|message| message.text.as_str()),
2331 Some("Press Ctrl+C again to quit")
2332 );
2333
2334 app.cancel_pending();
2335 assert!(app.status_message().is_some_and(|(text, _)| {
2336 text.contains("v0.2.0 → v0.3.0 available · run /update to install")
2337 }));
2338
2339 app.info("Temporary action result");
2340 assert_eq!(
2341 app.status_message().map(|(text, _)| text),
2342 Some("Temporary action result")
2343 );
2344 app.message.as_mut().unwrap().until = Instant::now();
2345 assert!(app.expire_message());
2346 assert!(app.status_message().is_some_and(|(text, _)| {
2347 text.contains("v0.2.0 → v0.3.0 available · run /update to install")
2348 }));
2349
2350 app.open_slash();
2351 assert!(app.status_message().is_none());
2352 }
2353}