2239 lines
80 KiB
Rust
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");
|
|
}
|
|
}
|