use std::{ collections::HashSet, path::PathBuf, time::{Duration, Instant}, }; use anyhow::{Context, Result, bail}; use chrono::{Local, NaiveDate}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; use ratatui::layout::Rect; use crate::{ db::Database, macro_lang::{ MacroAction, MacroContext, MacroRuntime, MenuChoice, PromptKind, key_to_source, macro_name, parse_key_binding, }, model::{Category, DocumentSettings, Item, ItemChanges, MacroDef, ViewDef}, preferences::AppPreferences, }; #[derive(Debug, Clone)] pub enum InputKind { NewItem, EditItem(i64), Note(i64), Search, MacroEditor(Option), MacroBinding(i64), MacroPrompt(String), } #[derive(Debug, Clone)] pub struct InputState { pub kind: InputKind, pub title: String, pub value: String, pub multiline: bool, /// UTF-8 byte offset kept on a character boundary. pub cursor: usize, } #[derive(Debug, Clone)] pub struct PropsState { pub item_id: i64, pub values: [String; 5], pub field: usize, } #[derive(Debug, Clone)] pub enum FormKind { Category(Option), View(Option), Preferences, DocumentSettings, } #[derive(Debug, Clone)] pub struct FormState { pub kind: FormKind, pub values: Vec, pub field: usize, } #[derive(Debug, Clone)] pub struct ChoiceOption { pub label: String, pub value: String, } #[derive(Debug, Clone)] pub struct ChoiceState { pub form: FormState, pub options: Vec, pub selected: usize, } #[derive(Debug, Clone)] pub enum Mode { Normal, Help, Input(InputState), Properties(PropsState), Form(FormState), Dependencies { item_id: i64, choices: Vec, selected: usize, }, Categories, Views, CategoryManager, Menu, Confirm { prompt: String, action: ConfirmAction, }, Choice(ChoiceState), MacroManager, MacroMenu { title: String, prompt: String, choices: Vec, selected: usize, }, MacroMessage { title: String, message: String, }, MacroGetKey { variable: String, }, } #[derive(Debug, Clone)] pub struct RecordingState { pub macro_id: i64, pub name: String, pub source: String, } #[derive(Debug, Clone)] pub enum ConfirmAction { DeleteView(i64), DeleteCategory(i64), EmptyTrash, PermanentlyDiscard(Vec), DeleteMacro(i64), } pub struct App { pub db: Database, pub path: PathBuf, pub preferences: AppPreferences, pub preferences_path: PathBuf, pub document_settings: DocumentSettings, pub views: Vec, pub view_index: usize, pub items: Vec, pub selected: usize, pub scroll: usize, pub search: String, pub marked: HashSet, pub categories: Vec, pub category_selected: usize, pub view_selected: usize, pub mode: Mode, pub status: String, pub should_quit: bool, pub item_rows: Vec<(Rect, i64)>, pub command_regions: Vec, pub view_regions: Vec, pub choice_regions: Vec, pub macros: Vec, pub macro_selected: usize, pub recording: Option, pub macro_capture_key: bool, macro_runtime: Option, macro_globals: std::collections::HashMap, macro_return_mode: Option, macro_key_hit: bool, macro_origin_mode: Option, last_autosave: Instant, current_day: NaiveDate, } impl App { pub fn new(db: Database, path: PathBuf) -> Result { let (preferences, preferences_path) = AppPreferences::load()?; Self::build(db, path, preferences, preferences_path) } #[cfg(test)] pub fn new_with_preferences( db: Database, path: PathBuf, preferences: AppPreferences, preferences_path: PathBuf, ) -> Result { Self::build(db, path, preferences, preferences_path) } fn build( db: Database, path: PathBuf, preferences: AppPreferences, preferences_path: PathBuf, ) -> Result { let views = db.views()?; let categories = db.categories()?; let document_settings = db.document_settings()?; let macros = db.macros()?; let macro_globals = db.macro_variables()?; let mut app = Self { db, path, preferences, preferences_path, document_settings, views, view_index: 0, items: vec![], selected: 0, scroll: 0, search: String::new(), marked: HashSet::new(), categories, category_selected: 0, view_selected: 0, mode: Mode::Normal, status: "Ready — capture first, organize later".into(), should_quit: false, item_rows: vec![], command_regions: vec![], view_regions: vec![], choice_regions: vec![], macros, macro_selected: 0, recording: None, macro_capture_key: false, macro_runtime: None, macro_globals, macro_return_mode: None, macro_key_hit: false, macro_origin_mode: None, last_autosave: Instant::now(), current_day: Local::now().date_naive(), }; app.refresh()?; if app .macros .iter() .any(|definition| definition.name.eq_ignore_ascii_case("AUTOEXEC")) && let Err(error) = app.start_macro("AUTOEXEC") { app.status = format!("AUTOEXEC error: {error:#}"); } Ok(app) } pub fn current_view(&self) -> &ViewDef { &self.views[self.view_index] } pub fn selected_item(&self) -> Option<&Item> { self.items.get(self.selected) } pub fn refresh(&mut self) -> Result<()> { let keep = self.selected_item().map(|i| i.id); self.views = self.db.views()?; self.categories = self.db.categories()?; self.view_index = self.view_index.min(self.views.len().saturating_sub(1)); self.items = self.db.items(&self.views[self.view_index], &self.search)?; self.selected = keep .and_then(|id| self.items.iter().position(|i| i.id == id)) .unwrap_or(self.selected.min(self.items.len().saturating_sub(1))); if self.selected < self.scroll { self.scroll = self.selected; } Ok(()) } fn chosen_ids(&self) -> Vec { if self.marked.is_empty() { self.selected_item().map(|i| vec![i.id]).unwrap_or_default() } else { self.marked.iter().copied().collect() } } pub fn handle_key(&mut self, key: KeyEvent) -> Result<()> { if self.macro_runtime.is_some() { self.macro_key_hit = true; if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { let handled = if let Some(runtime) = &mut self.macro_runtime { runtime.trigger_break()? } else { false }; if handled { self.status = "Macro break handler invoked".into(); return Ok(()); } return self.abort_macro("Macro interrupted"); } } if self.recording.is_some() && (is_macro_shortcut(key) || is_legacy_macro_shortcut(key)) { return self.stop_recording(); } if let Mode::MacroMenu { .. } = self.mode { return self.handle_macro_menu(key); } if let Mode::MacroMessage { .. } = self.mode { if let Some(runtime) = &mut self.macro_runtime { runtime.resume_message(); } self.mode = self.macro_return_mode.take().unwrap_or(Mode::Normal); return Ok(()); } if let Mode::MacroGetKey { variable } = self.mode.clone() { if key.code == KeyCode::Esc { return self.abort_macro("Macro canceled"); } if let Some(runtime) = &mut self.macro_runtime { runtime.resume_value(&variable, key_to_source(key))?; } self.mode = self.macro_return_mode.take().unwrap_or(Mode::Normal); return Ok(()); } if self.macro_capture_key { if let Mode::Input(mut input) = self.mode.clone() { match input.kind { InputKind::MacroEditor(_) => { insert_str_at_cursor(&mut input, &key_to_source(key)); self.mode = Mode::Input(input); self.macro_capture_key = false; self.status = "Captured key in macro source".into(); return Ok(()); } InputKind::MacroBinding(_) => { input.value = key_to_source(key); input.cursor = input.value.len(); self.mode = Mode::Input(input); self.macro_capture_key = false; self.status = "Captured macro key binding".into(); return Ok(()); } _ => {} } } self.macro_capture_key = false; } if matches!( self.mode, Mode::Input(InputState { kind: InputKind::MacroEditor(_) | InputKind::MacroBinding(_), .. }) ) && (is_key_capture_shortcut(key) || is_legacy_key_capture_shortcut(key)) { self.macro_capture_key = true; self.status = "KEY CAPTURE — press the key to insert".into(); return Ok(()); } if is_macro_shortcut(key) || is_legacy_macro_shortcut(key) { self.macro_origin_mode = Some(self.mode.clone()); self.mode = Mode::MacroManager; return Ok(()); } if let Some(recording) = &mut self.recording { recording.source.push_str(&key_to_source(key)); } if matches!(self.mode, Mode::Normal) && self.macro_runtime.is_none() { let bound = self.macros.iter().find(|definition| { !definition.key_binding.is_empty() && parse_key_binding(&definition.key_binding).is_ok_and(|binding| { binding.code == key.code && binding.modifiers == key.modifiers }) }); if let Some(name) = bound.map(|definition| definition.name.clone()) { return self.start_macro(&name); } } self.dispatch_key(key) } fn dispatch_key(&mut self, key: KeyEvent) -> Result<()> { match self.mode.clone() { Mode::Normal => self.handle_normal(key), Mode::Help => { self.mode = Mode::Normal; Ok(()) } Mode::Input(_) => self.handle_input(key), Mode::Properties(_) => self.handle_properties(key), Mode::Form(_) => self.handle_form(key), Mode::Dependencies { .. } => self.handle_dependencies(key), Mode::Categories => self.handle_categories(key), Mode::Views => self.handle_views(key), Mode::CategoryManager => self.handle_category_manager(key), Mode::Menu => self.handle_menu(key), Mode::Confirm { .. } => self.handle_confirm(key), Mode::Choice(_) => self.handle_choice(key), Mode::MacroManager => self.handle_macro_manager(key), Mode::MacroMenu { .. } | Mode::MacroMessage { .. } | Mode::MacroGetKey { .. } => Ok(()), } } pub fn tick(&mut self) -> Result<()> { self.advance_macro()?; let interval = self.preferences.autosave_minutes; if interval > 0 && self.last_autosave.elapsed() >= Duration::from_secs(interval * 60) { self.db.checkpoint()?; self.last_autosave = Instant::now(); self.status = "Autosaved and checkpointed".into(); } let today = Local::now().date_naive(); if today != self.current_day { self.current_day = today; if self.document_settings.trash_policy == "end-of-day" { let count = self.db.empty_trash()?; if count > 0 { self.status = format!("Emptied {count} Trash item(s) at end of day"); self.refresh()?; } } } Ok(()) } fn macro_context(&self) -> MacroContext { let now = Local::now(); let highlighted = self.selected_item(); MacroContext { date: now.format("%Y-%m-%d").to_string(), time: now.format("%H:%M:%S").to_string(), filename: self .path .file_name() .map(|name| name.to_string_lossy().into_owned()) .unwrap_or_default(), filepath: self .path .parent() .map(|path| path.display().to_string()) .unwrap_or_default(), view_name: self.current_view().name.clone(), mode: match self.mode { Mode::Normal => "VIEW", Mode::Input(_) => "EDITOR", Mode::CategoryManager | Mode::Categories => "CATEGORY", Mode::MacroManager => "MACRO", _ => "BOX", } .into(), highlight_type: highlighted.map_or("NONE", |_| "ITEM").into(), highlight_value: highlighted .map(|item| item.text.clone()) .unwrap_or_default(), mark_count: self.marked.len(), marked_in_view: self .items .iter() .filter(|item| self.marked.contains(&item.id)) .count(), clipboard: String::new(), depth: self.category_depth(), key_hit: self.macro_key_hit, prompt: self.current_prompt(), } } fn category_depth(&self) -> usize { let Some(mut parent) = self .categories .get(self.category_selected) .and_then(|category| category.parent_id) else { return 0; }; let mut depth = 1; while let Some(category) = self .categories .iter() .find(|category| category.id == parent) { let Some(next) = category.parent_id else { break; }; parent = next; depth += 1; } depth } fn current_prompt(&self) -> String { match &self.mode { Mode::Properties(properties) => { ["Priority", "When", "Alarm", "Numeric value", "Recurrence"][properties.field] .into() } Mode::Form(form) => form_prompt(&form.kind, form.field).into(), Mode::Input(input) => input.title.clone(), _ => String::new(), } } fn advance_macro(&mut self) -> Result<()> { for _ in 0..512 { let Some(mut runtime) = self.macro_runtime.take() else { return Ok(()); }; let action = match runtime.next_action(&self.macro_context()) { Ok(action) => action, Err(error) => { self.macro_globals = runtime.globals().clone(); self.db.save_macro_variables(&self.macro_globals)?; self.mode = Mode::Normal; self.macro_return_mode = None; self.macro_key_hit = false; self.status = format!("Macro error: {error:#}"); return Ok(()); } }; match action { None => { self.macro_runtime = Some(runtime); return Ok(()); } Some(MacroAction::Finished) => { self.macro_globals = runtime.globals().clone(); self.db.save_macro_variables(&self.macro_globals)?; if matches!( self.mode, Mode::MacroMenu { .. } | Mode::MacroMessage { .. } | Mode::MacroGetKey { .. } ) { self.mode = Mode::Normal; } self.status = "Macro finished".into(); self.macro_return_mode = None; self.macro_key_hit = false; return Ok(()); } Some(MacroAction::Key(key)) => { self.macro_runtime = Some(runtime); self.dispatch_key(key)?; } Some(MacroAction::Prompt { kind, prompt, variable, }) => { self.macro_runtime = Some(runtime); self.macro_return_mode = Some(self.mode.clone()); let title = match kind { PromptKind::Text => prompt, PromptKind::Category => format!("{prompt} (category)"), PromptKind::File => format!("{prompt} (file)"), }; self.open_input( InputKind::MacroPrompt(variable), &title, String::new(), false, ); return Ok(()); } Some(MacroAction::Menu { title, prompt, choices, }) => { self.macro_runtime = Some(runtime); self.macro_return_mode = Some(self.mode.clone()); self.mode = Mode::MacroMenu { title, prompt, choices, selected: 0, }; return Ok(()); } Some(MacroAction::Message { title, message }) => { self.macro_runtime = Some(runtime); self.macro_return_mode = Some(self.mode.clone()); self.mode = Mode::MacroMessage { title, message }; return Ok(()); } Some(MacroAction::GetKey { variable }) => { self.macro_runtime = Some(runtime); self.macro_return_mode = Some(self.mode.clone()); self.mode = Mode::MacroGetKey { variable }; return Ok(()); } Some(MacroAction::IfKey { target }) => { runtime.resume_if_key(&target, self.macro_key_hit)?; self.macro_key_hit = false; self.macro_runtime = Some(runtime); } Some(MacroAction::Selection { prompt }) => { self.select_setting(&prompt); self.macro_runtime = Some(runtime); } } } Ok(()) } fn start_macro(&mut self, name: &str) -> Result<()> { let runtime = MacroRuntime::new(name, &self.macros, self.macro_globals.clone())?; self.macro_runtime = Some(runtime); self.mode = self .macro_origin_mode .take() .unwrap_or_else(|| self.mode.clone()); self.macro_return_mode = None; self.macro_key_hit = false; self.status = format!("Running macro {name}"); Ok(()) } fn abort_macro(&mut self, status: &str) -> Result<()> { if let Some(runtime) = self.macro_runtime.take() { self.macro_globals = runtime.globals().clone(); self.db.save_macro_variables(&self.macro_globals)?; } let current_mode = match &self.mode { Mode::Input(InputState { kind: InputKind::MacroPrompt(_), .. }) | Mode::MacroMenu { .. } | Mode::MacroMessage { .. } | Mode::MacroGetKey { .. } => Mode::Normal, _ => self.mode.clone(), }; self.mode = self.macro_return_mode.take().unwrap_or(current_mode); self.macro_key_hit = false; self.status = status.into(); Ok(()) } fn refresh_macros(&mut self) -> Result<()> { self.macros = self.db.macros()?; self.macro_selected = self.macro_selected.min(self.macros.len().saturating_sub(1)); Ok(()) } fn select_setting(&mut self, requested: &str) { let requested = requested.trim().to_lowercase(); match &mut self.mode { Mode::Properties(properties) => { let prompts = ["priority", "when", "alarm", "numeric value", "recurrence"]; if let Some(index) = prompts .iter() .position(|prompt| prompt.starts_with(&requested)) { properties.field = index; } } Mode::Form(form) => { if let Some(index) = (0..form.values.len()).position(|index| { form_prompt(&form.kind, index) .to_lowercase() .starts_with(&requested) }) { form.field = index; } } _ => {} } } fn handle_macro_manager(&mut self, key: KeyEvent) -> Result<()> { match key.code { KeyCode::Esc => { self.mode = self.macro_origin_mode.take().unwrap_or(Mode::Normal); } KeyCode::Up | KeyCode::Char('k') => { self.macro_selected = self.macro_selected.saturating_sub(1) } KeyCode::Down | KeyCode::Char('j') => { self.macro_selected = (self.macro_selected + 1).min(self.macros.len().saturating_sub(1)) } KeyCode::Insert | KeyCode::Char('n') => { let mut number = self.macros.len() + 1; let name = loop { let candidate = format!("macro{number}"); if self .macros .iter() .all(|definition| !definition.name.eq_ignore_ascii_case(&candidate)) { break candidate; } number += 1; }; self.open_input( InputKind::MacroEditor(None), "Macro Editor (F5/Ctrl-S save · Ctrl-V capture key · Esc cancel)", format!("{{{name}}}\n"), true, ); } KeyCode::F(2) | KeyCode::Char('e') => { if let Some(definition) = self.macros.get(self.macro_selected).cloned() { self.open_input( InputKind::MacroEditor(Some(definition.id)), "Macro Editor (F5/Ctrl-S save · Ctrl-V capture key · Esc cancel)", definition.source, true, ); } } KeyCode::Enter => { if let Some(name) = self .macros .get(self.macro_selected) .map(|definition| definition.name.clone()) { self.start_macro(&name)?; } } KeyCode::F(7) | KeyCode::Char('r') => { self.start_recording(key.modifiers.contains(KeyModifiers::SHIFT)) } KeyCode::Char('a') => self.start_recording(true), KeyCode::F(6) | KeyCode::Char('b') => { if let Some(definition) = self.macros.get(self.macro_selected).cloned() { self.open_input( InputKind::MacroBinding(definition.id), "Attach macro to key (Ctrl-V captures the next key)", definition.key_binding, false, ); } } KeyCode::Delete | KeyCode::F(4) => { if let Some(definition) = self.macros.get(self.macro_selected) { if self.preferences.confirm_destructive { self.mode = Mode::Confirm { prompt: format!("Delete macro “{}”?", definition.name), action: ConfirmAction::DeleteMacro(definition.id), }; } else { self.delete_macro(definition.id)?; } } } _ => {} } Ok(()) } fn start_recording(&mut self, append: bool) { if let Some(definition) = self.macros.get(self.macro_selected).cloned() { self.recording = Some(RecordingState { macro_id: definition.id, name: definition.name.clone(), source: if append { definition.source } else { format!("{{{}}}\n", definition.name) }, }); self.mode = self.macro_origin_mode.clone().unwrap_or(Mode::Normal); self.status = format!("LEARN {} — Ctrl-G stops and saves", definition.name); } } fn handle_macro_menu(&mut self, key: KeyEvent) -> Result<()> { let Mode::MacroMenu { title, prompt, choices, mut selected, } = self.mode.clone() else { return Ok(()); }; match key.code { KeyCode::Esc => return self.abort_macro("Macro menu canceled"), KeyCode::Up | KeyCode::Char('k') => selected = selected.saturating_sub(1), KeyCode::Down | KeyCode::Char('j') => { selected = (selected + 1).min(choices.len().saturating_sub(1)) } KeyCode::Enter | KeyCode::Char(' ') => { if let Some(choice) = choices.get(selected) && let Some(runtime) = &mut self.macro_runtime { runtime.resume_menu(&choice.target)?; } self.mode = self.macro_return_mode.take().unwrap_or(Mode::Normal); return Ok(()); } KeyCode::Char(character) => { if let Some(index) = choices.iter().position(|choice| { choice .label .chars() .next() .is_some_and(|first| first.eq_ignore_ascii_case(&character)) }) { selected = index; } } _ => {} } self.mode = Mode::MacroMenu { title, prompt, choices, selected, }; Ok(()) } fn stop_recording(&mut self) -> Result<()> { let Some(recording) = self.recording.take() else { return Ok(()); }; let binding = self .macros .iter() .find(|definition| definition.id == recording.macro_id) .map(|definition| definition.key_binding.clone()) .unwrap_or_default(); macro_name(&recording.source)?; self.db.save_macro( Some(recording.macro_id), &recording.name, &recording.source, &binding, )?; self.refresh_macros()?; self.mode = Mode::MacroManager; self.status = format!("LEARN saved macro {}", recording.name); Ok(()) } fn delete_macro(&mut self, id: i64) -> Result<()> { self.db.delete_macro(id)?; self.refresh_macros()?; self.mode = Mode::MacroManager; self.status = "Macro deleted".into(); Ok(()) } pub fn shutdown(&self) -> Result<()> { self.db.close_maintenance() } fn handle_normal(&mut self, key: KeyEvent) -> Result<()> { if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('s') { self.db.checkpoint()?; self.status = "Saved and checkpointed".into(); return Ok(()); } if key.code == KeyCode::F(6) && key.modifiers.contains(KeyModifiers::ALT) { self.open_input( InputKind::Search, "Search current view", self.search.clone(), false, ); return Ok(()); } match key.code { KeyCode::Char('q') => self.should_quit = true, KeyCode::Up | KeyCode::Char('k') => self.move_selection(-1), KeyCode::Down | KeyCode::Char('j') => self.move_selection(1), KeyCode::PageUp => self.move_selection(-10), KeyCode::PageDown => self.move_selection(10), KeyCode::Home => self.selected = 0, KeyCode::End => self.selected = self.items.len().saturating_sub(1), KeyCode::Insert | KeyCode::Char('n') => { self.open_input(InputKind::NewItem, "New item", String::new(), false) } KeyCode::F(2) | KeyCode::Char('e') | KeyCode::Enter => self.edit_selected(), KeyCode::F(3) | KeyCode::Char('c') => { if self.selected_item().is_some() { self.category_selected = 0; self.mode = Mode::Categories; } } KeyCode::F(4) | KeyCode::Char('d') => self.toggle_done()?, KeyCode::F(5) => self.edit_note(), KeyCode::F(6) | KeyCode::Char('p') => self.edit_properties(), KeyCode::F(7) | KeyCode::Char(' ') => self.toggle_mark(), KeyCode::F(8) | KeyCode::Char('v') => { self.view_selected = self.view_index; self.mode = Mode::Views; } KeyCode::F(9) => { self.category_selected = 0; self.mode = Mode::CategoryManager; } KeyCode::F(10) | KeyCode::Char('m') => self.mode = Mode::Menu, KeyCode::F(1) | KeyCode::Char('?') => self.mode = Mode::Help, KeyCode::Char('r') => self.open_dependencies()?, KeyCode::Char('/') => self.open_input( InputKind::Search, "Search current view", self.search.clone(), false, ), KeyCode::Char('l') | KeyCode::Char(']') => self.switch_view(1)?, KeyCode::Char('h') | KeyCode::Char('[') => self.switch_view(-1)?, KeyCode::Delete => self.discard_or_restore()?, KeyCode::Esc if !self.search.is_empty() => { self.search.clear(); self.refresh()?; self.status = "Search cleared".into(); } _ => {} } Ok(()) } fn move_selection(&mut self, delta: isize) { if self.items.is_empty() { return; } self.selected = (self.selected as isize + delta).clamp(0, self.items.len() as isize - 1) as usize; } fn switch_view(&mut self, delta: isize) -> Result<()> { let len = self.views.len() as isize; self.view_index = (self.view_index as isize + delta).rem_euclid(len) as usize; self.selected = 0; self.scroll = 0; self.search.clear(); self.refresh()?; self.status = format!("View: {}", self.current_view().name); Ok(()) } fn open_input(&mut self, kind: InputKind, title: &str, value: String, multiline: bool) { let cursor = value.len(); self.mode = Mode::Input(InputState { kind, title: title.into(), value, multiline, cursor, }); } fn edit_selected(&mut self) { if let Some(i) = self.selected_item() { self.open_input( InputKind::EditItem(i.id), "Edit item", i.text.clone(), false, ); } } fn edit_note(&mut self) { if let Some(i) = self.selected_item() { self.open_input( InputKind::Note(i.id), "Item note (Ctrl-S saves; Enter adds a line)", i.note.clone(), true, ); } } fn edit_properties(&mut self) { if let Some(i) = self.selected_item() { self.mode = Mode::Properties(PropsState { item_id: i.id, values: [ i.priority.to_string(), i.when_at.clone().unwrap_or_default(), i.alarm_at.clone().unwrap_or_default(), i.numeric_value.map(|v| v.to_string()).unwrap_or_default(), i.recurrence.clone(), ], field: 0, }); } } fn toggle_mark(&mut self) { if let Some(id) = self.selected_item().map(|i| i.id) { if !self.marked.remove(&id) { self.marked.insert(id); } self.status = format!("{} item(s) marked", self.marked.len()); } } fn toggle_done(&mut self) -> Result<()> { let ids = self.chosen_ids(); if !ids.is_empty() { self.db.toggle_done(&ids)?; self.marked.clear(); self.refresh()?; self.status = format!("Updated {} item(s)", ids.len()); } Ok(()) } fn discard_or_restore(&mut self) -> Result<()> { let ids = self.chosen_ids(); if ids.is_empty() { return Ok(()); } let restore = self.current_view().kind == "trash"; if !restore && self.document_settings.trash_policy == "immediate" && self.preferences.confirm_destructive { self.mode = Mode::Confirm { prompt: format!("Permanently delete {} item(s)?", ids.len()), action: ConfirmAction::PermanentlyDiscard(ids), }; return Ok(()); } self.apply_discard(ids, restore) } fn apply_discard(&mut self, ids: Vec, restore: bool) -> Result<()> { let permanent = !restore && self.document_settings.trash_policy == "immediate"; self.db.discard(&ids, !restore)?; self.marked.clear(); self.refresh()?; self.status = if restore { format!("Restored {} item(s)", ids.len()) } else if permanent { format!("Permanently deleted {} item(s)", ids.len()) } else { format!("Moved {} item(s) to Trash", ids.len()) }; Ok(()) } fn handle_input(&mut self, key: KeyEvent) -> Result<()> { let Mode::Input(mut input) = self.mode.clone() else { return Ok(()); }; if key.code == KeyCode::Esc { return match input.kind { InputKind::MacroEditor(_) | InputKind::MacroBinding(_) => { self.mode = Mode::MacroManager; self.status = "Macro changes canceled".into(); Ok(()) } InputKind::MacroPrompt(_) => self.abort_macro("Macro input canceled"), _ => { self.mode = Mode::Normal; self.status = "Canceled".into(); Ok(()) } }; } if input.multiline && key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('s') { return self.accept_input(input); } if matches!(input.kind, InputKind::MacroEditor(_)) && key.code == KeyCode::F(5) { return self.accept_input(input); } match key.code { KeyCode::Enter if input.multiline => insert_at_cursor(&mut input, '\n'), KeyCode::Enter => return self.accept_input(input), KeyCode::Tab if input.multiline => { for _ in 0..self.document_settings.note_tab_width { insert_at_cursor(&mut input, ' '); } } KeyCode::Backspace => delete_before_cursor(&mut input), KeyCode::Delete => delete_at_cursor(&mut input), KeyCode::Left => input.cursor = previous_boundary(&input.value, input.cursor), KeyCode::Right => input.cursor = next_boundary(&input.value, input.cursor), KeyCode::Home => input.cursor = line_start(&input.value, input.cursor), KeyCode::End => input.cursor = line_end(&input.value, input.cursor), KeyCode::Up if input.multiline => move_cursor_vertically(&mut input, -1), KeyCode::Down if input.multiline => move_cursor_vertically(&mut input, 1), KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => input.cursor = 0, KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => { input.cursor = input.value.len() } KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { insert_at_cursor(&mut input, c) } _ => {} } self.mode = Mode::Input(input); Ok(()) } fn accept_input(&mut self, input: InputState) -> Result<()> { match input.kind { InputKind::NewItem => { self.db.add_item(&input.value)?; self.status = "Item captured and automatically filed".into(); } InputKind::EditItem(id) => { self.db.update_item( id, &ItemChanges { text: Some(input.value), ..Default::default() }, )?; self.status = "Item updated and filing re-evaluated".into(); } InputKind::Note(id) => { self.db.update_item( id, &ItemChanges { note: Some(input.value), ..Default::default() }, )?; self.status = "Note saved".into(); } InputKind::Search => { self.search = input.value; self.status = if self.search.is_empty() { "Search cleared".into() } else { format!("Filtering for “{}”", self.search) }; } InputKind::MacroEditor(id) => { let name = macro_name(&input.value)?; let binding = id .and_then(|id| { self.macros .iter() .find(|definition| definition.id == id) .map(|definition| definition.key_binding.clone()) }) .unwrap_or_default(); let saved = self.db.save_macro(id, &name, &input.value, &binding)?; self.refresh_macros()?; self.macro_selected = self .macros .iter() .position(|definition| definition.id == saved) .unwrap_or(0); self.mode = Mode::MacroManager; self.status = format!("Macro {name} saved"); return Ok(()); } InputKind::MacroBinding(id) => { if !input.value.trim().is_empty() { parse_key_binding(&input.value)?; } let definition = self .macros .iter() .find(|definition| definition.id == id) .cloned() .context("macro no longer exists")?; self.db.save_macro( Some(id), &definition.name, &definition.source, input.value.trim(), )?; self.refresh_macros()?; self.mode = Mode::MacroManager; self.status = "Macro key binding saved".into(); return Ok(()); } InputKind::MacroPrompt(variable) => { if let Some(runtime) = &mut self.macro_runtime { runtime.resume_value(&variable, input.value)?; } self.mode = self.macro_return_mode.take().unwrap_or(Mode::Normal); return Ok(()); } } self.mode = Mode::Normal; self.refresh()?; Ok(()) } fn handle_properties(&mut self, key: KeyEvent) -> Result<()> { let Mode::Properties(mut props) = self.mode.clone() else { return Ok(()); }; match key.code { KeyCode::Esc => { self.mode = Mode::Normal; return Ok(()); } KeyCode::Tab | KeyCode::Down => props.field = (props.field + 1) % 5, KeyCode::BackTab | KeyCode::Up => props.field = (props.field + 4) % 5, KeyCode::Backspace => { props.values[props.field].pop(); } KeyCode::Enter => { let priority = props.values[0].parse().unwrap_or(3); let when_at = normalize_date(&self.db, &props.values[1])?; let alarm_at = normalize_date(&self.db, &props.values[2])?; let numeric_value = if props.values[3].trim().is_empty() { None } else { props.values[3].parse().ok() }; self.db.update_item( props.item_id, &ItemChanges { priority: Some(priority), when_at: Some(when_at), alarm_at: Some(alarm_at), numeric_value: Some(numeric_value), recurrence: Some(props.values[4].clone()), ..Default::default() }, )?; self.mode = Mode::Normal; self.refresh()?; self.status = "Properties saved".into(); return Ok(()); } KeyCode::Char(c) => props.values[props.field].push(c), _ => {} } self.mode = Mode::Properties(props); Ok(()) } fn handle_categories(&mut self, key: KeyEvent) -> Result<()> { match key.code { KeyCode::Esc | KeyCode::Enter => self.mode = Mode::Normal, KeyCode::Up | KeyCode::Char('k') => { self.category_selected = self.category_selected.saturating_sub(1) } KeyCode::Down | KeyCode::Char('j') => { self.category_selected = (self.category_selected + 1).min(self.categories.len().saturating_sub(1)) } KeyCode::Char(' ') => { if let (Some(item), Some(cat)) = ( self.selected_item(), self.categories.get(self.category_selected), ) { self.db.toggle_assignment(item.id, cat.id)?; self.refresh()?; self.status = "Category assignment changed".into(); } } _ => {} } Ok(()) } fn handle_views(&mut self, key: KeyEvent) -> Result<()> { match key.code { KeyCode::Esc => self.mode = Mode::Normal, KeyCode::Up if key.modifiers.contains(KeyModifiers::CONTROL) => { if let Some(view) = self.views.get(self.view_selected) { let id = view.id; self.db.move_view(id, -1)?; self.refresh()?; self.view_selected = self.views.iter().position(|v| v.id == id).unwrap_or(0); self.status = "View moved up".into(); } } KeyCode::Down if key.modifiers.contains(KeyModifiers::CONTROL) => { if let Some(view) = self.views.get(self.view_selected) { let id = view.id; self.db.move_view(id, 1)?; self.refresh()?; self.view_selected = self.views.iter().position(|v| v.id == id).unwrap_or(0); self.status = "View moved down".into(); } } KeyCode::Up | KeyCode::Char('k') => { self.view_selected = self.view_selected.saturating_sub(1) } KeyCode::Down | KeyCode::Char('j') => { self.view_selected = (self.view_selected + 1).min(self.views.len().saturating_sub(1)) } KeyCode::Enter => { self.view_index = self.view_selected; self.selected = 0; self.scroll = 0; self.search.clear(); self.mode = Mode::Normal; self.refresh()?; self.status = format!("View: {}", self.current_view().name); } KeyCode::Char('n') | KeyCode::Insert => self.open_view_form(None), KeyCode::Char('e') | KeyCode::F(2) => { if let Some(view) = self.views.get(self.view_selected).cloned() { self.open_view_form(Some(view)); } } KeyCode::Delete => { if let Some(view) = self.views.get(self.view_selected) { let id = view.id; if self.preferences.confirm_destructive { self.mode = Mode::Confirm { prompt: format!("Delete view “{}”?", view.name), action: ConfirmAction::DeleteView(id), }; } else { self.delete_view(id)?; } } } _ => {} } Ok(()) } fn handle_category_manager(&mut self, key: KeyEvent) -> Result<()> { match key.code { KeyCode::Esc => self.mode = Mode::Normal, KeyCode::Up | KeyCode::Char('k') => { self.category_selected = self.category_selected.saturating_sub(1) } KeyCode::Down | KeyCode::Char('j') => { self.category_selected = (self.category_selected + 1).min(self.categories.len().saturating_sub(1)) } KeyCode::Char('n') | KeyCode::Insert => self.open_category_form(None), KeyCode::Char('e') | KeyCode::Enter | KeyCode::F(2) => { if let Some(category) = self.categories.get(self.category_selected).cloned() { self.open_category_form(Some(category)); } } KeyCode::Delete => { if let Some(category) = self.categories.get(self.category_selected) { let id = category.id; if self.preferences.confirm_destructive { self.mode = Mode::Confirm { prompt: format!("Delete category “{}”?", category.name), action: ConfirmAction::DeleteCategory(id), }; } else { self.delete_category(id)?; } } } _ => {} } Ok(()) } fn handle_menu(&mut self, key: KeyEvent) -> Result<()> { self.mode = Mode::Normal; match key.code { KeyCode::Char('n') => { self.open_input(InputKind::NewItem, "New item", String::new(), false) } KeyCode::Char('s') => { self.db.checkpoint()?; self.status = "Saved and checkpointed".into(); } KeyCode::Char('h') => self.mode = Mode::Help, KeyCode::Char('r') => self.open_dependencies()?, KeyCode::Char('p') => self.open_preferences_form(), KeyCode::Char('d') => self.open_document_settings_form(), KeyCode::Char('x') => self.mode = Mode::MacroManager, KeyCode::Char('b') => { let path = self.db.backup_now()?; self.status = format!("Backup written to {}", path.display()); } KeyCode::Char('t') => { if self.preferences.confirm_destructive { self.mode = Mode::Confirm { prompt: "Permanently empty Trash?".into(), action: ConfirmAction::EmptyTrash, }; } else { self.empty_trash()?; } } KeyCode::Char('q') => self.should_quit = true, _ => {} } Ok(()) } fn handle_confirm(&mut self, key: KeyEvent) -> Result<()> { let Mode::Confirm { action, .. } = self.mode.clone() else { return Ok(()); }; match key.code { KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => { match action { ConfirmAction::DeleteView(id) => self.delete_view(id)?, ConfirmAction::DeleteCategory(id) => self.delete_category(id)?, ConfirmAction::EmptyTrash => self.empty_trash()?, ConfirmAction::PermanentlyDiscard(ids) => self.apply_discard(ids, false)?, ConfirmAction::DeleteMacro(id) => self.delete_macro(id)?, } if matches!(self.mode, Mode::Confirm { .. }) { self.mode = Mode::Normal; } } KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { self.mode = Mode::Normal; self.status = "Canceled".into(); } _ => {} } Ok(()) } fn delete_view(&mut self, id: i64) -> Result<()> { self.db.delete_view(id)?; self.view_index = 0; self.view_selected = 0; self.mode = Mode::Views; self.refresh()?; self.status = "View deleted".into(); Ok(()) } fn delete_category(&mut self, id: i64) -> Result<()> { self.db.delete_category(id)?; self.category_selected = self.category_selected.saturating_sub(1); self.mode = Mode::CategoryManager; self.refresh()?; self.status = "Category deleted; its items were preserved".into(); Ok(()) } fn empty_trash(&mut self) -> Result<()> { let count = self.db.empty_trash()?; self.mode = Mode::Normal; self.refresh()?; self.status = format!("Permanently removed {count} Trash item(s)"); Ok(()) } fn open_preferences_form(&mut self) { self.mode = Mode::Form(FormState { kind: FormKind::Preferences, values: vec![ self.preferences.theme.clone(), yes_no(self.preferences.show_command_bar), yes_no(self.preferences.show_rule_info), yes_no(self.preferences.show_carriage_returns), display_space(&self.preferences.item_marker), self.preferences.autosave_minutes.to_string(), yes_no(self.preferences.confirm_destructive), self.preferences.date_format.clone(), yes_no(self.preferences.clock_24h), self.preferences.decimal_separator.clone(), display_space(&self.preferences.thousands_separator), ], field: 0, }); } fn open_document_settings_form(&mut self) { let settings = &self.document_settings; self.mode = Mode::Form(FormState { kind: FormKind::DocumentSettings, values: vec![ settings.description.clone(), yes_no(settings.backup_on_open), settings.trash_policy.clone(), settings.done_policy.clone(), yes_no(settings.automatic_filing), settings.date_order.clone(), settings.week_start.clone(), settings.default_time.clone(), settings.morning_time.clone(), settings.afternoon_time.clone(), settings.evening_time.clone(), settings.note_tab_width.to_string(), ], field: 0, }); } fn open_category_form(&mut self, category: Option) { let id = category.as_ref().map(|c| c.id); let rule = id.and_then(|category_id| self.db.category_rules(category_id).ok()?.into_iter().next()); let condition = rule .as_ref() .map(|r| r.condition_expr.clone()) .unwrap_or_default(); let action = rule .map(|r| format!("{}:{}", r.action_kind, r.action_value)) .unwrap_or_default(); let values = if let Some(c) = category { let parent = c .parent_id .and_then(|id| self.categories.iter().find(|p| p.id == id)) .map(|p| p.name.clone()) .unwrap_or_default(); vec![ c.name, parent, c.kind, c.match_text, yes_no(c.exclusive), condition, action, ] } else { vec![ String::new(), String::new(), "standard".into(), String::new(), "no".into(), String::new(), String::new(), ] }; self.mode = Mode::Form(FormState { kind: FormKind::Category(id), values, field: 0, }); } fn open_view_form(&mut self, view: Option) { let id = view.as_ref().map(|v| v.id); let values = if let Some(v) = view { let columns = v.columns_spec(); let sections = v.sections_spec(); vec![ v.name, v.kind, v.filter_value, v.filter_expr, v.sort_key, yes_no(v.show_done), columns, sections, ] } else { vec![ String::new(), "list".into(), String::new(), String::new(), "manual".into(), "yes".into(), "item:48:Items:none,categories:27:Categories:none,when:20:When:none,priority:5:P:none".into(), String::new(), ] }; self.mode = Mode::Form(FormState { kind: FormKind::View(id), values, field: 0, }); } fn handle_form(&mut self, key: KeyEvent) -> Result<()> { let Mode::Form(mut form) = self.mode.clone() else { return Ok(()); }; let choices = form_choices(&form.kind, form.field); match key.code { KeyCode::Esc => { self.mode = match form.kind { FormKind::Category(_) => Mode::CategoryManager, FormKind::View(_) => Mode::Views, FormKind::Preferences | FormKind::DocumentSettings => Mode::Normal, }; return Ok(()); } KeyCode::Tab | KeyCode::Down => form.field = (form.field + 1) % form.values.len(), KeyCode::BackTab | KeyCode::Up => { form.field = (form.field + form.values.len() - 1) % form.values.len() } KeyCode::Backspace => { if choices.is_none() { form.values[form.field].pop(); } } KeyCode::Char(' ') if choices.is_some() => { let options = choices.unwrap_or_default(); let selected = options .iter() .position(|option| option.value == form.values[form.field]) .unwrap_or(0); self.mode = Mode::Choice(ChoiceState { form, options, selected, }); return Ok(()); } KeyCode::Enter => { match form.kind { FormKind::Category(id) => { let parent_id = if form.values[1].trim().is_empty() { None } else { self.categories .iter() .find(|c| c.name.eq_ignore_ascii_case(form.values[1].trim())) .map(|c| c.id) }; let category_id = self.db.save_category( id, &form.values[0], parent_id, form.values[2].trim(), &form.values[3], parse_yes(&form.values[4]), )?; self.db .save_primary_rule(category_id, &form.values[5], &form.values[6])?; self.db.reapply_automatic_assignments()?; self.refresh()?; self.mode = Mode::CategoryManager; self.status = "Category definition saved".into(); } FormKind::View(id) => { let saved = self.db.save_view_design( id, &form.values[0], form.values[1].trim(), &form.values[2], form.values[4].trim(), parse_yes(&form.values[5]), &form.values[3], &form.values[6], &form.values[7], )?; self.refresh()?; self.view_selected = self.views.iter().position(|v| v.id == saved).unwrap_or(0); self.mode = Mode::Views; self.status = "Live view saved".into(); } FormKind::Preferences => { let preferences = AppPreferences { theme: form.values[0].trim().to_lowercase(), show_command_bar: parse_yes_no(&form.values[1])?, show_rule_info: parse_yes_no(&form.values[2])?, show_carriage_returns: parse_yes_no(&form.values[3])?, item_marker: decode_space(&form.values[4]), autosave_minutes: form.values[5] .parse() .context("autosave interval must be a number")?, confirm_destructive: parse_yes_no(&form.values[6])?, date_format: form.values[7].trim().to_lowercase(), clock_24h: parse_yes_no(&form.values[8])?, decimal_separator: form.values[9].clone(), thousands_separator: decode_space(&form.values[10]), }; preferences.save_to(&self.preferences_path)?; self.preferences = preferences; self.last_autosave = Instant::now(); self.mode = Mode::Normal; self.status = format!("Preferences saved to {}", self.preferences_path.display()); } FormKind::DocumentSettings => { let settings = DocumentSettings { description: form.values[0].trim().into(), backup_on_open: parse_yes_no(&form.values[1])?, trash_policy: form.values[2].trim().to_lowercase(), done_policy: form.values[3].trim().to_lowercase(), automatic_filing: parse_yes_no(&form.values[4])?, date_order: form.values[5].trim().to_lowercase(), week_start: form.values[6].trim().to_lowercase(), default_time: form.values[7].trim().into(), morning_time: form.values[8].trim().into(), afternoon_time: form.values[9].trim().into(), evening_time: form.values[10].trim().into(), note_tab_width: form.values[11] .parse() .context("note tab width must be a number")?, }; self.db.save_document_settings(&settings)?; self.document_settings = settings; self.mode = Mode::Normal; self.refresh()?; self.status = "Document settings saved inside the .agnd file".into(); } } return Ok(()); } KeyCode::Char(c) if choices.is_none() && !key.modifiers.contains(KeyModifiers::CONTROL) => { form.values[form.field].push(c) } _ => {} } self.mode = Mode::Form(form); Ok(()) } fn handle_choice(&mut self, key: KeyEvent) -> Result<()> { let Mode::Choice(mut choice) = self.mode.clone() else { return Ok(()); }; match key.code { KeyCode::Esc => self.mode = Mode::Form(choice.form), KeyCode::Up | KeyCode::Char('k') => { choice.selected = choice.selected.saturating_sub(1); self.mode = Mode::Choice(choice); } KeyCode::Down | KeyCode::Char('j') => { choice.selected = (choice.selected + 1).min(choice.options.len().saturating_sub(1)); self.mode = Mode::Choice(choice); } KeyCode::Home => { choice.selected = 0; self.mode = Mode::Choice(choice); } KeyCode::End => { choice.selected = choice.options.len().saturating_sub(1); self.mode = Mode::Choice(choice); } KeyCode::Enter | KeyCode::Char(' ') => { if let Some(option) = choice.options.get(choice.selected) { let field = choice.form.field; choice.form.values[field] = option.value.clone(); } self.mode = Mode::Form(choice.form); } _ => self.mode = Mode::Choice(choice), } Ok(()) } fn open_dependencies(&mut self) -> Result<()> { let Some(item_id) = self.selected_item().map(|i| i.id) else { return Ok(()); }; let Some(all) = self.views.iter().find(|v| v.kind == "list") else { return Ok(()); }; let choices = self .db .items(all, "")? .into_iter() .filter(|i| i.id != item_id) .collect(); self.mode = Mode::Dependencies { item_id, choices, selected: 0, }; Ok(()) } fn handle_dependencies(&mut self, key: KeyEvent) -> Result<()> { let Mode::Dependencies { item_id, choices, mut selected, } = self.mode.clone() else { return Ok(()); }; match key.code { KeyCode::Esc | KeyCode::Enter => { self.mode = Mode::Normal; return Ok(()); } KeyCode::Up | KeyCode::Char('k') => selected = selected.saturating_sub(1), KeyCode::Down | KeyCode::Char('j') => { selected = (selected + 1).min(choices.len().saturating_sub(1)) } KeyCode::Char(' ') => { if let Some(choice) = choices.get(selected) { self.db.toggle_dependency(item_id, choice.id)?; self.status = "Prerequisite changed".into(); } } _ => {} } self.mode = Mode::Dependencies { item_id, choices, selected, }; Ok(()) } pub fn handle_mouse(&mut self, event: MouseEvent) -> Result<()> { if !matches!(event.kind, MouseEventKind::Down(MouseButton::Left)) { return Ok(()); } let (x, y) = (event.column, event.row); if matches!(self.mode, Mode::MacroManager) { if let Some(index) = self .choice_regions .iter() .position(|region| inside(*region, x, y)) { self.macro_selected = index; } return Ok(()); } if matches!(self.mode, Mode::MacroMenu { .. }) { if let Some(index) = self .choice_regions .iter() .position(|region| inside(*region, x, y)) { if let Mode::MacroMenu { selected, .. } = &mut self.mode { *selected = index; } return self.handle_macro_menu(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); } return Ok(()); } if let Mode::Choice(mut choice) = self.mode.clone() { if let Some(index) = self .choice_regions .iter() .position(|region| inside(*region, x, y)) && let Some(option) = choice.options.get(index) { let field = choice.form.field; choice.form.values[field] = option.value.clone(); self.mode = Mode::Form(choice.form); } return Ok(()); } if let Some((_, id)) = self.item_rows.iter().find(|(r, _)| inside(*r, x, y)) { if let Some(pos) = self.items.iter().position(|i| i.id == *id) { self.selected = pos; } return Ok(()); } if let Some(i) = self.command_regions.iter().position(|r| inside(*r, x, y)) { return self.handle_key(KeyEvent::new(KeyCode::F((i + 1) as u8), KeyModifiers::NONE)); } if let Some(i) = self.view_regions.iter().position(|r| inside(*r, x, y)) { self.view_index = i; self.selected = 0; self.scroll = 0; self.search.clear(); self.refresh()?; } Ok(()) } } fn yes_no(value: bool) -> String { if value { "yes" } else { "no" }.into() } fn parse_yes(value: &str) -> bool { matches!( value.trim().to_lowercase().as_str(), "y" | "yes" | "true" | "1" | "on" ) } fn parse_yes_no(value: &str) -> Result { match value.trim().to_lowercase().as_str() { "y" | "yes" | "true" | "1" | "on" => Ok(true), "n" | "no" | "false" | "0" | "off" => Ok(false), _ => bail!("expected yes or no, got {value}"), } } fn display_space(value: &str) -> String { if value == " " { "space" } else { value }.into() } fn decode_space(value: &str) -> String { if value.eq_ignore_ascii_case("space") { " ".into() } else { value.into() } } pub(crate) fn form_choices(kind: &FormKind, field: usize) -> Option> { let entries: &[(&str, &str)] = match kind { FormKind::Preferences => match field { 0 => &[ ("Classic — Lotus-inspired blue", "classic"), ("Mono — grayscale", "mono"), ("Amber — warm dark", "amber"), ("Greenscreen — muted phosphor green", "greenscreen"), ("Nord — arctic blue dark", "nord"), ("Catppuccin Mocha — cozy pastel dark", "catppuccin-mocha"), ], 1..=3 | 6 | 8 => YES_NO_CHOICES, 7 => &[ ("ISO — 2026-08-16", "iso"), ("US — 08/16/2026", "us"), ("European — 16/08/2026", "european"), ("Long — 16 Aug 2026", "long"), ], _ => return None, }, FormKind::DocumentSettings => match field { 1 | 4 => YES_NO_CHOICES, 2 => &[ ("On demand", "on-demand"), ("On close", "on-close"), ("End of day", "end-of-day"), ("Immediately", "immediate"), ], 3 => &[ ("Keep completed items", "keep"), ("Move them to Trash", "trash"), ], 5 => &[ ("Year / month / day", "ymd"), ("Month / day / year", "mdy"), ("Day / month / year", "dmy"), ], 6 => &[("Monday", "monday"), ("Sunday", "sunday")], _ => return None, }, FormKind::Category(_) | FormKind::View(_) => return None, }; Some( entries .iter() .map(|(label, value)| ChoiceOption { label: (*label).into(), value: (*value).into(), }) .collect(), ) } fn form_prompt(kind: &FormKind, field: usize) -> &'static str { let prompts: &[&str] = match kind { FormKind::Category(_) => &[ "Name", "Parent category", "Kind", "Match phrases", "Mutually exclusive", "Rule condition", "Rule action", ], FormKind::View(_) => &[ "Name", "Kind", "Filter value", "Boolean filter", "Sort key", "Show done", "Columns", "Sections", ], FormKind::Preferences => &[ "Theme", "Show command bar", "Show rule info", "Show return markers", "Item marker", "Autosave minutes", "Confirm destructive actions", "Date format", "24-hour clock", "Decimal separator", "Thousands separator", ], FormKind::DocumentSettings => &[ "Description", "Backup on open", "Trash", "Completed items", "Automatic filing", "Numeric date order", "Week starts", "Default time", "Morning time", "Afternoon time", "Evening time", "Note tab width", ], }; prompts.get(field).copied().unwrap_or("") } const YES_NO_CHOICES: &[(&str, &str)] = &[("Yes", "yes"), ("No", "no")]; fn normalize_date(db: &Database, value: &str) -> Result> { let s = value.trim(); if s.is_empty() { Ok(None) } else if chrono::DateTime::parse_from_rfc3339(s).is_ok() { Ok(Some(s.into())) } else { db.interpret_date(&format!("on {s}")) } } fn inside(r: Rect, x: u16, y: u16) -> bool { x >= r.x && x < r.x + r.width && y >= r.y && y < r.y + r.height } fn is_macro_shortcut(key: KeyEvent) -> bool { key.code == KeyCode::Char('g') && key.modifiers.contains(KeyModifiers::CONTROL) } fn is_legacy_macro_shortcut(key: KeyEvent) -> bool { key.code == KeyCode::F(3) && key.modifiers.contains(KeyModifiers::ALT) } fn is_key_capture_shortcut(key: KeyEvent) -> bool { key.code == KeyCode::Char('v') && key.modifiers.contains(KeyModifiers::CONTROL) } fn is_legacy_key_capture_shortcut(key: KeyEvent) -> bool { key.code == KeyCode::Char('=') && key.modifiers.contains(KeyModifiers::ALT) } fn insert_at_cursor(input: &mut InputState, c: char) { input.value.insert(input.cursor, c); input.cursor += c.len_utf8(); } fn insert_str_at_cursor(input: &mut InputState, value: &str) { input.value.insert_str(input.cursor, value); input.cursor += value.len(); } fn delete_before_cursor(input: &mut InputState) { let previous = previous_boundary(&input.value, input.cursor); if previous != input.cursor { input.value.drain(previous..input.cursor); input.cursor = previous; } } fn delete_at_cursor(input: &mut InputState) { let next = next_boundary(&input.value, input.cursor); if next != input.cursor { input.value.drain(input.cursor..next); } } fn previous_boundary(value: &str, cursor: usize) -> usize { value[..cursor] .char_indices() .next_back() .map_or(0, |(index, _)| index) } fn next_boundary(value: &str, cursor: usize) -> usize { value[cursor..] .chars() .next() .map_or(cursor, |c| cursor + c.len_utf8()) } fn line_start(value: &str, cursor: usize) -> usize { value[..cursor].rfind('\n').map_or(0, |index| index + 1) } fn line_end(value: &str, cursor: usize) -> usize { value[cursor..] .find('\n') .map_or(value.len(), |index| cursor + index) } fn byte_at_character(value: &str, start: usize, end: usize, column: usize) -> usize { value[start..end] .char_indices() .nth(column) .map_or(end, |(offset, _)| start + offset) } fn move_cursor_vertically(input: &mut InputState, delta: isize) { let start = line_start(&input.value, input.cursor); let end = line_end(&input.value, input.cursor); let column = input.value[start..input.cursor].chars().count(); input.cursor = if delta < 0 { if start == 0 { input.cursor } else { let target_end = start - 1; let target_start = input.value[..target_end] .rfind('\n') .map_or(0, |index| index + 1); byte_at_character(&input.value, target_start, target_end, column) } } else if end == input.value.len() { input.cursor } else { let target_start = end + 1; let target_end = input.value[target_start..] .find('\n') .map_or(input.value.len(), |index| target_start + index); byte_at_character(&input.value, target_start, target_end, column) }; } #[cfg(test)] mod tests { use super::*; use crate::preferences::AppPreferences; use tempfile::tempdir; fn key(code: KeyCode) -> KeyEvent { KeyEvent::new(code, KeyModifiers::NONE) } fn control(character: char) -> KeyEvent { KeyEvent::new(KeyCode::Char(character), KeyModifiers::CONTROL) } #[test] fn learn_records_real_commands_and_the_recording_plays_back() { let directory = tempdir().unwrap(); let path = directory.path().join("learn.agnd"); let mut db = Database::open(&path).unwrap(); db.save_macro(None, "learnme", "{learnme}\n", "").unwrap(); let mut app = App::new_with_preferences( db, path, AppPreferences::default(), directory.path().join("preferences.toml"), ) .unwrap(); app.handle_key(control('g')).unwrap(); app.handle_key(key(KeyCode::F(7))).unwrap(); app.handle_key(key(KeyCode::Char('n'))).unwrap(); for character in "recorded item".chars() { app.handle_key(key(KeyCode::Char(character))).unwrap(); } app.handle_key(key(KeyCode::Enter)).unwrap(); app.handle_key(control('g')).unwrap(); assert_eq!(app.macros[0].source, "{learnme}\nnrecorded item{ENTER}"); assert_eq!(app.items.len(), 1); app.handle_key(key(KeyCode::Enter)).unwrap(); app.tick().unwrap(); assert_eq!(app.items.len(), 2); assert!(app.items.iter().all(|item| item.text == "recorded item")); } #[test] fn plain_a_appends_a_learn_recording() { let directory = tempdir().unwrap(); let path = directory.path().join("append-learn.agnd"); let mut db = Database::open(&path).unwrap(); db.save_macro(None, "appendme", "{appendme}x", "").unwrap(); let mut app = App::new_with_preferences( db, path, AppPreferences::default(), directory.path().join("preferences.toml"), ) .unwrap(); app.handle_key(control('g')).unwrap(); app.handle_key(key(KeyCode::Char('a'))).unwrap(); app.handle_key(key(KeyCode::Char('y'))).unwrap(); app.handle_key(control('g')).unwrap(); assert_eq!(app.macros[0].source, "{appendme}xy"); } #[test] fn prompted_macro_returns_to_the_editor_it_opened() { let directory = tempdir().unwrap(); let path = directory.path().join("prompt.agnd"); let mut db = Database::open(&path).unwrap(); db.save_macro( None, "capture", "{capture}n{inputtext;Item text;%text}{type;%text}{enter}", "", ) .unwrap(); let mut app = App::new_with_preferences( db, path, AppPreferences::default(), directory.path().join("preferences.toml"), ) .unwrap(); app.start_macro("capture").unwrap(); app.tick().unwrap(); assert!(matches!( app.mode, Mode::Input(InputState { kind: InputKind::MacroPrompt(_), .. }) )); for character in "prompted item".chars() { app.handle_key(key(KeyCode::Char(character))).unwrap(); } app.handle_key(key(KeyCode::Enter)).unwrap(); assert!(matches!( app.mode, Mode::Input(InputState { kind: InputKind::NewItem, .. }) )); app.tick().unwrap(); assert_eq!(app.items.len(), 1); assert_eq!(app.items[0].text, "prompted item"); } #[test] fn macro_editor_captures_keys_and_f5_saves() { let directory = tempdir().unwrap(); let path = directory.path().join("editor.agnd"); let db = Database::open(&path).unwrap(); let mut app = App::new_with_preferences( db, path, AppPreferences::default(), directory.path().join("preferences.toml"), ) .unwrap(); app.handle_key(control('g')).unwrap(); app.handle_key(key(KeyCode::Insert)).unwrap(); app.handle_key(control('v')).unwrap(); app.handle_key(key(KeyCode::F(2))).unwrap(); app.handle_key(key(KeyCode::F(5))).unwrap(); assert!(matches!(app.mode, Mode::MacroManager)); assert_eq!(app.macros.len(), 1); assert_eq!(app.macros[0].source, "{macro1}\n{F2}"); app.handle_key(key(KeyCode::Char('b'))).unwrap(); app.handle_key(control('v')).unwrap(); app.handle_key(control('p')).unwrap(); app.handle_key(key(KeyCode::Enter)).unwrap(); assert_eq!(app.macros[0].key_binding, "{CtlP}"); } #[test] fn manager_runs_a_macro_in_the_screen_it_was_opened_from() { let directory = tempdir().unwrap(); let path = directory.path().join("context.agnd"); let mut db = Database::open(&path).unwrap(); db.add_item("Item with a note").unwrap(); db.save_macro(None, "typenote", "{typenote}x", "").unwrap(); let mut app = App::new_with_preferences( db, path, AppPreferences::default(), directory.path().join("preferences.toml"), ) .unwrap(); app.handle_key(key(KeyCode::F(5))).unwrap(); app.handle_key(control('g')).unwrap(); app.handle_key(key(KeyCode::Enter)).unwrap(); app.tick().unwrap(); let Mode::Input(input) = &app.mode else { panic!("macro did not return to the note editor"); }; assert!(matches!(input.kind, InputKind::Note(_))); assert_eq!(input.value, "x"); } }