added macro support

This commit is contained in:
Hermes Agent
2026-08-16 21:39:18 +00:00
parent e407c064bf
commit b9471a89fb
8 changed files with 2533 additions and 16 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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<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(())
}

1157
src/macro_lang.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
mod app;
mod db;
mod filter;
mod macro_lang;
mod model;
mod parser;
mod preferences;

View File

@@ -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<String>,

270
src/ui.rs
View File

@@ -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::<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) {
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);