added macro support
This commit is contained in:
270
src/ui.rs
270
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::<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);
|
||||
|
||||
Reference in New Issue
Block a user