Files
RogueAgenda/src/app.rs
2026-08-16 21:39:18 +00:00

2239 lines
80 KiB
Rust

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<i64>),
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<i64>),
View(Option<i64>),
Preferences,
DocumentSettings,
}
#[derive(Debug, Clone)]
pub struct FormState {
pub kind: FormKind,
pub values: Vec<String>,
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<ChoiceOption>,
pub selected: usize,
}
#[derive(Debug, Clone)]
pub enum Mode {
Normal,
Help,
Input(InputState),
Properties(PropsState),
Form(FormState),
Dependencies {
item_id: i64,
choices: Vec<Item>,
selected: usize,
},
Categories,
Views,
CategoryManager,
Menu,
Confirm {
prompt: String,
action: ConfirmAction,
},
Choice(ChoiceState),
MacroManager,
MacroMenu {
title: String,
prompt: String,
choices: Vec<MenuChoice>,
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<i64>),
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<ViewDef>,
pub view_index: usize,
pub items: Vec<Item>,
pub selected: usize,
pub scroll: usize,
pub search: String,
pub marked: HashSet<i64>,
pub categories: Vec<Category>,
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<Rect>,
pub view_regions: Vec<Rect>,
pub choice_regions: Vec<Rect>,
pub macros: Vec<MacroDef>,
pub macro_selected: usize,
pub recording: Option<RecordingState>,
pub macro_capture_key: bool,
macro_runtime: Option<MacroRuntime>,
macro_globals: std::collections::HashMap<String, String>,
macro_return_mode: Option<Mode>,
macro_key_hit: bool,
macro_origin_mode: Option<Mode>,
last_autosave: Instant,
current_day: NaiveDate,
}
impl App {
pub fn new(db: Database, path: PathBuf) -> Result<Self> {
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> {
Self::build(db, path, preferences, preferences_path)
}
fn build(
db: Database,
path: PathBuf,
preferences: AppPreferences,
preferences_path: PathBuf,
) -> Result<Self> {
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<i64> {
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<i64>, 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<Category>) {
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<ViewDef>) {
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<bool> {
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<Vec<ChoiceOption>> {
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<Option<String>> {
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");
}
}