diff --git a/PROJECT.md b/PROJECT.md index 2d9230d..59f8a52 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -35,6 +35,9 @@ interaction, conventional shortcuts, UTF-8, and modern durability. assignment, aliases/abbreviations, incremental structure, and named live views. - Period reviews from the Los Angeles Times and HUMANIST archives corroborate long notes, free-form entry, priorities, Boolean reports, and automatic filing. +- The preserved Agenda 2.0 Help file documents the Macro Manager, Learn workflow, + note-style Macro Editor, complete brace-command catalog, variables, special + values, error/break handlers, key notation, macro menus, and manager indicators. Research URLs: @@ -42,6 +45,7 @@ Research URLs: - https://lock.cmpxchg8b.com/lotusagenda.html - https://www.bobnewell.net/agenda/agfaq.txt.html - https://www.bobnewell.net/agenda/atlantic.txt.html +- https://www.bobnewell.net/agenda/AgendaHelp.htm - https://dhhumanist.org/archives/Virginia/v02/0410.html - https://www.latimes.com/archives/la-xpm-1988-07-28-fi-9745-story.html @@ -93,6 +97,20 @@ Research URLs: - [x] Text and iCalendar import; view-scoped CSV, JSON, Markdown, styled HTML, and iCalendar export with notes, categories, dates, priority, completion, and recurring `RRULE` schedules. +- [x] Document-local Agenda macro programs with raw keystroke playback; named and + modified key commands; local and persistent global variables; arithmetic and + string expressions; branches, loops, cross-macro calls, error/break handlers; + input boxes, messages, horizontal/vertical menus; special context variables; + AUTOEXEC; and attachable keyboard shortcuts. +- [x] Agenda-inspired Macro Manager (`Ctrl-G`, with legacy `Alt-F3` alias) with + add/edit/run/delete/key binding + and replace/append Learn recording. Recordings operate across the live TUI, + show a LEARN indicator, and stop/save from anywhere with `Ctrl-G`; plain `a` + replaces the unreliable modified-function-key append gesture. +- [x] Responsive full-screen Macro Editor with original-style `F5` return/save, + `Esc` cancellation and terminal-safe `Ctrl-V` key capture, plus modern cursor editing and + syntax highlighting for commands, labels, variables, values, comments, + delimiters, and malformed source. - [x] Demo data, CLI help, README, MIT license, and unit/integration tests. ## Supported with a modern interpretation @@ -110,7 +128,8 @@ Research URLs: filesystem backup tools also work on `.agnd` files. - [x] Clipboard/accessory capture: terminal paste into the new-item form replaces the DOS resident accessory. -- [x] Macros: conventional keys and direct commands replace keystroke-replay macros. +- [x] Macros retain Agenda's compact hybrid of literal keystrokes and `{COMMAND}` + entries while applying those actions to Rogue Agenda's modern TUI. ## Delayed / unsupported @@ -121,8 +140,14 @@ a larger follow-on implementation: view columns support `sum`, `avg`, `count`, `min`, and `max` aggregates. - [ ] Rich-text/font markers, printer control sequences, page-layout preview, and named printer configurations. -- [ ] Lotus Metro/List Manager/STF compatibility, DOS accessories, and Agenda macro - compatibility (explicitly outside the project's format-compatibility goal). +- [ ] Lotus Metro/List Manager/STF compatibility, DOS accessories, and import of + external legacy `.MAC` files (outside the project's format-compatibility goal). +- [ ] Historic macro protection, hidden-list indicators, external source-file + attachment, and per-screen run restrictions. Macro source, Learn recording, + execution, key binding, AUTOEXEC, and persistent variables are already native. +- [ ] DOS macro single-stepping/speed/frozen-window effects and an internal + clipboard bridge. Their command syntax and `#CLIPBOARD` value are recognized; + screen-pacing directives are safe no-ops and the clipboard currently reads empty. - [ ] Password encryption. A description field is supported by schema metadata; weak historic password protection will not be imitated without real encryption. - [ ] Desktop notifications while the application is closed. Due alarms are shown diff --git a/README.md b/README.md index 0ac6c6b..4eb2c9b 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,10 @@ database with the `.agnd` extension. clickable function-key commands, and an in-app help screen. - Application-wide Preferences plus per-document settings for themes, display and locale conventions, autosave, confirmations, dates, filing, Trash, and backups. +- Document-local Agenda-style macros: literal keystroke playback, brace commands, + persistent variables, branches, loops, calls, prompts and menus, AUTOEXEC, + key bindings, and a Learn recorder. The responsive macro editor adds syntax + highlighting and key capture without sanding away the unusual original language. ## Build and run @@ -99,6 +103,7 @@ or build failure stops the gate. | `F10` or `m` | Command menu | | `r` | Edit prerequisites for the selected item | | `/` | Search the current view | +| `Ctrl-G` | Macro Manager / stop and save a Learn recording | | `h` / `l` | Previous / next saved view (`[` / `]` are aliases) | | `Ctrl-S` | Save/checkpoint | | `?` or `F1` | Help | @@ -149,6 +154,64 @@ Use `F10`, then `b` to create a consistent sibling backup named `document.agnd.bak`, or enable backup-on-open. Use `F10`, then `t` to empty Trash. Permanent operations request confirmation unless that preference is disabled. +## Macros and Learn recording + +Press `Ctrl-G` to open the Macro Manager. Its layout and primary keys follow +Agenda 2.0: `Insert` adds, `F2` edits, `Enter` runs, `F7` starts Learn recording, +plain `a` appends a recording, `F6` attaches a key, and `F4`/`Delete` removes. +Clicking a macro selects it. While Learn is active, Rogue Agenda shows a `LEARN` +indicator and records the real keys you use throughout the application; press +`Ctrl-G` anywhere to stop and save. The historical `Alt-F3` and `Shift-F7` +combinations remain aliases when a terminal can transmit them. + +The first command names the macro. Everything outside braces is played as literal +typing, while entries inside braces represent keys or commands. For example: + +```text +{capturetask} +n +{inputtext;What needs doing?;%task} +{type;%task} +{enter} +``` + +This opens New Item, asks a question, types the answer into the still-open item +editor, and saves it. Newlines and indentation in macro source are for readability +and are not played. The language is case-insensitive. + +The implementation covers the original command families: + +- Keys and highlight control, including repeat counts: `{enter;2}`, `{altf6}`, + `{ctlpgup}`, `{shftab}`, and `{selection;setting}`. +- Variables and strings: `{defstr}`, `{defint}`, `{deffloat}`, `{let}`, `{clear}`, + `{undef}`, `{append}`, `{find}`, `{length}`, `{leftstr}`, `{midstr}`, and + `{rightstr}`. Local names begin with `%`; persistent document globals use `%%`. +- Control flow: `{branch}`, `{call}`, `{for}`, `{goto}`, `{if}`, `{ifeq}`, + `{ifkey}`, `{ifnoteq}`, `{label}`, `{onbreak}`, `{onerror}`, `{return}`, and + `{quit}`. +- Interaction: `{alert}`, `{getkey}`, `{inputcat}`, `{inputfile}`, `{inputtext}`, + `{largebox}`, `{lotusmenu}`, and `{rollmenu}`. +- Agenda special variables such as `#DATE`, `#TIME`, `#FILENAME`, `#VIEWNAME`, + `#HIGHLIGHT_VALUE`, `#MARK_COUNT`, `#ARGCOUNT`, and `#ASCII(nnn)`. + +The DOS screen-pacing directives (`DEBUGON`/`DEBUGOFF`, `SPEED`, and the +`WINDOWS*` commands) are accepted but have no visual effect in the continuously +redrawn TUI. `#CLIPBOARD` is reserved but remains empty until Rogue Agenda gains +an application-owned copy/cut buffer. + +`AUTOEXEC` runs automatically when its document opens. Global `%%` variables are +stored in the `.agnd` database. `Ctrl-C` interrupts a running macro or invokes its +`{onbreak}` handler. Macro-created prompts and menus support both keyboard and +mouse operation. + +The editor is a responsive, near-full-screen reinterpretation of Agenda's note- +style Macro Editor. `Enter` adds a line, `F5` or `Ctrl-S` saves, `Esc` cancels, +and `Ctrl-V` captures the next pressed key as source notation. The same `Ctrl-V` +shortcut captures a key in the F6 binding field. `Alt-=` remains an optional +historical alias. Commands, labels, +local/global/special variables, numbers, comments, delimiters, and syntax errors +have distinct colors. + ## Designing live views Open the View Manager with `F8`. Press `n` to create a view, `e` to edit one, diff --git a/src/app.rs b/src/app.rs index c4e5f21..328d92c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -11,7 +11,11 @@ use ratatui::layout::Rect; use crate::{ db::Database, - model::{Category, DocumentSettings, Item, ItemChanges, ViewDef}, + macro_lang::{ + MacroAction, MacroContext, MacroRuntime, MenuChoice, PromptKind, key_to_source, macro_name, + parse_key_binding, + }, + model::{Category, DocumentSettings, Item, ItemChanges, MacroDef, ViewDef}, preferences::AppPreferences, }; @@ -21,6 +25,9 @@ pub enum InputKind { EditItem(i64), Note(i64), Search, + MacroEditor(Option), + MacroBinding(i64), + MacroPrompt(String), } #[derive(Debug, Clone)] @@ -89,6 +96,27 @@ pub enum Mode { action: ConfirmAction, }, Choice(ChoiceState), + MacroManager, + MacroMenu { + title: String, + prompt: String, + choices: Vec, + selected: usize, + }, + MacroMessage { + title: String, + message: String, + }, + MacroGetKey { + variable: String, + }, +} + +#[derive(Debug, Clone)] +pub struct RecordingState { + pub macro_id: i64, + pub name: String, + pub source: String, } #[derive(Debug, Clone)] @@ -97,6 +125,7 @@ pub enum ConfirmAction { DeleteCategory(i64), EmptyTrash, PermanentlyDiscard(Vec), + DeleteMacro(i64), } pub struct App { @@ -122,6 +151,15 @@ pub struct App { pub command_regions: Vec, pub view_regions: Vec, pub choice_regions: Vec, + pub macros: Vec, + pub macro_selected: usize, + pub recording: Option, + pub macro_capture_key: bool, + macro_runtime: Option, + macro_globals: std::collections::HashMap, + macro_return_mode: Option, + macro_key_hit: bool, + macro_origin_mode: Option, last_autosave: Instant, current_day: NaiveDate, } @@ -151,6 +189,8 @@ impl App { 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, @@ -174,10 +214,27 @@ impl App { 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) } @@ -212,6 +269,102 @@ impl App { } 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 => { @@ -228,10 +381,13 @@ impl App { 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()?; @@ -252,6 +408,427 @@ impl App { 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() } @@ -262,6 +839,15 @@ impl App { 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), @@ -433,9 +1019,19 @@ impl App { return Ok(()); }; if key.code == KeyCode::Esc { - self.mode = Mode::Normal; - self.status = "Canceled".into(); - return Ok(()); + 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) @@ -443,6 +1039,9 @@ impl App { { 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), @@ -505,6 +1104,55 @@ impl App { 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()?; @@ -687,6 +1335,7 @@ impl App { 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()); @@ -718,6 +1367,7 @@ impl App { 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; @@ -1113,6 +1763,29 @@ impl App { 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 @@ -1229,6 +1902,58 @@ pub(crate) fn form_choices(kind: &FormKind, field: usize) -> Option &'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> { @@ -1245,11 +1970,32 @@ 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 { @@ -1320,3 +2066,173 @@ fn move_cursor_vertically(input: &mut InputState, delta: isize) { 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"); + } +} diff --git a/src/db.rs b/src/db.rs index f337b9d..b6fb141 100644 --- a/src/db.rs +++ b/src/db.rs @@ -9,7 +9,8 @@ use rusqlite::{Connection, OptionalExtension, params}; use crate::filter; use crate::model::{ - Category, CategoryRule, DocumentSettings, Item, ItemChanges, ViewColumn, ViewDef, ViewSection, + Category, CategoryRule, DocumentSettings, Item, ItemChanges, MacroDef, ViewColumn, ViewDef, + ViewSection, }; use crate::parser::{extract_when_configured, next_occurrence}; @@ -142,6 +143,98 @@ impl Database { } self.conn.pragma_update(None, "user_version", 3)?; } + self.conn.execute_batch( + "CREATE TABLE IF NOT EXISTS macros ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE COLLATE NOCASE, + source TEXT NOT NULL, + key_binding TEXT NOT NULL DEFAULT '', + sort_order INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE IF NOT EXISTS macro_variables ( + name TEXT PRIMARY KEY COLLATE NOCASE, + value TEXT NOT NULL + );", + )?; + if version < 4 { + self.conn.pragma_update(None, "user_version", 4)?; + } + Ok(()) + } + + pub fn macros(&self) -> Result> { + let mut statement = self + .conn + .prepare("SELECT id,name,source,key_binding FROM macros ORDER BY sort_order,id")?; + Ok(statement + .query_map([], |row| { + Ok(MacroDef { + id: row.get(0)?, + name: row.get(1)?, + source: row.get(2)?, + key_binding: row.get(3)?, + }) + })? + .collect::>>()?) + } + + pub fn save_macro( + &mut self, + id: Option, + name: &str, + source: &str, + key_binding: &str, + ) -> Result { + if name.trim().is_empty() { + bail!("macro name cannot be empty") + } + if let Some(id) = id { + self.conn.execute( + "UPDATE macros SET name=?1,source=?2,key_binding=?3 WHERE id=?4", + params![name.trim(), source, key_binding.trim(), id], + )?; + Ok(id) + } else { + let order: i64 = self.conn.query_row( + "SELECT COALESCE(MAX(sort_order),-1)+1 FROM macros", + [], + |row| row.get(0), + )?; + self.conn.execute( + "INSERT INTO macros(name,source,key_binding,sort_order) VALUES(?1,?2,?3,?4)", + params![name.trim(), source, key_binding.trim(), order], + )?; + Ok(self.conn.last_insert_rowid()) + } + } + + pub fn delete_macro(&mut self, id: i64) -> Result<()> { + self.conn.execute("DELETE FROM macros WHERE id=?1", [id])?; + Ok(()) + } + + pub fn macro_variables(&self) -> Result> { + let mut statement = self + .conn + .prepare("SELECT name,value FROM macro_variables")?; + Ok(statement + .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))? + .collect::>()?) + } + + pub fn save_macro_variables( + &mut self, + variables: &std::collections::HashMap, + ) -> Result<()> { + let transaction = self.conn.transaction()?; + transaction.execute("DELETE FROM macro_variables", [])?; + for (name, value) in variables { + transaction.execute( + "INSERT INTO macro_variables(name,value) VALUES(?1,?2)", + params![name, value], + )?; + } + transaction.commit()?; Ok(()) } diff --git a/src/macro_lang.rs b/src/macro_lang.rs new file mode 100644 index 0000000..4391c6e --- /dev/null +++ b/src/macro_lang.rs @@ -0,0 +1,1157 @@ +use std::collections::{HashMap, VecDeque}; + +use anyhow::{Result, bail}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +use crate::model::MacroDef; + +#[derive(Debug, Clone)] +enum Token { + Raw(char), + Command { name: String, args: Vec }, +} + +#[derive(Debug, Clone)] +struct Program { + name: String, + tokens: Vec, + labels: HashMap, +} + +#[derive(Debug, Clone)] +struct LoopState { + variable: String, + current: f64, + final_value: f64, + increment: f64, + body: usize, + end_label: String, +} + +#[derive(Debug, Clone)] +struct Frame { + program: String, + pc: usize, + locals: HashMap, + arg_count: usize, + loops: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PromptKind { + Text, + Category, + File, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MenuChoice { + pub label: String, + pub target: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MacroAction { + Key(KeyEvent), + Prompt { + kind: PromptKind, + prompt: String, + variable: String, + }, + Menu { + title: String, + prompt: String, + choices: Vec, + }, + Message { + title: String, + message: String, + }, + GetKey { + variable: String, + }, + IfKey { + target: String, + }, + Selection { + prompt: String, + }, + Finished, +} + +#[derive(Debug, Clone, Default)] +pub struct MacroContext { + pub date: String, + pub time: String, + pub filename: String, + pub filepath: String, + pub view_name: String, + pub mode: String, + pub highlight_type: String, + pub highlight_value: String, + pub mark_count: usize, + pub marked_in_view: usize, + pub clipboard: String, + pub depth: usize, + pub key_hit: bool, + pub prompt: String, +} + +pub struct MacroRuntime { + programs: HashMap, + frames: Vec, + globals: HashMap, + pending_keys: VecDeque, + waiting: bool, + steps: usize, + error_handler: Option, + break_handler: Option, + last_error: Option, +} + +impl MacroRuntime { + pub fn new(entry: &str, macros: &[MacroDef], globals: HashMap) -> Result { + let mut programs = HashMap::new(); + for definition in macros { + let program = parse(&definition.source)?; + programs.insert(program.name.to_lowercase(), program); + } + let entry = entry.to_lowercase(); + if !programs.contains_key(&entry) { + bail!("macro “{entry}” does not exist") + } + Ok(Self { + programs, + frames: vec![Frame { + program: entry, + pc: 0, + locals: HashMap::new(), + arg_count: 0, + loops: vec![], + }], + globals, + pending_keys: VecDeque::new(), + waiting: false, + steps: 0, + error_handler: None, + break_handler: None, + last_error: None, + }) + } + + pub fn globals(&self) -> &HashMap { + &self.globals + } + + pub fn resume_value(&mut self, variable: &str, value: String) -> Result<()> { + self.set_variable(variable, value)?; + self.waiting = false; + Ok(()) + } + + pub fn resume_menu(&mut self, target: &str) -> Result<()> { + self.jump_to_label(target)?; + self.waiting = false; + Ok(()) + } + + pub fn resume_message(&mut self) { + self.waiting = false; + } + + pub fn resume_if_key(&mut self, target: &str, key_hit: bool) -> Result<()> { + if key_hit { + self.jump_to_label(target)?; + } + self.waiting = false; + Ok(()) + } + + pub fn trigger_break(&mut self) -> Result { + let Some(handler) = self.break_handler.clone() else { + return Ok(false); + }; + self.push_handler(&handler)?; + Ok(true) + } + + pub fn next_action(&mut self, context: &MacroContext) -> Result> { + if self.waiting { + return Ok(None); + } + if let Some(key) = self.pending_keys.pop_front() { + return Ok(Some(MacroAction::Key(key))); + } + loop { + self.steps += 1; + if self.steps > 100_000 { + bail!("macro stopped after 100,000 instructions (possible infinite loop)") + } + let Some(frame) = self.frames.last_mut() else { + return Ok(Some(MacroAction::Finished)); + }; + let program = self + .programs + .get(&frame.program) + .ok_or_else(|| anyhow::anyhow!("macro program disappeared"))?; + let Some(token) = program.tokens.get(frame.pc).cloned() else { + self.frames.pop(); + continue; + }; + frame.pc += 1; + match token { + Token::Raw(character) => { + return Ok(Some(MacroAction::Key(KeyEvent::new( + KeyCode::Char(character), + KeyModifiers::NONE, + )))); + } + Token::Command { name, args } => { + match self.execute_command(&name, &args, context) { + Ok(Some(action)) => return Ok(Some(action)), + Ok(None) => {} + Err(error) => { + let Some(handler) = self.error_handler.clone() else { + return Err(error); + }; + self.last_error = Some(format!("{error:#}")); + self.push_handler(&handler)?; + } + } + } + } + } + } + + fn execute_command( + &mut self, + name: &str, + args: &[String], + context: &MacroContext, + ) -> Result> { + if let Some(key) = named_key(name) { + let repeat = args + .first() + .map(|value| self.resolve(value, context)) + .transpose()? + .and_then(|value| value.parse::().ok()) + .unwrap_or(1) + .clamp(1, 10_000); + self.pending_keys.extend(std::iter::repeat_n(key, repeat)); + return Ok(self.pending_keys.pop_front().map(MacroAction::Key)); + } + match name { + "COMMENT" | "DEBUGON" | "DEBUGOFF" | "WINDOWSOFF" | "WINDOWSON" | "WINDOWSUPD" + | "SPEED" => {} + "DEFSTR" => { + for variable in args { + self.set_variable(variable, String::new())?; + } + } + "DEFINT" | "DEFFLOAT" => { + for variable in args { + self.set_variable(variable, "0".into())?; + } + } + "LET" => { + require_args(name, args, 2)?; + let value = self.resolve_expression_or_text(&args[1], context)?; + self.set_variable(&args[0], value)?; + } + "CLEAR" => { + if args + .first() + .is_some_and(|value| value.eq_ignore_ascii_case("ALL")) + { + if let Some(frame) = self.frames.last_mut() { + frame.locals.clear(); + } + self.globals.clear(); + } else { + for variable in args { + let current = self.variable(variable, context).unwrap_or_default(); + self.set_variable( + variable, + if current.parse::().is_ok() { + "0".into() + } else { + String::new() + }, + )?; + } + } + } + "UNDEF" => { + for variable in args { + if variable.eq_ignore_ascii_case("ALL") { + if let Some(frame) = self.frames.last_mut() { + frame.locals.clear(); + } + self.globals.clear(); + } else if variable.starts_with("%%") { + self.globals.remove(&variable.to_uppercase()); + } else if let Some(frame) = self.frames.last_mut() { + frame.locals.remove(&variable.to_uppercase()); + } + } + } + "APPEND" => { + require_args(name, args, 2)?; + let mut value = self.variable(&args[0], context).unwrap_or_default(); + value.push_str(&self.resolve(&args[1], context)?); + self.set_variable(&args[0], value)?; + } + "LENGTH" => { + require_args(name, args, 2)?; + let length = self.resolve(&args[1], context)?.chars().count(); + self.set_variable(&args[0], length.to_string())?; + } + "FIND" => { + require_args(name, args, 4)?; + let haystack = self.resolve(&args[1], context)?; + let needle = self.resolve(&args[2], context)?; + let start = self + .resolve(&args[3], context)? + .parse::() + .unwrap_or(1); + let found = haystack + .get(start.saturating_sub(1)..) + .and_then(|tail| tail.find(&needle)) + .map_or(0, |position| position + start); + self.set_variable(&args[0], found.to_string())?; + } + "LEFTSTR" | "RIGHTSTR" | "MIDSTR" => { + let required = if name == "MIDSTR" { 4 } else { 3 }; + require_args(name, args, required)?; + let source = self.resolve(&args[1], context)?; + let characters = source.chars().collect::>(); + let (start, width) = if name == "MIDSTR" { + ( + self.resolve(&args[2], context)? + .parse::() + .unwrap_or(1) + .saturating_sub(1), + self.resolve(&args[3], context)? + .parse::() + .unwrap_or(0), + ) + } else { + let width = self + .resolve(&args[2], context)? + .parse::() + .unwrap_or(0); + let start = if name == "RIGHTSTR" { + characters.len().saturating_sub(width) + } else { + 0 + }; + (start, width) + }; + let result = characters + .iter() + .skip(start) + .take(width) + .collect::(); + self.set_variable(&args[0], result)?; + } + "TYPE" => { + require_args(name, args, 1)?; + let value = self.resolve(&args[0], context)?; + self.pending_keys.extend( + value.chars().map(|character| { + KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE) + }), + ); + return Ok(self.pending_keys.pop_front().map(MacroAction::Key)); + } + "BRANCH" => { + require_args(name, args, 1)?; + self.jump_to_label(&args[0])?; + } + "IF" => { + require_args(name, args, 2)?; + if self.evaluate_condition(&args[0], context)? { + self.jump_to_label(&args[1])?; + } + } + "IFKEY" => { + require_args(name, args, 1)?; + self.waiting = true; + return Ok(Some(MacroAction::IfKey { + target: args[0].clone(), + })); + } + "IFEQ" | "IFNOTEQ" => { + require_args(name, args, 3)?; + let equal = self.resolve(&args[0], context)? == self.resolve(&args[1], context)?; + if equal == (name == "IFEQ") { + self.jump_to_label(&args[2])?; + } + } + "FOR" => { + require_args(name, args, 5)?; + let variable = args[0].clone(); + let initial = self.evaluate_number(&args[1], context)?; + let final_value = self.evaluate_number(&args[2], context)?; + let increment = self.evaluate_number(&args[3], context)?; + if increment == 0.0 { + bail!("FOR increment cannot be zero") + } + self.set_variable(&variable, format_number(initial))?; + let body = self.frames.last().map_or(0, |frame| frame.pc); + self.frames + .last_mut() + .expect("frame exists") + .loops + .push(LoopState { + variable, + current: initial, + final_value, + increment, + body, + end_label: args[4].to_lowercase(), + }); + } + "LABEL" => { + require_args(name, args, 1)?; + let label = args[0].to_lowercase(); + let repeat = self + .frames + .last() + .and_then(|frame| frame.loops.last()) + .is_some_and(|loop_state| loop_state.end_label == label); + if repeat { + let (variable, value, body, keep_looping) = { + let loop_state = self + .frames + .last_mut() + .and_then(|frame| frame.loops.last_mut()) + .expect("loop exists"); + loop_state.current += loop_state.increment; + let keep = if loop_state.increment > 0.0 { + loop_state.current <= loop_state.final_value + } else { + loop_state.current >= loop_state.final_value + }; + ( + loop_state.variable.clone(), + format_number(loop_state.current), + loop_state.body, + keep, + ) + }; + if keep_looping { + self.set_variable(&variable, value)?; + self.frames.last_mut().expect("frame exists").pc = body; + } else { + self.frames.last_mut().expect("frame exists").loops.pop(); + } + } + } + "CALL" => { + require_args(name, args, 1)?; + let target = self.resolve(&args[0], context)?.to_lowercase(); + self.push_call(&target, &args[1..], context)?; + } + "GOTO" => { + require_args(name, args, 1)?; + let target = self.resolve(&args[0], context)?.to_lowercase(); + self.frames.pop(); + self.push_call(&target, &[], context)?; + } + "RETURN" => { + self.frames.pop(); + } + "QUIT" => { + if args + .first() + .is_some_and(|argument| argument.eq_ignore_ascii_case("ERROR")) + { + bail!("macro requested an error exit") + } + self.frames.clear(); + } + "INPUTTEXT" | "INPUTCAT" | "INPUTFILE" => { + require_args(name, args, 2)?; + let prompt = self.resolve(&args[0], context)?; + self.waiting = true; + return Ok(Some(MacroAction::Prompt { + kind: match name { + "INPUTCAT" => PromptKind::Category, + "INPUTFILE" => PromptKind::File, + _ => PromptKind::Text, + }, + prompt, + variable: args[1].clone(), + })); + } + "ALERT" | "LARGEBOX" => { + require_args(name, args, usize::from(name == "ALERT") + 1)?; + let (title, message) = if args.len() >= 2 { + ( + self.resolve(&args[0], context)?, + self.resolve(&args[1], context)?, + ) + } else { + ("Macro".into(), self.resolve(&args[0], context)?) + }; + self.waiting = true; + return Ok(Some(MacroAction::Message { title, message })); + } + "GETKEY" => { + require_args(name, args, 1)?; + self.waiting = true; + return Ok(Some(MacroAction::GetKey { + variable: args[0].clone(), + })); + } + "SELECTION" => { + require_args(name, args, 1)?; + return Ok(Some(MacroAction::Selection { + prompt: self.resolve(&args[0], context)?, + })); + } + "LOTUSMENU" => { + require_args(name, args, 4)?; + let count = self + .resolve(&args[0], context)? + .parse::() + .unwrap_or(0); + let choices = (0..count) + .filter_map(|index| { + let offset = 1 + index * 3; + Some(MenuChoice { + label: self.resolve(args.get(offset)?, context).ok()?, + target: args.get(offset + 2)?.to_lowercase(), + }) + }) + .collect::>(); + self.waiting = true; + return Ok(Some(MacroAction::Menu { + title: "Macro Menu".into(), + prompt: "Choose a command".into(), + choices, + })); + } + "ROLLMENU" => { + require_args(name, args, 5)?; + let count = self + .resolve(&args[2], context)? + .parse::() + .unwrap_or(0); + let choices = (0..count) + .filter_map(|index| { + let offset = 3 + index * 2; + Some(MenuChoice { + label: self.resolve(args.get(offset)?, context).ok()?, + target: args.get(offset + 1)?.to_lowercase(), + }) + }) + .collect::>(); + self.waiting = true; + return Ok(Some(MacroAction::Menu { + title: self.resolve(&args[0], context)?, + prompt: self.resolve(&args[1], context)?, + choices, + })); + } + "ONERROR" | "ONBREAK" => { + require_args(name, args, 1)?; + let handler = self.resolve(&args[0], context)?.to_lowercase(); + if !self.programs.contains_key(&handler) { + bail!("handler macro “{handler}” does not exist") + } + if name == "ONERROR" { + self.error_handler = Some(handler); + } else { + self.break_handler = Some(handler); + } + } + other => bail!("unknown macro command {{{other}}}"), + } + Ok(None) + } + + fn push_call(&mut self, target: &str, args: &[String], context: &MacroContext) -> Result<()> { + if self.frames.len() >= 64 { + bail!("macro call depth exceeded 64") + } + if !self.programs.contains_key(target) { + bail!("called macro “{target}” does not exist") + } + let mut locals = HashMap::new(); + for (index, argument) in args.iter().enumerate() { + locals.insert(format!("%{}", index + 1), self.resolve(argument, context)?); + } + self.frames.push(Frame { + program: target.into(), + pc: 0, + locals, + arg_count: args.len(), + loops: vec![], + }); + Ok(()) + } + + fn push_handler(&mut self, target: &str) -> Result<()> { + if self.frames.len() >= 64 { + bail!("macro call depth exceeded 64") + } + if !self.programs.contains_key(target) { + bail!("handler macro “{target}” does not exist") + } + self.frames.push(Frame { + program: target.into(), + pc: 0, + locals: HashMap::new(), + arg_count: 0, + loops: vec![], + }); + Ok(()) + } + + fn jump_to_label(&mut self, label: &str) -> Result<()> { + let frame = self + .frames + .last_mut() + .ok_or_else(|| anyhow::anyhow!("macro has no active frame"))?; + let program = self + .programs + .get(&frame.program) + .ok_or_else(|| anyhow::anyhow!("macro program disappeared"))?; + frame.pc = *program + .labels + .get(&label.to_lowercase()) + .ok_or_else(|| anyhow::anyhow!("label “{label}” does not exist"))?; + Ok(()) + } + + fn set_variable(&mut self, name: &str, value: String) -> Result<()> { + let name = name.trim().to_uppercase(); + if name.starts_with("%%") { + self.globals.insert(name, value); + } else if name.starts_with('%') { + self.frames + .last_mut() + .ok_or_else(|| anyhow::anyhow!("macro has no active frame"))? + .locals + .insert(name, value); + } else { + bail!("macro variable “{name}” must begin with % or %%") + } + Ok(()) + } + + fn variable(&self, name: &str, context: &MacroContext) -> Option { + let name = name.trim().to_uppercase(); + if name.starts_with("%%") { + self.globals.get(&name).cloned() + } else if name.starts_with('%') { + self.frames.last()?.locals.get(&name).cloned() + } else if name.starts_with('#') { + self.special_variable(&name, context) + } else { + None + } + } + + fn special_variable(&self, name: &str, context: &MacroContext) -> Option { + Some(match name { + "#ARGCOUNT" => self.frames.last()?.arg_count.to_string(), + "#CLIPBOARD" => context.clipboard.clone(), + "#DATE" => context.date.clone(), + "#DEPTH" => context.depth.to_string(), + "#ERRMSG" => self.last_error.clone().unwrap_or_default(), + "#ERRNO" => usize::from(self.last_error.is_some()).to_string(), + "#FALSE" => "0".into(), + "#FILENAME" => context.filename.clone(), + "#FILEPATH" => context.filepath.clone(), + "#HIGHLIGHT_TYPE" => context.highlight_type.clone(), + "#HIGHLIGHT_VALUE" => context.highlight_value.clone(), + "#KEYHIT" => usize::from(context.key_hit).to_string(), + "#MARK_COUNT" => context.mark_count.to_string(), + "#MARKED_IN_VIEW" => context.marked_in_view.to_string(), + "#MODE" => context.mode.clone(), + "#NULL" => String::new(), + "#PROMPT" => context.prompt.clone(), + "#TIME" => context.time.clone(), + "#TRUE" => "1".into(), + "#VIEWNAME" => context.view_name.clone(), + value if value.starts_with("#ASCII(") && value.ends_with(')') => { + let number = value[7..value.len() - 1].parse::().ok()?; + char::from(number).to_string() + } + _ => return None, + }) + } + + fn resolve(&self, value: &str, context: &MacroContext) -> Result { + let value = value.trim(); + if (value.starts_with('%') || value.starts_with('#')) + && !value.contains(char::is_whitespace) + { + return self + .variable(value, context) + .ok_or_else(|| anyhow::anyhow!("undefined macro variable “{value}”")); + } + Ok(value.trim_matches('"').to_owned()) + } + + fn resolve_expression_or_text(&self, value: &str, context: &MacroContext) -> Result { + if let Ok(number) = self.evaluate_number(value, context) { + Ok(format_number(number)) + } else { + self.resolve(value, context) + } + } + + fn evaluate_condition(&self, expression: &str, context: &MacroContext) -> Result { + let expression = expression + .trim() + .strip_prefix('(') + .and_then(|value| value.strip_suffix(')')) + .unwrap_or(expression.trim()); + for operator in ["<=", ">=", "<>", "=", "<", ">"] { + if let Some((left, right)) = expression.split_once(operator) { + let left_text = self.resolve_expression_or_text(left, context)?; + let right_text = self.resolve_expression_or_text(right, context)?; + let numeric = left_text + .parse::() + .ok() + .zip(right_text.parse::().ok()); + return Ok(match (operator, numeric) { + ("<=", Some((left, right))) => left <= right, + (">=", Some((left, right))) => left >= right, + ("<", Some((left, right))) => left < right, + (">", Some((left, right))) => left > right, + ("=", Some((left, right))) => (left - right).abs() < f64::EPSILON, + ("<>", Some((left, right))) => (left - right).abs() >= f64::EPSILON, + ("=", None) => left_text.eq_ignore_ascii_case(&right_text), + ("<>", None) => !left_text.eq_ignore_ascii_case(&right_text), + ("<", None) => left_text < right_text, + (">", None) => left_text > right_text, + ("<=", None) => left_text <= right_text, + (">=", None) => left_text >= right_text, + _ => false, + }); + } + } + Ok(self.evaluate_number(expression, context)? != 0.0) + } + + fn evaluate_number(&self, expression: &str, context: &MacroContext) -> Result { + NumericParser::new(expression, |name| { + self.variable(name, context) + .and_then(|value| value.parse::().ok()) + }) + .parse() + } +} + +fn parse(source: &str) -> Result { + let mut tokens = Vec::new(); + let mut characters = source.chars().peekable(); + let mut line_start = true; + while let Some(character) = characters.next() { + if matches!(character, '\r' | '\n') { + line_start = true; + continue; + } + if line_start && matches!(character, ' ' | '\t') { + continue; + } + line_start = false; + if character == '{' { + let mut contents = String::new(); + let mut closed = false; + for next in characters.by_ref() { + if next == '}' { + closed = true; + break; + } + contents.push(next); + } + if !closed { + bail!("unterminated macro command") + } + let mut parts = contents.split(';').map(|part| part.trim().to_owned()); + let name = parts.next().unwrap_or_default().to_uppercase(); + tokens.push(Token::Command { + name, + args: parts.collect(), + }); + } else { + tokens.push(Token::Raw(character)); + } + } + let Some(Token::Command { name, args }) = tokens.first() else { + bail!("macro source must start with {{macroname}}") + }; + if !args.is_empty() || name.is_empty() { + bail!("first macro entry must be {{macroname}}") + } + let macro_name = name.to_lowercase(); + tokens.remove(0); + let labels = tokens + .iter() + .enumerate() + .filter_map(|(index, token)| match token { + Token::Command { name, args } if name == "LABEL" => { + args.first().map(|label| (label.to_lowercase(), index + 1)) + } + _ => None, + }) + .collect(); + Ok(Program { + name: macro_name, + tokens, + labels, + }) +} + +pub fn macro_name(source: &str) -> Result { + Ok(parse(source)?.name) +} + +pub fn parse_key_binding(binding: &str) -> Result { + let binding = binding.trim(); + let contents = binding + .strip_prefix('{') + .and_then(|value| value.strip_suffix('}')) + .unwrap_or(binding); + named_key(&contents.to_uppercase()) + .ok_or_else(|| anyhow::anyhow!("unsupported macro key binding “{binding}”")) +} + +pub fn key_to_source(key: KeyEvent) -> String { + if let KeyCode::Char(character) = key.code { + if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT { + return character.to_string(); + } + if key.modifiers.contains(KeyModifiers::CONTROL) { + return format!("{{Ctl{}}}", character.to_ascii_uppercase()); + } + if key.modifiers.contains(KeyModifiers::ALT) { + return format!("{{Alt{}}}", character.to_ascii_uppercase()); + } + } + let modifier = if key.modifiers.contains(KeyModifiers::CONTROL) { + "Ctl" + } else if key.modifiers.contains(KeyModifiers::ALT) { + "Alt" + } else if key.modifiers.contains(KeyModifiers::SHIFT) { + "Shf" + } else { + "" + }; + let name = match key.code { + KeyCode::Enter => "ENTER".into(), + KeyCode::Esc => "ESC".into(), + KeyCode::Backspace => "BS".into(), + KeyCode::Tab => "TAB".into(), + KeyCode::BackTab => "ShfTAB".into(), + KeyCode::Up => "UP".into(), + KeyCode::Down => "DOWN".into(), + KeyCode::Left => "LEFT".into(), + KeyCode::Right => "RIGHT".into(), + KeyCode::Home => "HOME".into(), + KeyCode::End => "END".into(), + KeyCode::PageUp => "PGUP".into(), + KeyCode::PageDown => "PGDN".into(), + KeyCode::Insert => "INS".into(), + KeyCode::Delete => "DEL".into(), + KeyCode::F(number) => format!("{modifier}F{number}"), + _ => "UNKNOWN".into(), + }; + format!("{{{name}}}") +} + +fn named_key(name: &str) -> Option { + let upper = name.to_uppercase(); + if upper == "SHFTAB" { + return Some(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT)); + } + let (modifiers, base) = if let Some(base) = upper.strip_prefix("CTL") { + (KeyModifiers::CONTROL, base) + } else if let Some(base) = upper.strip_prefix("ALT") { + (KeyModifiers::ALT, base) + } else if let Some(base) = upper.strip_prefix("SHF") { + (KeyModifiers::SHIFT, base) + } else { + (KeyModifiers::NONE, upper.as_str()) + }; + let code = match base { + "ENTER" => KeyCode::Enter, + "ESC" => KeyCode::Esc, + "BS" => KeyCode::Backspace, + "TAB" => KeyCode::Tab, + "UP" => KeyCode::Up, + "DOWN" => KeyCode::Down, + "LEFT" => KeyCode::Left, + "RIGHT" => KeyCode::Right, + "HOME" => KeyCode::Home, + "END" => KeyCode::End, + "PGUP" => KeyCode::PageUp, + "PGDN" => KeyCode::PageDown, + "INS" => KeyCode::Insert, + "DEL" => KeyCode::Delete, + value if value.starts_with('F') => KeyCode::F(value[1..].parse().ok()?), + value if value.chars().count() == 1 => { + KeyCode::Char(value.chars().next()?.to_ascii_lowercase()) + } + _ => return None, + }; + Some(KeyEvent::new(code, modifiers)) +} + +fn require_args(command: &str, args: &[String], count: usize) -> Result<()> { + if args.len() < count { + bail!("{{{command}}} requires at least {count} argument(s)") + } + Ok(()) +} + +fn format_number(value: f64) -> String { + if value.fract().abs() < f64::EPSILON { + format!("{value:.0}") + } else { + value.to_string() + } +} + +struct NumericParser<'a, F> { + input: &'a [u8], + position: usize, + variable: F, +} + +impl<'a, F: Fn(&str) -> Option> NumericParser<'a, F> { + fn new(input: &'a str, variable: F) -> Self { + Self { + input: input.as_bytes(), + position: 0, + variable, + } + } + + fn parse(mut self) -> Result { + let value = self.expression()?; + self.whitespace(); + if self.position != self.input.len() { + bail!("invalid numeric expression") + } + Ok(value) + } + + fn expression(&mut self) -> Result { + let mut value = self.term()?; + loop { + self.whitespace(); + if self.consume(b'+') { + value += self.term()?; + } else if self.consume(b'-') { + value -= self.term()?; + } else { + return Ok(value); + } + } + } + + fn term(&mut self) -> Result { + let mut value = self.factor()?; + loop { + self.whitespace(); + if self.consume(b'*') { + value *= self.factor()?; + } else if self.consume(b'/') { + value /= self.factor()?; + } else { + return Ok(value); + } + } + } + + fn factor(&mut self) -> Result { + self.whitespace(); + if self.consume(b'(') { + let value = self.expression()?; + self.whitespace(); + if !self.consume(b')') { + bail!("missing closing parenthesis") + } + return Ok(value); + } + let negative = self.consume(b'-'); + let start = self.position; + while self.position < self.input.len() + && (self.input[self.position].is_ascii_alphanumeric() + || matches!(self.input[self.position], b'%' | b'.' | b'_')) + { + self.position += 1; + } + if start == self.position { + bail!("expected number or variable") + } + let token = std::str::from_utf8(&self.input[start..self.position])?; + let value = token + .parse::() + .ok() + .or_else(|| (self.variable)(token)) + .ok_or_else(|| anyhow::anyhow!("“{token}” is not numeric"))?; + Ok(if negative { -value } else { value }) + } + + fn whitespace(&mut self) { + while self + .input + .get(self.position) + .is_some_and(u8::is_ascii_whitespace) + { + self.position += 1; + } + } + + fn consume(&mut self, expected: u8) -> bool { + if self.input.get(self.position) == Some(&expected) { + self.position += 1; + true + } else { + false + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn definition(source: &str) -> MacroDef { + MacroDef { + id: 1, + name: macro_name(source).unwrap(), + source: source.into(), + key_binding: String::new(), + } + } + + #[test] + fn parses_and_runs_the_original_search_example() { + let source = "{searchmac}\n{inputtext;Enter text to search for;%search}\n\ + {altf6}\n{type;%search}\n{enter;2}"; + let mut runtime = + MacroRuntime::new("searchmac", &[definition(source)], HashMap::new()).unwrap(); + let context = MacroContext::default(); + let Some(MacroAction::Prompt { variable, .. }) = runtime.next_action(&context).unwrap() + else { + panic!("expected prompt"); + }; + runtime.resume_value(&variable, "quarterly".into()).unwrap(); + assert_eq!( + runtime.next_action(&context).unwrap(), + Some(MacroAction::Key(KeyEvent::new( + KeyCode::F(6), + KeyModifiers::ALT + ))) + ); + let mut typed = String::new(); + for _ in 0..9 { + let Some(MacroAction::Key(key)) = runtime.next_action(&context).unwrap() else { + panic!("expected typed key"); + }; + if let KeyCode::Char(character) = key.code { + typed.push(character); + } + } + assert_eq!(typed, "quarterly"); + } + + #[test] + fn supports_variables_arithmetic_loops_and_branches() { + let source = "{math}{defint;%i;%sum}{let;%sum;0}{for;%i;1;3;1;done}\ + {let;%sum;(%sum+%i)}{label;done}{if;(%sum=6);good}\ + x{quit}{label;good}y"; + let mut runtime = MacroRuntime::new("math", &[definition(source)], HashMap::new()).unwrap(); + let context = MacroContext::default(); + let Some(MacroAction::Key(key)) = runtime.next_action(&context).unwrap() else { + panic!("expected result key"); + }; + assert_eq!(key.code, KeyCode::Char('y')); + } + + #[test] + fn records_and_parses_key_bindings_in_agenda_notation() { + let key = KeyEvent::new(KeyCode::F(3), KeyModifiers::ALT); + assert_eq!(key_to_source(key), "{AltF3}"); + assert_eq!( + parse_key_binding("{CtlA}").unwrap().code, + KeyCode::Char('a') + ); + assert_eq!( + parse_key_binding("{CtlA}").unwrap().modifiers, + KeyModifiers::CONTROL + ); + assert_eq!( + parse_key_binding("{CtlPgUp}").unwrap(), + KeyEvent::new(KeyCode::PageUp, KeyModifiers::CONTROL) + ); + assert_eq!( + parse_key_binding("{ShfTab}").unwrap(), + KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT) + ); + } + + #[test] + fn supports_ifkey_special_ascii_and_setting_selection() { + let source = "{control}{ifkey;pressed}x{quit}{label;pressed}\ + {type;#ascii(65)}{selection;Priority}"; + let mut runtime = + MacroRuntime::new("control", &[definition(source)], HashMap::new()).unwrap(); + let context = MacroContext::default(); + let Some(MacroAction::IfKey { target }) = runtime.next_action(&context).unwrap() else { + panic!("expected IFKEY check"); + }; + runtime.resume_if_key(&target, true).unwrap(); + let Some(MacroAction::Key(key)) = runtime.next_action(&context).unwrap() else { + panic!("expected #ASCII output"); + }; + assert_eq!(key.code, KeyCode::Char('A')); + assert_eq!( + runtime.next_action(&context).unwrap(), + Some(MacroAction::Selection { + prompt: "Priority".into() + }) + ); + } + + #[test] + fn onerror_and_onbreak_invoke_handler_macros() { + let main = definition("{main}{onerror;handler}{unknown}x"); + let mut handler = definition("{handler}y{return}"); + handler.id = 2; + let context = MacroContext::default(); + let mut runtime = + MacroRuntime::new("main", &[main.clone(), handler.clone()], HashMap::new()).unwrap(); + let Some(MacroAction::Key(key)) = runtime.next_action(&context).unwrap() else { + panic!("expected error handler output"); + }; + assert_eq!(key.code, KeyCode::Char('y')); + let Some(MacroAction::Key(key)) = runtime.next_action(&context).unwrap() else { + panic!("expected main macro to resume"); + }; + assert_eq!(key.code, KeyCode::Char('x')); + + let mut break_main = definition("{breakmain}{onbreak;handler}x"); + break_main.id = 3; + let mut runtime = + MacroRuntime::new("breakmain", &[break_main, handler], HashMap::new()).unwrap(); + assert_eq!( + runtime.next_action(&context).unwrap().unwrap(), + MacroAction::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)) + ); + assert!(runtime.trigger_break().unwrap()); + let Some(MacroAction::Key(key)) = runtime.next_action(&context).unwrap() else { + panic!("expected break handler output"); + }; + assert_eq!(key.code, KeyCode::Char('y')); + } +} diff --git a/src/main.rs b/src/main.rs index 72d5274..8c664f8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ mod app; mod db; mod filter; +mod macro_lang; mod model; mod parser; mod preferences; diff --git a/src/model.rs b/src/model.rs index fb0d8ac..1ecabab 100644 --- a/src/model.rs +++ b/src/model.rs @@ -98,6 +98,14 @@ pub struct CategoryRule { pub enabled: bool, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MacroDef { + pub id: i64, + pub name: String, + pub source: String, + pub key_binding: String, +} + #[derive(Debug, Clone, Default)] pub struct ItemChanges { pub text: Option, diff --git a/src/ui.rs b/src/ui.rs index 5c4cc2e..68247d4 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -10,7 +10,7 @@ use ratatui::{ }; use crate::{ - app::{App, FormKind, Mode, form_choices}, + app::{App, FormKind, InputKind, Mode, form_choices}, filter, model::{Item, ViewColumn}, parser::format_when, @@ -162,13 +162,17 @@ fn draw_header(frame: &mut Frame<'_>, app: &App, area: Rect) { let pad = width.saturating_sub(left1.chars().count() + right.chars().count()); let line1 = format!("{left1}{}{right}", " ".repeat(pad)); let line2 = format!( - "View: {}{}", + "View: {}{}{}", app.current_view().name, if app.search.is_empty() { String::new() } else { format!(" /{}", app.search) - } + }, + app.recording + .as_ref() + .map(|recording| format!(" LEARN:{}", recording.name)) + .unwrap_or_default() ); frame.render_widget( Paragraph::new(vec![Line::from(line1), Line::from(line2)]).style( @@ -625,7 +629,7 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &mut App) { Line::from("F4/d done F5 note F6/p properties F7/Space mark"), Line::from("F8/v views F9 categories F10/m menu / search"), Line::from("h/l views ([/]) r prerequisites Delete trash/recover"), - Line::from("Ctrl-S save q quit"), + Line::from("Ctrl-G macros Ctrl-S save q quit"), Line::from(""), Line::from("Mouse: click items, view tabs, or function-key tiles."), Line::from("Press any key to close."), @@ -639,8 +643,20 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &mut App) { ); } Mode::Input(input) => { - let h = if input.multiline { 18 } else { 7 }; - let area = center(frame.area(), 78, h); + let macro_editor = matches!(input.kind, InputKind::MacroEditor(_)); + let h = if macro_editor { + frame.area().height.saturating_sub(4) + } else if input.multiline { + 18 + } else { + 7 + }; + let width = if macro_editor { + frame.area().width.saturating_sub(4) + } else { + 78 + }; + let area = center(frame.area(), width, h); let inner = area.inner(Margin { horizontal: 1, vertical: 1, @@ -653,7 +669,9 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &mut App) { cursor_line.saturating_sub(inner.height.saturating_sub(1) as usize); let horizontal_scroll = cursor_column.saturating_sub(inner.width.saturating_sub(1) as usize); - let shown = if input.value.is_empty() { + let shown = if macro_editor { + macro_highlight(&input.value) + } else if input.value.is_empty() { Text::from(Line::from(Span::styled( "Type here…", Style::default().fg(Color::DarkGray), @@ -983,7 +1001,7 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &mut App) { Mode::Menu => { popup( frame, - center(frame.area(), 58, 14), + center(frame.area(), 58, 15), "Rogue Agenda Menu", Paragraph::new(vec![ Line::from(" n New item"), @@ -992,6 +1010,7 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &mut App) { Line::from(" r Prerequisites"), Line::from(" p Preferences (user TOML)"), Line::from(" d Document settings (.agnd)"), + Line::from(" x Macro Manager (Ctrl-G)"), Line::from(" b Back up document now"), Line::from(" t Empty Trash permanently"), Line::from(" q Quit"), @@ -1015,9 +1034,216 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &mut App) { colors, ); } + Mode::MacroManager => { + let items = if app.macros.is_empty() { + vec![ListItem::new("(no macros — press Insert to create one)")] + } else { + app.macros + .iter() + .map(|definition| { + ListItem::new(format!( + "{:<35} {}", + definition.name, + if definition.key_binding.is_empty() { + String::new() + } else { + definition.key_binding.clone() + } + )) + }) + .collect() + }; + let mut state = ListState::default().with_selected(if app.macros.is_empty() { + None + } else { + Some(app.macro_selected) + }); + let area = center( + frame.area(), + 76, + (app.macros.len() as u16 + 5).clamp(10, 24), + ); + let inner = area.inner(Margin { + horizontal: 1, + vertical: 1, + }); + frame.render_widget(Clear, area); + frame.render_stateful_widget( + List::new(items) + .block( + Block::bordered() + .title(" Macro Manager ") + .title_bottom(Line::from( + " Ins add F2 edit Enter run F7/r record a append F6/b key Del ", + )) + .style(Style::default().fg(Color::White).bg(colors.primary)), + ) + .highlight_style( + Style::default() + .fg(colors.selection_foreground) + .bg(colors.selection) + .add_modifier(Modifier::BOLD), + ), + area, + &mut state, + ); + let offset = state.offset(); + app.choice_regions + .extend(std::iter::repeat_n(Rect::default(), offset)); + app.choice_regions.extend( + (offset..app.macros.len().min(offset + inner.height as usize)).map(|index| { + Rect::new(inner.x, inner.y + (index - offset) as u16, inner.width, 1) + }), + ); + } + Mode::MacroMenu { + title, + prompt, + choices, + selected, + } => { + let items = choices + .iter() + .map(|choice| ListItem::new(choice.label.as_str())) + .collect::>(); + let mut state = ListState::default().with_selected(Some(*selected)); + let area = center(frame.area(), 64, (choices.len() as u16 + 5).clamp(8, 22)); + let inner = area.inner(Margin { + horizontal: 1, + vertical: 1, + }); + frame.render_widget(Clear, area); + frame.render_stateful_widget( + List::new(items) + .block( + Block::bordered() + .title(format!(" {title} ")) + .title_bottom(Line::from(format!( + " {prompt} · Enter chooses · Esc cancels " + ))) + .style(Style::default().fg(Color::White).bg(colors.primary)), + ) + .highlight_style( + Style::default() + .fg(colors.selection_foreground) + .bg(colors.selection) + .add_modifier(Modifier::BOLD), + ), + area, + &mut state, + ); + let offset = state.offset(); + app.choice_regions + .extend(std::iter::repeat_n(Rect::default(), offset)); + app.choice_regions.extend( + (offset..choices.len().min(offset + inner.height as usize)).map(|index| { + Rect::new(inner.x, inner.y + (index - offset) as u16, inner.width, 1) + }), + ); + } + Mode::MacroMessage { title, message } => popup( + frame, + center(frame.area(), 70, 9), + title, + Paragraph::new(vec![ + Line::from(message.as_str()), + Line::from(""), + Line::from("Press any key to continue the macro"), + ]) + .wrap(Wrap { trim: false }), + colors, + ), + Mode::MacroGetKey { .. } => popup( + frame, + center(frame.area(), 58, 7), + "Macro Input", + Paragraph::new("Press a key for the macro, or Esc to cancel"), + colors, + ), } } +fn macro_highlight(source: &str) -> Text<'static> { + let mut lines = Vec::new(); + for line in source.split('\n') { + let mut spans = Vec::new(); + let mut rest = line; + while let Some(open) = rest.find('{') { + if open > 0 { + spans.push(Span::styled( + rest[..open].to_owned(), + Style::default().fg(Color::White), + )); + } + let command = &rest[open + 1..]; + let Some(close) = command.find('}') else { + spans.push(Span::styled( + rest[open..].to_owned(), + Style::default() + .fg(Color::LightRed) + .add_modifier(Modifier::BOLD), + )); + rest = ""; + break; + }; + let body = &command[..close]; + let parts = body.split(';').collect::>(); + let is_comment = parts + .first() + .is_some_and(|name| name.eq_ignore_ascii_case("COMMENT")); + spans.push(Span::styled( + "{".to_owned(), + Style::default().fg(Color::DarkGray), + )); + for (index, part) in parts.iter().enumerate() { + if index > 0 { + spans.push(Span::styled( + ";".to_owned(), + Style::default().fg(Color::DarkGray), + )); + } + let style = if is_comment { + Style::default().fg(Color::DarkGray) + } else if index == 0 { + Style::default() + .fg(if part.eq_ignore_ascii_case("LABEL") { + Color::LightGreen + } else { + Color::LightCyan + }) + .add_modifier(Modifier::BOLD) + } else if part.trim_start().starts_with("%%") { + Style::default() + .fg(Color::LightYellow) + .add_modifier(Modifier::BOLD) + } else if part.trim_start().starts_with('%') { + Style::default().fg(Color::Yellow) + } else if part.trim_start().starts_with('#') { + Style::default().fg(Color::LightMagenta) + } else if part.parse::().is_ok() { + Style::default().fg(Color::LightBlue) + } else { + Style::default().fg(Color::White) + }; + spans.push(Span::styled((*part).to_owned(), style)); + } + spans.push(Span::styled( + "}".to_owned(), + Style::default().fg(Color::DarkGray), + )); + rest = &command[close + 1..]; + } + if !rest.is_empty() { + spans.push(Span::styled( + rest.to_owned(), + Style::default().fg(Color::White), + )); + } + lines.push(Line::from(spans)); + } + Text::from(lines) +} + fn draw_category_list(frame: &mut Frame<'_>, app: &App, title: &str, assign: bool) { let colors = palette(&app.preferences.theme); let assigned = app @@ -1501,6 +1727,34 @@ mod tests { } } + #[test] + fn macro_source_highlighting_distinguishes_language_elements() { + let highlighted = macro_highlight( + "{demo}\n{defstr;%local;%%global}\n{type;#date}\n{comment;ignored}\n{broken", + ); + assert_eq!( + highlighted.lines[0].spans[1].style.fg, + Some(Color::LightCyan) + ); + assert_eq!(highlighted.lines[1].spans[3].style.fg, Some(Color::Yellow)); + assert_eq!( + highlighted.lines[1].spans[5].style.fg, + Some(Color::LightYellow) + ); + assert_eq!( + highlighted.lines[2].spans[3].style.fg, + Some(Color::LightMagenta) + ); + assert_eq!( + highlighted.lines[3].spans[1].style.fg, + Some(Color::DarkGray) + ); + assert_eq!( + highlighted.lines[4].spans[0].style.fg, + Some(Color::LightRed) + ); + } + fn contrast_ratio(first: Color, second: Color) -> f64 { let first = relative_luminance(first); let second = relative_luminance(second);