added macro support
This commit is contained in:
31
PROJECT.md
31
PROJECT.md
@@ -35,6 +35,9 @@ interaction, conventional shortcuts, UTF-8, and modern durability.
|
|||||||
assignment, aliases/abbreviations, incremental structure, and named live views.
|
assignment, aliases/abbreviations, incremental structure, and named live views.
|
||||||
- Period reviews from the Los Angeles Times and HUMANIST archives corroborate
|
- Period reviews from the Los Angeles Times and HUMANIST archives corroborate
|
||||||
long notes, free-form entry, priorities, Boolean reports, and automatic filing.
|
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:
|
Research URLs:
|
||||||
|
|
||||||
@@ -42,6 +45,7 @@ Research URLs:
|
|||||||
- https://lock.cmpxchg8b.com/lotusagenda.html
|
- https://lock.cmpxchg8b.com/lotusagenda.html
|
||||||
- https://www.bobnewell.net/agenda/agfaq.txt.html
|
- https://www.bobnewell.net/agenda/agfaq.txt.html
|
||||||
- https://www.bobnewell.net/agenda/atlantic.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://dhhumanist.org/archives/Virginia/v02/0410.html
|
||||||
- https://www.latimes.com/archives/la-xpm-1988-07-28-fi-9745-story.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,
|
- [x] Text and iCalendar import; view-scoped CSV, JSON, Markdown, styled HTML,
|
||||||
and iCalendar export with notes, categories, dates, priority, completion, and
|
and iCalendar export with notes, categories, dates, priority, completion, and
|
||||||
recurring `RRULE` schedules.
|
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.
|
- [x] Demo data, CLI help, README, MIT license, and unit/integration tests.
|
||||||
|
|
||||||
## Supported with a modern interpretation
|
## Supported with a modern interpretation
|
||||||
@@ -110,7 +128,8 @@ Research URLs:
|
|||||||
filesystem backup tools also work on `.agnd` files.
|
filesystem backup tools also work on `.agnd` files.
|
||||||
- [x] Clipboard/accessory capture: terminal paste into the new-item form replaces
|
- [x] Clipboard/accessory capture: terminal paste into the new-item form replaces
|
||||||
the DOS resident accessory.
|
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
|
## Delayed / unsupported
|
||||||
|
|
||||||
@@ -121,8 +140,14 @@ a larger follow-on implementation:
|
|||||||
view columns support `sum`, `avg`, `count`, `min`, and `max` aggregates.
|
view columns support `sum`, `avg`, `count`, `min`, and `max` aggregates.
|
||||||
- [ ] Rich-text/font markers, printer control sequences, page-layout preview, and
|
- [ ] Rich-text/font markers, printer control sequences, page-layout preview, and
|
||||||
named printer configurations.
|
named printer configurations.
|
||||||
- [ ] Lotus Metro/List Manager/STF compatibility, DOS accessories, and Agenda macro
|
- [ ] Lotus Metro/List Manager/STF compatibility, DOS accessories, and import of
|
||||||
compatibility (explicitly outside the project's format-compatibility goal).
|
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;
|
- [ ] Password encryption. A description field is supported by schema metadata;
|
||||||
weak historic password protection will not be imitated without real encryption.
|
weak historic password protection will not be imitated without real encryption.
|
||||||
- [ ] Desktop notifications while the application is closed. Due alarms are shown
|
- [ ] Desktop notifications while the application is closed. Due alarms are shown
|
||||||
|
|||||||
63
README.md
63
README.md
@@ -40,6 +40,10 @@ database with the `.agnd` extension.
|
|||||||
clickable function-key commands, and an in-app help screen.
|
clickable function-key commands, and an in-app help screen.
|
||||||
- Application-wide Preferences plus per-document settings for themes, display and
|
- Application-wide Preferences plus per-document settings for themes, display and
|
||||||
locale conventions, autosave, confirmations, dates, filing, Trash, and backups.
|
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
|
## Build and run
|
||||||
|
|
||||||
@@ -99,6 +103,7 @@ or build failure stops the gate.
|
|||||||
| `F10` or `m` | Command menu |
|
| `F10` or `m` | Command menu |
|
||||||
| `r` | Edit prerequisites for the selected item |
|
| `r` | Edit prerequisites for the selected item |
|
||||||
| `/` | Search the current view |
|
| `/` | Search the current view |
|
||||||
|
| `Ctrl-G` | Macro Manager / stop and save a Learn recording |
|
||||||
| `h` / `l` | Previous / next saved view (`[` / `]` are aliases) |
|
| `h` / `l` | Previous / next saved view (`[` / `]` are aliases) |
|
||||||
| `Ctrl-S` | Save/checkpoint |
|
| `Ctrl-S` | Save/checkpoint |
|
||||||
| `?` or `F1` | Help |
|
| `?` 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.
|
`document.agnd.bak`, or enable backup-on-open. Use `F10`, then `t` to empty Trash.
|
||||||
Permanent operations request confirmation unless that preference is disabled.
|
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
|
## Designing live views
|
||||||
|
|
||||||
Open the View Manager with `F8`. Press `n` to create a view, `e` to edit one,
|
Open the View Manager with `F8`. Press `n` to create a view, `e` to edit one,
|
||||||
|
|||||||
924
src/app.rs
924
src/app.rs
File diff suppressed because it is too large
Load Diff
95
src/db.rs
95
src/db.rs
@@ -9,7 +9,8 @@ use rusqlite::{Connection, OptionalExtension, params};
|
|||||||
|
|
||||||
use crate::filter;
|
use crate::filter;
|
||||||
use crate::model::{
|
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};
|
use crate::parser::{extract_when_configured, next_occurrence};
|
||||||
|
|
||||||
@@ -142,6 +143,98 @@ impl Database {
|
|||||||
}
|
}
|
||||||
self.conn.pragma_update(None, "user_version", 3)?;
|
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<Vec<MacroDef>> {
|
||||||
|
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::<rusqlite::Result<Vec<_>>>()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_macro(
|
||||||
|
&mut self,
|
||||||
|
id: Option<i64>,
|
||||||
|
name: &str,
|
||||||
|
source: &str,
|
||||||
|
key_binding: &str,
|
||||||
|
) -> Result<i64> {
|
||||||
|
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<std::collections::HashMap<String, String>> {
|
||||||
|
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::<rusqlite::Result<_>>()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_macro_variables(
|
||||||
|
&mut self,
|
||||||
|
variables: &std::collections::HashMap<String, String>,
|
||||||
|
) -> 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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
1157
src/macro_lang.rs
Normal file
1157
src/macro_lang.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
|||||||
mod app;
|
mod app;
|
||||||
mod db;
|
mod db;
|
||||||
mod filter;
|
mod filter;
|
||||||
|
mod macro_lang;
|
||||||
mod model;
|
mod model;
|
||||||
mod parser;
|
mod parser;
|
||||||
mod preferences;
|
mod preferences;
|
||||||
|
|||||||
@@ -98,6 +98,14 @@ pub struct CategoryRule {
|
|||||||
pub enabled: bool,
|
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)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct ItemChanges {
|
pub struct ItemChanges {
|
||||||
pub text: Option<String>,
|
pub text: Option<String>,
|
||||||
|
|||||||
270
src/ui.rs
270
src/ui.rs
@@ -10,7 +10,7 @@ use ratatui::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
app::{App, FormKind, Mode, form_choices},
|
app::{App, FormKind, InputKind, Mode, form_choices},
|
||||||
filter,
|
filter,
|
||||||
model::{Item, ViewColumn},
|
model::{Item, ViewColumn},
|
||||||
parser::format_when,
|
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 pad = width.saturating_sub(left1.chars().count() + right.chars().count());
|
||||||
let line1 = format!("{left1}{}{right}", " ".repeat(pad));
|
let line1 = format!("{left1}{}{right}", " ".repeat(pad));
|
||||||
let line2 = format!(
|
let line2 = format!(
|
||||||
"View: {}{}",
|
"View: {}{}{}",
|
||||||
app.current_view().name,
|
app.current_view().name,
|
||||||
if app.search.is_empty() {
|
if app.search.is_empty() {
|
||||||
String::new()
|
String::new()
|
||||||
} else {
|
} else {
|
||||||
format!(" /{}", app.search)
|
format!(" /{}", app.search)
|
||||||
}
|
},
|
||||||
|
app.recording
|
||||||
|
.as_ref()
|
||||||
|
.map(|recording| format!(" LEARN:{}", recording.name))
|
||||||
|
.unwrap_or_default()
|
||||||
);
|
);
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Paragraph::new(vec![Line::from(line1), Line::from(line2)]).style(
|
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("F4/d done F5 note F6/p properties F7/Space mark"),
|
||||||
Line::from("F8/v views F9 categories F10/m menu / search"),
|
Line::from("F8/v views F9 categories F10/m menu / search"),
|
||||||
Line::from("h/l views ([/]) r prerequisites Delete trash/recover"),
|
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(""),
|
||||||
Line::from("Mouse: click items, view tabs, or function-key tiles."),
|
Line::from("Mouse: click items, view tabs, or function-key tiles."),
|
||||||
Line::from("Press any key to close."),
|
Line::from("Press any key to close."),
|
||||||
@@ -639,8 +643,20 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &mut App) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
Mode::Input(input) => {
|
Mode::Input(input) => {
|
||||||
let h = if input.multiline { 18 } else { 7 };
|
let macro_editor = matches!(input.kind, InputKind::MacroEditor(_));
|
||||||
let area = center(frame.area(), 78, h);
|
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 {
|
let inner = area.inner(Margin {
|
||||||
horizontal: 1,
|
horizontal: 1,
|
||||||
vertical: 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);
|
cursor_line.saturating_sub(inner.height.saturating_sub(1) as usize);
|
||||||
let horizontal_scroll =
|
let horizontal_scroll =
|
||||||
cursor_column.saturating_sub(inner.width.saturating_sub(1) as usize);
|
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(
|
Text::from(Line::from(Span::styled(
|
||||||
"Type here…",
|
"Type here…",
|
||||||
Style::default().fg(Color::DarkGray),
|
Style::default().fg(Color::DarkGray),
|
||||||
@@ -983,7 +1001,7 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &mut App) {
|
|||||||
Mode::Menu => {
|
Mode::Menu => {
|
||||||
popup(
|
popup(
|
||||||
frame,
|
frame,
|
||||||
center(frame.area(), 58, 14),
|
center(frame.area(), 58, 15),
|
||||||
"Rogue Agenda Menu",
|
"Rogue Agenda Menu",
|
||||||
Paragraph::new(vec![
|
Paragraph::new(vec![
|
||||||
Line::from(" n New item"),
|
Line::from(" n New item"),
|
||||||
@@ -992,6 +1010,7 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &mut App) {
|
|||||||
Line::from(" r Prerequisites"),
|
Line::from(" r Prerequisites"),
|
||||||
Line::from(" p Preferences (user TOML)"),
|
Line::from(" p Preferences (user TOML)"),
|
||||||
Line::from(" d Document settings (.agnd)"),
|
Line::from(" d Document settings (.agnd)"),
|
||||||
|
Line::from(" x Macro Manager (Ctrl-G)"),
|
||||||
Line::from(" b Back up document now"),
|
Line::from(" b Back up document now"),
|
||||||
Line::from(" t Empty Trash permanently"),
|
Line::from(" t Empty Trash permanently"),
|
||||||
Line::from(" q Quit"),
|
Line::from(" q Quit"),
|
||||||
@@ -1015,9 +1034,216 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &mut App) {
|
|||||||
colors,
|
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::<Vec<_>>();
|
||||||
|
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::<Vec<_>>();
|
||||||
|
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::<f64>().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) {
|
fn draw_category_list(frame: &mut Frame<'_>, app: &App, title: &str, assign: bool) {
|
||||||
let colors = palette(&app.preferences.theme);
|
let colors = palette(&app.preferences.theme);
|
||||||
let assigned = app
|
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 {
|
fn contrast_ratio(first: Color, second: Color) -> f64 {
|
||||||
let first = relative_luminance(first);
|
let first = relative_luminance(first);
|
||||||
let second = relative_luminance(second);
|
let second = relative_luminance(second);
|
||||||
|
|||||||
Reference in New Issue
Block a user