- Add category-bound date and numeric values - Complete rule execution and conflict handling - Expand Markdown and HTML report output
1905 lines
67 KiB
Rust
1905 lines
67 KiB
Rust
use std::collections::HashMap;
|
||
|
||
use chrono::Local;
|
||
use ratatui::{
|
||
Frame,
|
||
layout::{Alignment, Constraint, Direction, Layout, Margin, Rect},
|
||
style::{Color, Modifier, Style},
|
||
text::{Line, Span, Text},
|
||
widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap},
|
||
};
|
||
|
||
use crate::{
|
||
app::{App, FormKind, InputKind, Mode, form_choices},
|
||
date::format_when,
|
||
filter,
|
||
model::{Aggregate, Item, ViewColumn, ViewKind},
|
||
};
|
||
|
||
mod theme;
|
||
|
||
use theme::{Palette, palette};
|
||
|
||
pub fn draw(frame: &mut Frame<'_>, app: &mut App) {
|
||
let colors = palette(&app.preferences.theme);
|
||
frame.render_widget(
|
||
Block::default().style(Style::default().bg(Color::Black)),
|
||
frame.area(),
|
||
);
|
||
let height = frame.area().height;
|
||
if height < 10 || frame.area().width < 40 {
|
||
frame.render_widget(
|
||
Paragraph::new("Rogue Agenda needs at least 40×10")
|
||
.style(Style::default().fg(colors.accent).bg(colors.primary))
|
||
.alignment(Alignment::Center),
|
||
frame.area(),
|
||
);
|
||
return;
|
||
}
|
||
let chunks = Layout::default()
|
||
.direction(Direction::Vertical)
|
||
.constraints([
|
||
Constraint::Length(if app.preferences.show_command_bar {
|
||
2
|
||
} else {
|
||
0
|
||
}),
|
||
Constraint::Length(1),
|
||
Constraint::Min(3),
|
||
Constraint::Length(1),
|
||
Constraint::Length(2),
|
||
])
|
||
.split(frame.area());
|
||
draw_header(frame, app, chunks[0]);
|
||
draw_view_tabs(frame, app, chunks[1]);
|
||
draw_items(frame, app, chunks[2]);
|
||
draw_status(frame, app, chunks[3]);
|
||
if app.preferences.show_command_bar {
|
||
draw_commands(frame, app, chunks[4]);
|
||
} else {
|
||
app.command_regions.clear();
|
||
}
|
||
draw_overlay(frame, app);
|
||
}
|
||
|
||
fn draw_header(frame: &mut Frame<'_>, app: &App, area: Rect) {
|
||
let colors = palette(&app.preferences.theme);
|
||
let alarms = app.db.due_alarm_count().unwrap_or(0);
|
||
let now = Local::now().to_rfc3339();
|
||
let right = format!(
|
||
"{}{}",
|
||
formatted_when(app, Some(&now)),
|
||
if alarms > 0 {
|
||
format!(" ALARM:{alarms}")
|
||
} else {
|
||
String::new()
|
||
}
|
||
);
|
||
let width = area.width as usize;
|
||
let left1 = format!(
|
||
"File: {}{}",
|
||
app.path.display(),
|
||
if app.document_settings.description.is_empty() {
|
||
String::new()
|
||
} else {
|
||
format!(" — {}", app.document_settings.description)
|
||
}
|
||
);
|
||
let pad = width.saturating_sub(left1.chars().count() + right.chars().count());
|
||
let line1 = format!("{left1}{}{right}", " ".repeat(pad));
|
||
let line2 = format!(
|
||
"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(
|
||
Style::default()
|
||
.fg(Color::White)
|
||
.bg(colors.primary)
|
||
.add_modifier(Modifier::BOLD),
|
||
),
|
||
area,
|
||
);
|
||
}
|
||
|
||
fn draw_view_tabs(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
|
||
let colors = palette(&app.preferences.theme);
|
||
app.view_regions.clear();
|
||
let mut x = area.x;
|
||
frame.render_widget(
|
||
Block::default().style(Style::default().bg(colors.canvas)),
|
||
area,
|
||
);
|
||
for (i, v) in app.views.iter().enumerate() {
|
||
let w = (v.name.chars().count() + 2).min(20) as u16;
|
||
if x + w > area.right() {
|
||
break;
|
||
}
|
||
let r = Rect::new(x, area.y, w, 1);
|
||
app.view_regions.push(r);
|
||
let st = if i == app.view_index {
|
||
Style::default()
|
||
.fg(colors.primary)
|
||
.bg(Color::White)
|
||
.add_modifier(Modifier::BOLD)
|
||
} else {
|
||
Style::default().fg(Color::White).bg(colors.primary)
|
||
};
|
||
frame.render_widget(Paragraph::new(format!(" {} ", v.name)).style(st), r);
|
||
x += w + 1;
|
||
}
|
||
}
|
||
|
||
fn draw_items(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
|
||
let colors = palette(&app.preferences.theme);
|
||
frame.render_widget(
|
||
Block::default().style(Style::default().bg(colors.canvas)),
|
||
area,
|
||
);
|
||
app.item_rows.clear();
|
||
app.item_columns.clear();
|
||
if app.items.is_empty() {
|
||
frame.render_widget(
|
||
Paragraph::new(if app.search.is_empty() {
|
||
" No items. Press Insert or n to capture one."
|
||
} else {
|
||
" No items match this search. Press Esc to clear it."
|
||
})
|
||
.style(Style::default().fg(colors.canvas_heading).bg(colors.canvas)),
|
||
area,
|
||
);
|
||
return;
|
||
}
|
||
if app.current_view().kind == ViewKind::Datebook {
|
||
draw_datebook(frame, app, area);
|
||
return;
|
||
}
|
||
let mut base_columns = app
|
||
.current_view()
|
||
.columns
|
||
.iter()
|
||
.cloned()
|
||
.enumerate()
|
||
.collect::<Vec<_>>();
|
||
if area.width < 70 && base_columns.len() > 2 {
|
||
let item = base_columns
|
||
.iter()
|
||
.find(|(_, column)| column.field == "item")
|
||
.cloned()
|
||
.unwrap_or_else(|| base_columns[0].clone());
|
||
let detail = base_columns
|
||
.iter()
|
||
.find(|(_, column)| column.field == "when")
|
||
.cloned()
|
||
.or_else(|| {
|
||
base_columns
|
||
.iter()
|
||
.find(|(_, column)| column.field != item.1.field)
|
||
.cloned()
|
||
});
|
||
base_columns = vec![item];
|
||
if let Some(detail) = detail {
|
||
base_columns.push(detail)
|
||
}
|
||
}
|
||
let columns = base_columns
|
||
.into_iter()
|
||
.flat_map(|(base_index, column)| {
|
||
let percent = column.percent_total.then(|| DisplayColumn {
|
||
column: column.clone(),
|
||
percent: true,
|
||
base_index,
|
||
});
|
||
std::iter::once(DisplayColumn {
|
||
column,
|
||
percent: false,
|
||
base_index,
|
||
})
|
||
.chain(percent)
|
||
})
|
||
.collect::<Vec<_>>();
|
||
let widths = column_widths(&columns, area.width);
|
||
let mut x = area.x;
|
||
for (column, width) in columns.iter().zip(&widths) {
|
||
app.item_columns.push((
|
||
Rect::new(
|
||
x,
|
||
area.y,
|
||
(*width).min(u16::MAX as usize) as u16,
|
||
area.height,
|
||
),
|
||
column.base_index,
|
||
));
|
||
x = x.saturating_add((*width).min(u16::MAX as usize) as u16 + 1);
|
||
}
|
||
let mut display: Vec<DisplayLine> = vec![];
|
||
let sections = app.current_view().sections.clone();
|
||
if sections.is_empty() {
|
||
display.push(DisplayLine::Header);
|
||
for index in 0..app.items.len() {
|
||
display.push(DisplayLine::Item {
|
||
index,
|
||
section: (0..app.items.len()).collect(),
|
||
});
|
||
}
|
||
if columns
|
||
.iter()
|
||
.any(|column| column.column.aggregate != Aggregate::None)
|
||
{
|
||
display.push(DisplayLine::Aggregate((0..app.items.len()).collect()));
|
||
}
|
||
} else {
|
||
for section in sections {
|
||
display.push(DisplayLine::Section(format!(
|
||
"{}{}",
|
||
if section.collapsed { "▸ " } else { "▾ " },
|
||
section.heading
|
||
)));
|
||
if section.collapsed {
|
||
continue;
|
||
}
|
||
display.push(DisplayLine::Header);
|
||
let indices = app
|
||
.items
|
||
.iter()
|
||
.enumerate()
|
||
.filter(|(_, item)| filter::matches(§ion.filter_expr, item).unwrap_or(false))
|
||
.map(|(i, _)| i)
|
||
.collect::<Vec<_>>();
|
||
for index in &indices {
|
||
display.push(DisplayLine::Item {
|
||
index: *index,
|
||
section: indices.clone(),
|
||
});
|
||
}
|
||
if columns
|
||
.iter()
|
||
.any(|column| column.column.aggregate != Aggregate::None)
|
||
{
|
||
display.push(DisplayLine::Aggregate(indices));
|
||
}
|
||
}
|
||
}
|
||
let selected_line = display
|
||
.iter()
|
||
.position(|line| matches!(line,DisplayLine::Item { index, .. } if *index==app.selected))
|
||
.unwrap_or(0);
|
||
let visible = area.height as usize;
|
||
if selected_line < app.scroll {
|
||
app.scroll = selected_line
|
||
}
|
||
if selected_line >= app.scroll + visible {
|
||
app.scroll = selected_line + 1 - visible
|
||
}
|
||
let mut lines = vec![];
|
||
for (shown, line) in display.iter().skip(app.scroll).take(visible).enumerate() {
|
||
match line {
|
||
DisplayLine::Section(heading) => lines.push(Line::from(Span::styled(
|
||
format!("── {heading} "),
|
||
Style::default()
|
||
.fg(colors.canvas_heading)
|
||
.bg(colors.canvas)
|
||
.add_modifier(Modifier::BOLD),
|
||
))),
|
||
DisplayLine::Header => lines.push(cells_line(
|
||
columns.iter().map(DisplayColumn::heading).collect(),
|
||
&widths,
|
||
Style::default()
|
||
.fg(colors.canvas_heading)
|
||
.bg(colors.canvas)
|
||
.add_modifier(Modifier::BOLD),
|
||
)),
|
||
DisplayLine::Item { index, section } => {
|
||
let item = &app.items[*index];
|
||
let style = Style::default()
|
||
.fg(if item.done_at.is_some() {
|
||
Color::DarkGray
|
||
} else {
|
||
colors.foreground
|
||
})
|
||
.bg(colors.canvas);
|
||
let cells = columns
|
||
.iter()
|
||
.map(|column| column_value(column, item, section, &app.items, app))
|
||
.collect();
|
||
lines.push(cells_line_selected(
|
||
cells,
|
||
&columns,
|
||
&widths,
|
||
style,
|
||
(*index == app.selected).then_some(app.selected_column),
|
||
Style::default()
|
||
.fg(colors.selection_foreground)
|
||
.bg(colors.selection)
|
||
.add_modifier(Modifier::BOLD),
|
||
));
|
||
app.item_rows.push((
|
||
Rect::new(area.x, area.y + shown as u16, area.width, 1),
|
||
item.id,
|
||
));
|
||
}
|
||
DisplayLine::Aggregate(indices) => {
|
||
let cells = columns
|
||
.iter()
|
||
.map(|column| {
|
||
if column.percent {
|
||
String::new()
|
||
} else {
|
||
aggregate_value(&column.column, indices, &app.items, app)
|
||
}
|
||
})
|
||
.collect();
|
||
lines.push(cells_line(
|
||
cells,
|
||
&widths,
|
||
Style::default()
|
||
.fg(colors.canvas_heading)
|
||
.bg(colors.canvas)
|
||
.add_modifier(Modifier::BOLD),
|
||
));
|
||
}
|
||
}
|
||
}
|
||
frame.render_widget(
|
||
Paragraph::new(lines).style(Style::default().bg(colors.canvas)),
|
||
area,
|
||
);
|
||
}
|
||
|
||
enum DisplayLine {
|
||
Section(String),
|
||
Header,
|
||
Item { index: usize, section: Vec<usize> },
|
||
Aggregate(Vec<usize>),
|
||
}
|
||
|
||
#[derive(Clone)]
|
||
struct DisplayColumn {
|
||
column: ViewColumn,
|
||
percent: bool,
|
||
base_index: usize,
|
||
}
|
||
|
||
impl DisplayColumn {
|
||
fn heading(&self) -> String {
|
||
if self.percent {
|
||
format!("{} %", self.column.heading)
|
||
} else {
|
||
self.column.heading.clone()
|
||
}
|
||
}
|
||
|
||
fn width(&self) -> u16 {
|
||
if self.percent { 10 } else { self.column.width }
|
||
}
|
||
}
|
||
|
||
fn column_widths(columns: &[DisplayColumn], total: u16) -> Vec<usize> {
|
||
let spacing = columns.len().saturating_sub(1) as u16;
|
||
let available = total.saturating_sub(spacing).max(columns.len() as u16);
|
||
let weights = columns
|
||
.iter()
|
||
.map(|column| column.width().max(1) as u32)
|
||
.sum::<u32>()
|
||
.max(1);
|
||
let mut widths = columns
|
||
.iter()
|
||
.map(|column| ((available as u32 * column.width().max(1) as u32 / weights).max(1)) as usize)
|
||
.collect::<Vec<_>>();
|
||
let used = widths.iter().sum::<usize>();
|
||
if let Some(first) = widths.first_mut() {
|
||
*first += available as usize - used;
|
||
}
|
||
widths
|
||
}
|
||
fn cells_line_selected(
|
||
values: Vec<String>,
|
||
columns: &[DisplayColumn],
|
||
widths: &[usize],
|
||
style: Style,
|
||
selected: Option<usize>,
|
||
selected_style: Style,
|
||
) -> Line<'static> {
|
||
let mut spans = Vec::with_capacity(values.len() * 2);
|
||
for (index, ((value, width), column)) in values.into_iter().zip(widths).zip(columns).enumerate()
|
||
{
|
||
if index > 0 {
|
||
spans.push(Span::styled(" ", style));
|
||
}
|
||
spans.push(Span::styled(
|
||
fit_aligned(&value, *width, &column.column.alignment),
|
||
if selected == Some(column.base_index) && !column.percent {
|
||
selected_style
|
||
} else {
|
||
style
|
||
},
|
||
));
|
||
}
|
||
Line::from(spans)
|
||
}
|
||
fn cells_line(values: Vec<String>, widths: &[usize], style: Style) -> Line<'static> {
|
||
let text = values
|
||
.into_iter()
|
||
.zip(widths)
|
||
.map(|(v, w)| fit(&v, *w))
|
||
.collect::<Vec<_>>()
|
||
.join(" ");
|
||
Line::from(Span::styled(text, style))
|
||
}
|
||
fn fit(value: &str, width: usize) -> String {
|
||
let mut chars = value.chars();
|
||
let mut out = chars.by_ref().take(width).collect::<String>();
|
||
let count = out.chars().count();
|
||
if chars.next().is_some() && width > 0 {
|
||
out.pop();
|
||
out.push('…')
|
||
} else if count < width {
|
||
out.push_str(&" ".repeat(width - count))
|
||
}
|
||
out
|
||
}
|
||
fn fit_aligned(value: &str, width: usize, alignment: &str) -> String {
|
||
let fitted = fit(value, width);
|
||
let trimmed = fitted.trim_end();
|
||
let padding = width.saturating_sub(trimmed.chars().count());
|
||
match alignment {
|
||
"right" => format!("{}{trimmed}", " ".repeat(padding)),
|
||
"center" => format!(
|
||
"{}{}{}",
|
||
" ".repeat(padding / 2),
|
||
trimmed,
|
||
" ".repeat(padding - padding / 2)
|
||
),
|
||
_ => fitted,
|
||
}
|
||
}
|
||
fn column_value(
|
||
display: &DisplayColumn,
|
||
item: &Item,
|
||
section: &[usize],
|
||
items: &[Item],
|
||
app: &App,
|
||
) -> String {
|
||
let column = &display.column;
|
||
if display.percent {
|
||
let total = section
|
||
.iter()
|
||
.filter_map(|index| column.numeric_value(&items[*index]))
|
||
.sum::<f64>();
|
||
return column
|
||
.numeric_value(item)
|
||
.filter(|_| total != 0.0)
|
||
.map(|value| format!("{:.2}%", value * 100.0 / total))
|
||
.unwrap_or_default();
|
||
}
|
||
match column.field.as_str() {
|
||
"item" => {
|
||
let marker = if app.marked.contains(&item.id) {
|
||
"◆"
|
||
} else if item.done_at.is_some() {
|
||
"✓"
|
||
} else if app.db.unmet_dependencies(item.id).unwrap_or(0) > 0 {
|
||
"◦"
|
||
} else {
|
||
&app.preferences.item_marker
|
||
};
|
||
format!("{marker} {}", item.text.replace('\n', " "))
|
||
}
|
||
"categories" => format_categories(item.category_names()),
|
||
"when" => formatted_when(app, item.when_at.as_deref()),
|
||
"priority" => item.priority.to_string(),
|
||
"note" => item.note.replace(
|
||
'\n',
|
||
if app.preferences.show_carriage_returns {
|
||
"↵ "
|
||
} else {
|
||
" "
|
||
},
|
||
),
|
||
"value" | "numeric" => column
|
||
.numeric_value(item)
|
||
.map(|value| column.format_number(value))
|
||
.unwrap_or_default(),
|
||
"date" => column
|
||
.category
|
||
.as_deref()
|
||
.and_then(|category| item.date_value_for(category))
|
||
.map(|value| formatted_when(app, Some(value)))
|
||
.unwrap_or_default(),
|
||
"done" => formatted_when(app, item.done_at.as_deref()),
|
||
"alarm" => formatted_when(app, item.alarm_at.as_deref()),
|
||
"recurrence" => item.recurrence.clone(),
|
||
"created" => formatted_when(app, Some(&item.created_at)),
|
||
"updated" => formatted_when(app, Some(&item.updated_at)),
|
||
_ => String::new(),
|
||
}
|
||
}
|
||
fn aggregate_value(column: &ViewColumn, indices: &[usize], items: &[Item], app: &App) -> String {
|
||
let _ = app;
|
||
column.aggregate_value(indices.iter().map(|index| &items[*index]))
|
||
}
|
||
|
||
fn draw_datebook(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
|
||
let colors = palette(&app.preferences.theme);
|
||
let mut lines: Vec<Line> = vec![];
|
||
let mut dates: HashMap<String, bool> = HashMap::new();
|
||
for i in &app.items {
|
||
let formatted = formatted_when(app, i.when_at.as_deref());
|
||
let (date, time) = formatted.rsplit_once(' ').unwrap_or((&formatted, ""));
|
||
let date = date.to_owned();
|
||
if !dates.contains_key(&date) {
|
||
lines.push(Line::from(Span::styled(
|
||
format!("── {date} ──"),
|
||
Style::default()
|
||
.fg(colors.canvas_heading)
|
||
.bg(colors.canvas)
|
||
.add_modifier(Modifier::BOLD),
|
||
)));
|
||
dates.insert(date.clone(), true);
|
||
}
|
||
let selected = app.items.get(app.selected).map(|s| s.id) == Some(i.id);
|
||
let style = if selected {
|
||
Style::default()
|
||
.fg(colors.selection_foreground)
|
||
.bg(colors.selection)
|
||
} else {
|
||
Style::default().fg(colors.foreground).bg(colors.canvas)
|
||
};
|
||
let y = area.y + lines.len() as u16;
|
||
app.item_rows
|
||
.push((Rect::new(area.x, y, area.width, 1), i.id));
|
||
lines.push(Line::from(Span::styled(
|
||
format!(" {} {}", time, i.text),
|
||
style,
|
||
)));
|
||
}
|
||
frame.render_widget(
|
||
Paragraph::new(lines).style(Style::default().bg(colors.canvas)),
|
||
area,
|
||
);
|
||
}
|
||
|
||
fn format_categories(s: String) -> String {
|
||
if s.is_empty() {
|
||
String::new()
|
||
} else {
|
||
s.split(", ")
|
||
.map(|v| format!("·{v}"))
|
||
.collect::<Vec<_>>()
|
||
.join(" ")
|
||
}
|
||
}
|
||
|
||
fn formatted_when(app: &App, value: Option<&str>) -> String {
|
||
format_when(
|
||
value,
|
||
&app.preferences.date_format,
|
||
app.preferences.clock_24h,
|
||
)
|
||
}
|
||
|
||
fn draw_status(frame: &mut Frame<'_>, app: &App, area: Rect) {
|
||
let colors = palette(&app.preferences.theme);
|
||
let column = app
|
||
.current_view()
|
||
.columns
|
||
.get(app.selected_column)
|
||
.map(|column| format!(" Cell: {}", column.heading))
|
||
.unwrap_or_default();
|
||
let right = format!("{} item(s){column}", app.items.len());
|
||
let pad = area
|
||
.width
|
||
.saturating_sub((app.status.chars().count() + right.chars().count() + 2) as u16)
|
||
as usize;
|
||
frame.render_widget(
|
||
Paragraph::new(format!(" {}{}{right}", app.status, " ".repeat(pad)))
|
||
.style(Style::default().fg(colors.accent).bg(colors.primary)),
|
||
area,
|
||
);
|
||
}
|
||
|
||
fn draw_commands(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
|
||
let colors = palette(&app.preferences.theme);
|
||
let labels = [
|
||
"Help", "Edit", "Choices", "Done", "Note", "Props", "Mark", "Vw Mgr", "Cat Mgr", "Menu",
|
||
];
|
||
app.command_regions.clear();
|
||
let cells = Layout::default()
|
||
.direction(Direction::Horizontal)
|
||
.constraints((0..10).map(|_| Constraint::Ratio(1, 10)))
|
||
.split(area);
|
||
for (i, r) in cells.iter().enumerate() {
|
||
app.command_regions.push(*r);
|
||
frame.render_widget(
|
||
Paragraph::new(vec![
|
||
Line::from(format!("F{}", i + 1)),
|
||
Line::from(labels[i]),
|
||
])
|
||
.alignment(Alignment::Center)
|
||
.style(Style::default().fg(Color::White).bg(colors.primary)),
|
||
*r,
|
||
);
|
||
}
|
||
}
|
||
|
||
fn draw_overlay(frame: &mut Frame<'_>, app: &mut App) {
|
||
let colors = palette(&app.preferences.theme);
|
||
app.choice_regions.clear();
|
||
match &app.mode {
|
||
Mode::Normal => {}
|
||
Mode::Help => {
|
||
let text = Text::from(vec![
|
||
Line::from(Span::styled(
|
||
"ROGUE AGENDA HELP",
|
||
Style::default()
|
||
.fg(colors.accent)
|
||
.add_modifier(Modifier::BOLD),
|
||
)),
|
||
Line::from(""),
|
||
Line::from(
|
||
"Capture a sentence first; assign categories and shape views as patterns emerge.",
|
||
),
|
||
Line::from(""),
|
||
Line::from("↑↓/jk move Insert/n new Enter/F2 edit F3/c categories"),
|
||
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-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."),
|
||
]);
|
||
popup(
|
||
frame,
|
||
center(frame.area(), 74, 15),
|
||
"Help",
|
||
Paragraph::new(text).wrap(Wrap { trim: false }),
|
||
colors,
|
||
);
|
||
}
|
||
Mode::Input(input) => {
|
||
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,
|
||
});
|
||
let before_cursor = &input.value[..input.cursor];
|
||
let cursor_line = before_cursor.bytes().filter(|byte| *byte == b'\n').count();
|
||
let current_line_start = before_cursor.rfind('\n').map_or(0, |index| index + 1);
|
||
let cursor_column = Line::from(&before_cursor[current_line_start..]).width();
|
||
let vertical_scroll =
|
||
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 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),
|
||
)))
|
||
} else {
|
||
Text::from(
|
||
if app.preferences.show_carriage_returns && input.multiline {
|
||
input.value.replace('\n', "↵\n")
|
||
} else {
|
||
input.value.clone()
|
||
},
|
||
)
|
||
};
|
||
popup(
|
||
frame,
|
||
area,
|
||
&input.title,
|
||
Paragraph::new(shown).scroll((
|
||
vertical_scroll.min(u16::MAX as usize) as u16,
|
||
horizontal_scroll.min(u16::MAX as usize) as u16,
|
||
)),
|
||
colors,
|
||
);
|
||
frame.set_cursor_position((
|
||
inner.x + cursor_column.saturating_sub(horizontal_scroll) as u16,
|
||
inner.y + cursor_line.saturating_sub(vertical_scroll) as u16,
|
||
));
|
||
}
|
||
Mode::Properties(p) => {
|
||
let names = [
|
||
"Priority (1 highest)",
|
||
"When (phrase or RFC3339)",
|
||
"Alarm (phrase or RFC3339)",
|
||
"Recurrence (daily/weekly/monthly/every N days)",
|
||
];
|
||
let lines = names
|
||
.iter()
|
||
.enumerate()
|
||
.flat_map(|(i, n)| {
|
||
[
|
||
Line::from(Span::styled(
|
||
*n,
|
||
Style::default().fg(if i == p.field {
|
||
Color::Yellow
|
||
} else {
|
||
colors.accent
|
||
}),
|
||
)),
|
||
Line::from(Span::styled(
|
||
format!("> {}", p.values[i]),
|
||
Style::default()
|
||
.fg(if i == p.field {
|
||
colors.selection_foreground
|
||
} else {
|
||
Color::White
|
||
})
|
||
.bg(if i == p.field {
|
||
colors.selection
|
||
} else {
|
||
colors.primary
|
||
}),
|
||
)),
|
||
]
|
||
})
|
||
.chain([
|
||
Line::from(""),
|
||
Line::from("Tab fields · Enter save · Esc cancel"),
|
||
])
|
||
.collect::<Vec<_>>();
|
||
popup(
|
||
frame,
|
||
center(frame.area(), 78, 16),
|
||
"Item Properties",
|
||
Paragraph::new(lines),
|
||
colors,
|
||
);
|
||
}
|
||
Mode::Form(form) => {
|
||
let (title, names, hint) = match form.kind {
|
||
FormKind::Category(_) => (
|
||
"Category Definition",
|
||
vec![
|
||
"Name",
|
||
"Parent category",
|
||
"Kind",
|
||
"Match phrases (comma separated)",
|
||
"Mutually exclusive (yes/no)",
|
||
"Rule condition",
|
||
"Rule action",
|
||
],
|
||
"Condition files live; action fires on entry (numeric:Hours=7.5)",
|
||
),
|
||
FormKind::View(_) => (
|
||
"Live View Definition",
|
||
vec![
|
||
"Name",
|
||
"Kind",
|
||
"Filter value",
|
||
"Boolean filter",
|
||
"Sort key",
|
||
"Show done (yes/no)",
|
||
"Columns (numeric[Hours]:width:heading:aggregate:…)",
|
||
"Sections (heading|filter; …)",
|
||
],
|
||
"Filters: category=Work and (open or priority<=2)",
|
||
),
|
||
FormKind::Preferences => (
|
||
"Preferences (~/.config/rogue-agenda/preferences.toml)",
|
||
vec![
|
||
"Theme",
|
||
"Show command bar (yes/no)",
|
||
"Show rule info (yes/no)",
|
||
"Show return markers (yes/no)",
|
||
"Item marker (one character)",
|
||
"Autosave minutes (0-60)",
|
||
"Confirm destructive actions",
|
||
"Date format (iso/us/european/long)",
|
||
"24-hour clock (yes/no)",
|
||
"Decimal separator",
|
||
"Thousands separator",
|
||
],
|
||
"Application-wide settings are stored as TOML in your home directory.",
|
||
),
|
||
FormKind::DocumentSettings => (
|
||
"Document Settings (stored inside this .agnd)",
|
||
vec![
|
||
"Description",
|
||
"Backup on open (yes/no)",
|
||
"Trash (on-demand/on-close/end-of-day/immediate)",
|
||
"Completed items (keep/trash)",
|
||
"Automatic filing (yes/no)",
|
||
"Numeric date order (ymd/mdy/dmy)",
|
||
"Week starts (monday/sunday)",
|
||
"Default time (HH:MM)",
|
||
"Morning time (HH:MM)",
|
||
"Afternoon time (HH:MM)",
|
||
"Evening time (HH:MM)",
|
||
"Note tab width (1-16)",
|
||
"Report headers and footers (yes/no)",
|
||
],
|
||
"These settings travel with the SQLite document.",
|
||
),
|
||
};
|
||
let preview = match form.kind {
|
||
FormKind::Category(_) | FormKind::View(_) => {
|
||
let filter_source = match form.kind {
|
||
FormKind::Category(_) => form.values.get(5),
|
||
FormKind::View(_) => form.values.get(3),
|
||
FormKind::Preferences | FormKind::DocumentSettings => None,
|
||
}
|
||
.map(String::as_str)
|
||
.unwrap_or("");
|
||
match app.db.count_matching_filter(filter_source) {
|
||
Ok(n) => format!("Preview: {n} existing item(s) match"),
|
||
Err(e) => format!("Preview error: {e}"),
|
||
}
|
||
}
|
||
FormKind::Preferences => format!("File: {}", app.preferences_path.display()),
|
||
FormKind::DocumentSettings => {
|
||
"Document preferences are saved transactionally".into()
|
||
}
|
||
};
|
||
let lines = names
|
||
.iter()
|
||
.enumerate()
|
||
.map(|(i, name)| {
|
||
let indicator = if form_choices(&form.kind, i).is_some() {
|
||
"▾"
|
||
} else {
|
||
" "
|
||
};
|
||
Line::from(Span::styled(
|
||
format!("{indicator} {name:<48} {}", form.values[i]),
|
||
Style::default()
|
||
.fg(if i == form.field {
|
||
colors.selection_foreground
|
||
} else {
|
||
colors.accent
|
||
})
|
||
.bg(if i == form.field {
|
||
colors.selection
|
||
} else {
|
||
colors.primary
|
||
}),
|
||
))
|
||
})
|
||
.chain([
|
||
Line::from(""),
|
||
Line::from(hint),
|
||
Line::from(preview),
|
||
Line::from("Space opens choices · Tab fields · Enter save · Esc back"),
|
||
])
|
||
.collect::<Vec<_>>();
|
||
popup(
|
||
frame,
|
||
center(frame.area(), 110, (names.len() as u16 + 6).clamp(12, 22)),
|
||
title,
|
||
Paragraph::new(lines),
|
||
colors,
|
||
);
|
||
}
|
||
Mode::Choice(choice) => {
|
||
let items = choice
|
||
.options
|
||
.iter()
|
||
.map(|option| ListItem::new(option.label.as_str()))
|
||
.collect::<Vec<_>>();
|
||
let mut state = ListState::default().with_selected(Some(choice.selected));
|
||
let area = center(
|
||
frame.area(),
|
||
58,
|
||
(choice.options.len() as u16 + 2).clamp(5, 18),
|
||
);
|
||
let inner = area.inner(Margin {
|
||
horizontal: 1,
|
||
vertical: 1,
|
||
});
|
||
app.choice_regions
|
||
.extend(
|
||
(0..choice.options.len().min(inner.height as usize)).map(|index| Rect {
|
||
x: inner.x,
|
||
y: inner.y + index as u16,
|
||
width: inner.width,
|
||
height: 1,
|
||
}),
|
||
);
|
||
frame.render_widget(Clear, area);
|
||
frame.render_stateful_widget(
|
||
List::new(items)
|
||
.block(
|
||
Block::bordered()
|
||
.title(" Choose (↑↓ · Enter/Space · Esc) ")
|
||
.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,
|
||
);
|
||
}
|
||
Mode::Dependencies {
|
||
item_id,
|
||
choices,
|
||
selected,
|
||
} => {
|
||
let assigned = app.db.dependency_ids(*item_id).unwrap_or_default();
|
||
let items = choices
|
||
.iter()
|
||
.map(|item| {
|
||
ListItem::new(format!(
|
||
"{} {}",
|
||
if assigned.contains(&item.id) {
|
||
"[×]"
|
||
} else {
|
||
"[ ]"
|
||
},
|
||
item.text
|
||
))
|
||
})
|
||
.collect::<Vec<_>>();
|
||
let mut state = ListState::default().with_selected(Some(*selected));
|
||
let area = center(frame.area(), 76, (choices.len() as u16 + 4).clamp(8, 22));
|
||
frame.render_widget(Clear, area);
|
||
frame.render_stateful_widget(
|
||
List::new(items)
|
||
.block(
|
||
Block::bordered()
|
||
.title(" Prerequisites (Space toggles · Enter closes) ")
|
||
.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,
|
||
);
|
||
}
|
||
Mode::Categories => draw_category_list(
|
||
frame,
|
||
app,
|
||
"Assign Categories (Space toggles · Enter closes)",
|
||
true,
|
||
),
|
||
Mode::CategoryManager => draw_category_list(
|
||
frame,
|
||
app,
|
||
"Category Manager (n add · e edit · Delete discard)",
|
||
false,
|
||
),
|
||
Mode::Views => {
|
||
let items = app
|
||
.views
|
||
.iter()
|
||
.map(|v| {
|
||
ListItem::new(format!("{:<18} {:<9} sort: {}", v.name, v.kind, v.sort_key))
|
||
})
|
||
.collect::<Vec<_>>();
|
||
let mut state = ListState::default().with_selected(Some(app.view_selected));
|
||
let area = center(frame.area(), 64, (app.views.len() as u16 + 4).min(20));
|
||
frame.render_widget(Clear, area);
|
||
frame.render_stateful_widget(
|
||
List::new(items)
|
||
.block(
|
||
Block::bordered()
|
||
.title(
|
||
" View Manager (n add · e edit · Ctrl-↑↓ move · Delete discard) ",
|
||
)
|
||
.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,
|
||
);
|
||
}
|
||
Mode::Menu => {
|
||
popup(
|
||
frame,
|
||
center(frame.area(), 64, 17),
|
||
"Rogue Agenda Menu",
|
||
Paragraph::new(vec![
|
||
Line::from(" n New item"),
|
||
Line::from(" s Save / checkpoint"),
|
||
Line::from(" h Help"),
|
||
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(" u Utilities Execute conditions/actions"),
|
||
Line::from(" f Inspect rule conflicts"),
|
||
Line::from(" b Back up document now"),
|
||
Line::from(" t Empty Trash permanently"),
|
||
Line::from(" q Quit"),
|
||
Line::from(""),
|
||
Line::from(" Esc closes the menu"),
|
||
])
|
||
.style(Style::default().fg(Color::White)),
|
||
colors,
|
||
);
|
||
}
|
||
Mode::Execute { selected } => {
|
||
let choices = [
|
||
"Current item",
|
||
"Marked items",
|
||
"Current section",
|
||
"Current view",
|
||
"Whole document",
|
||
];
|
||
let items = choices
|
||
.iter()
|
||
.map(|choice| ListItem::new(*choice))
|
||
.collect::<Vec<_>>();
|
||
let mut state = ListState::default().with_selected(Some(*selected));
|
||
let area = center(frame.area(), 62, 9);
|
||
frame.render_widget(Clear, area);
|
||
frame.render_stateful_widget(
|
||
List::new(items)
|
||
.block(
|
||
Block::bordered()
|
||
.title(" Utilities Execute ")
|
||
.title_bottom(" Enter applies conditions/actions · Esc cancels ")
|
||
.style(Style::default().fg(Color::White).bg(colors.primary)),
|
||
)
|
||
.highlight_style(
|
||
Style::default()
|
||
.fg(colors.selection_foreground)
|
||
.bg(colors.selection),
|
||
),
|
||
area,
|
||
&mut state,
|
||
);
|
||
}
|
||
Mode::RuleConflicts => {
|
||
let conflicts = app.db.rule_conflicts().unwrap_or_default();
|
||
let lines = if conflicts.is_empty() {
|
||
vec![Line::from("No rule conflicts.")]
|
||
} else {
|
||
conflicts
|
||
.iter()
|
||
.map(|conflict| {
|
||
Line::from(format!(
|
||
"#{} {} — {}",
|
||
conflict.item_id, conflict.item_text, conflict.message
|
||
))
|
||
})
|
||
.collect()
|
||
};
|
||
popup(
|
||
frame,
|
||
center(frame.area(), 90, (lines.len() as u16 + 4).clamp(7, 22)),
|
||
"Rule Conflicts (any key closes)",
|
||
Paragraph::new(lines).wrap(Wrap { trim: false }),
|
||
colors,
|
||
);
|
||
}
|
||
Mode::Confirm { prompt, .. } => {
|
||
popup(
|
||
frame,
|
||
center(frame.area(), 66, 7),
|
||
"Confirm",
|
||
Paragraph::new(vec![
|
||
Line::from(prompt.as_str()),
|
||
Line::from(""),
|
||
Line::from("Enter/Y confirms · N/Esc cancels"),
|
||
]),
|
||
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
|
||
.selected_item()
|
||
.and_then(|i| app.db.item_category_ids(i.id).ok())
|
||
.unwrap_or_default();
|
||
let excluded = app
|
||
.selected_item()
|
||
.and_then(|i| app.db.excluded_category_ids(i.id).ok())
|
||
.unwrap_or_default();
|
||
let names: HashMap<i64, &str> = app
|
||
.categories
|
||
.iter()
|
||
.map(|c| (c.id, c.name.as_str()))
|
||
.collect();
|
||
let items = app
|
||
.categories
|
||
.iter()
|
||
.map(|c| {
|
||
let indent = if c.parent_id.is_some() { " └ " } else { "" };
|
||
let marker = if assign && assigned.contains(&c.id) {
|
||
"[×]"
|
||
} else if assign && excluded.contains(&c.id) {
|
||
"[-]"
|
||
} else if assign {
|
||
"[ ]"
|
||
} else {
|
||
" • "
|
||
};
|
||
let parent = c
|
||
.parent_id
|
||
.and_then(|p| names.get(&p))
|
||
.map(|p| format!(" ({p})"))
|
||
.unwrap_or_default();
|
||
let rule = if app.preferences.show_rule_info {
|
||
app.db
|
||
.category_rules(c.id)
|
||
.ok()
|
||
.and_then(|rules| rules.into_iter().next())
|
||
.map(|r| {
|
||
format!(
|
||
" rule: {} → {}:{}",
|
||
r.condition_expr, r.action_kind, r.action_value
|
||
)
|
||
})
|
||
.unwrap_or_default()
|
||
} else {
|
||
String::new()
|
||
};
|
||
ListItem::new(format!(
|
||
"{marker} {indent}{}{} {}{}",
|
||
c.name,
|
||
parent,
|
||
if c.match_text.is_empty() {
|
||
String::new()
|
||
} else {
|
||
format!("matches: {}", c.match_text)
|
||
},
|
||
rule
|
||
))
|
||
})
|
||
.collect::<Vec<_>>();
|
||
let mut state = ListState::default().with_selected(Some(app.category_selected));
|
||
let area = center(frame.area(), 78, (app.categories.len() as u16 + 4).min(22));
|
||
frame.render_widget(Clear, area);
|
||
frame.render_stateful_widget(
|
||
List::new(items)
|
||
.block(
|
||
Block::bordered()
|
||
.title(format!(" {title} "))
|
||
.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,
|
||
);
|
||
}
|
||
|
||
fn popup(frame: &mut Frame<'_>, area: Rect, title: &str, widget: Paragraph<'_>, colors: Palette) {
|
||
frame.render_widget(Clear, area);
|
||
frame.render_widget(
|
||
Block::default()
|
||
.borders(Borders::ALL)
|
||
.title(format!(" {title} "))
|
||
.border_style(Style::default().fg(colors.accent))
|
||
.style(Style::default().bg(colors.primary)),
|
||
area,
|
||
);
|
||
frame.render_widget(
|
||
widget.style(Style::default().fg(Color::White).bg(colors.primary)),
|
||
area.inner(Margin {
|
||
horizontal: 1,
|
||
vertical: 1,
|
||
}),
|
||
);
|
||
}
|
||
fn center(area: Rect, width: u16, height: u16) -> Rect {
|
||
let w = width.min(area.width.saturating_sub(2));
|
||
let h = height.min(area.height.saturating_sub(2));
|
||
Rect::new(
|
||
area.x + (area.width - w) / 2,
|
||
area.y + (area.height - h) / 2,
|
||
w,
|
||
h,
|
||
)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::{app::App, db::Database, model::ItemChanges, preferences::AppPreferences};
|
||
use crossterm::event::{
|
||
KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
|
||
};
|
||
use ratatui::{
|
||
Terminal,
|
||
backend::{Backend, TestBackend},
|
||
};
|
||
use tempfile::tempdir;
|
||
#[test]
|
||
fn renders_main_workspace() {
|
||
let d = tempdir().unwrap();
|
||
let p = d.path().join("ui.agnd");
|
||
let mut db = Database::open(&p).unwrap();
|
||
db.add_item("Call Grace tomorrow").unwrap();
|
||
let mut app = App::new_with_preferences(
|
||
db,
|
||
p,
|
||
AppPreferences::default(),
|
||
d.path().join("preferences.toml"),
|
||
)
|
||
.unwrap();
|
||
let backend = TestBackend::new(100, 30);
|
||
let mut term = Terminal::new(backend).unwrap();
|
||
term.draw(|f| draw(f, &mut app)).unwrap();
|
||
let screen = term
|
||
.backend()
|
||
.buffer()
|
||
.content
|
||
.iter()
|
||
.map(|c| c.symbol())
|
||
.collect::<String>();
|
||
assert!(screen.contains("View: All Items"));
|
||
assert!(screen.contains("Call Grace"));
|
||
assert!(screen.contains("Cat Mgr"));
|
||
}
|
||
|
||
#[test]
|
||
fn renders_persistent_columns_sections_and_aggregates() {
|
||
let d = tempdir().unwrap();
|
||
let p = d.path().join("designed.agnd");
|
||
let mut db = Database::open(&p).unwrap();
|
||
let item = db.add_item("File the urgent report").unwrap();
|
||
db.update_item(
|
||
item,
|
||
&ItemChanges {
|
||
priority: Some(1),
|
||
numeric_value: Some(Some(75.0)),
|
||
..Default::default()
|
||
},
|
||
)
|
||
.unwrap();
|
||
let view_id = db
|
||
.save_view_design(
|
||
None,
|
||
"Dashboard",
|
||
"list",
|
||
"",
|
||
"priority",
|
||
true,
|
||
"open",
|
||
"item:70:Action:none,value:30:Cost:sum::2:.:comma:minus:percent:right",
|
||
"Urgent work|priority=1",
|
||
)
|
||
.unwrap();
|
||
let mut app = App::new_with_preferences(
|
||
db,
|
||
p,
|
||
AppPreferences::default(),
|
||
d.path().join("preferences.toml"),
|
||
)
|
||
.unwrap();
|
||
app.view_index = app.views.iter().position(|v| v.id == view_id).unwrap();
|
||
app.refresh().unwrap();
|
||
let backend = TestBackend::new(90, 24);
|
||
let mut term = Terminal::new(backend).unwrap();
|
||
term.draw(|f| draw(f, &mut app)).unwrap();
|
||
let screen = term
|
||
.backend()
|
||
.buffer()
|
||
.content
|
||
.iter()
|
||
.map(|c| c.symbol())
|
||
.collect::<String>();
|
||
assert!(screen.contains("Urgent work"));
|
||
assert!(screen.contains("Action"));
|
||
assert!(screen.contains("Cost"));
|
||
assert!(screen.contains("Total 75.00"));
|
||
assert!(screen.contains("100.00%"));
|
||
}
|
||
|
||
#[test]
|
||
fn compact_layout_keeps_primary_fields_visible() {
|
||
let d = tempdir().unwrap();
|
||
let p = d.path().join("compact.agnd");
|
||
let mut db = Database::open(&p).unwrap();
|
||
db.add_item("Compact item tomorrow").unwrap();
|
||
let mut app = App::new_with_preferences(
|
||
db,
|
||
p,
|
||
AppPreferences::default(),
|
||
d.path().join("preferences.toml"),
|
||
)
|
||
.unwrap();
|
||
let backend = TestBackend::new(50, 15);
|
||
let mut term = Terminal::new(backend).unwrap();
|
||
term.draw(|f| draw(f, &mut app)).unwrap();
|
||
let screen = term
|
||
.backend()
|
||
.buffer()
|
||
.content
|
||
.iter()
|
||
.map(|c| c.symbol())
|
||
.collect::<String>();
|
||
assert!(screen.contains("Compact item"));
|
||
assert!(screen.contains("When"));
|
||
}
|
||
|
||
#[test]
|
||
fn main_screen_h_and_l_cycle_views_with_wraparound() {
|
||
let directory = tempdir().unwrap();
|
||
let path = directory.path().join("view-navigation.agnd");
|
||
let db = Database::open(&path).unwrap();
|
||
let mut app = App::new_with_preferences(
|
||
db,
|
||
path,
|
||
AppPreferences::default(),
|
||
directory.path().join("preferences.toml"),
|
||
)
|
||
.unwrap();
|
||
assert!(app.views.len() > 1);
|
||
|
||
app.handle_key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE))
|
||
.unwrap();
|
||
assert_eq!(app.view_index, 1);
|
||
app.handle_key(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE))
|
||
.unwrap();
|
||
assert_eq!(app.view_index, 0);
|
||
app.handle_key(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE))
|
||
.unwrap();
|
||
assert_eq!(app.view_index, app.views.len() - 1);
|
||
app.handle_key(KeyEvent::new(KeyCode::Char(']'), KeyModifiers::NONE))
|
||
.unwrap();
|
||
assert_eq!(app.view_index, 0);
|
||
}
|
||
|
||
#[test]
|
||
fn note_editor_renders_and_saves_entered_line_breaks() {
|
||
let d = tempdir().unwrap();
|
||
let p = d.path().join("notes.agnd");
|
||
let mut db = Database::open(&p).unwrap();
|
||
db.add_item("Write release notes").unwrap();
|
||
let mut app = App::new_with_preferences(
|
||
db,
|
||
p,
|
||
AppPreferences::default(),
|
||
d.path().join("preferences.toml"),
|
||
)
|
||
.unwrap();
|
||
app.handle_key(KeyEvent::new(KeyCode::F(5), KeyModifiers::NONE))
|
||
.unwrap();
|
||
for c in "first line".chars() {
|
||
app.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE))
|
||
.unwrap();
|
||
}
|
||
app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
|
||
.unwrap();
|
||
for c in "second line".chars() {
|
||
app.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE))
|
||
.unwrap();
|
||
}
|
||
|
||
let backend = TestBackend::new(100, 30);
|
||
let mut term = Terminal::new(backend).unwrap();
|
||
term.draw(|frame| draw(frame, &mut app)).unwrap();
|
||
let cursor = term.backend_mut().get_cursor_position().unwrap();
|
||
let rows = term
|
||
.backend()
|
||
.buffer()
|
||
.content
|
||
.chunks(100)
|
||
.map(|row| row.iter().map(|cell| cell.symbol()).collect::<String>())
|
||
.collect::<Vec<_>>();
|
||
let first_row = rows
|
||
.iter()
|
||
.position(|row| row.contains("first line"))
|
||
.unwrap();
|
||
let second_row = rows
|
||
.iter()
|
||
.position(|row| row.contains("second line"))
|
||
.unwrap();
|
||
assert_eq!(second_row, first_row + 1);
|
||
assert_eq!(cursor.y as usize, second_row);
|
||
|
||
app.handle_key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL))
|
||
.unwrap();
|
||
assert_eq!(app.items[0].note, "first line\nsecond line");
|
||
}
|
||
|
||
#[test]
|
||
fn application_preferences_change_the_workspace_and_open_from_the_menu() {
|
||
let directory = tempdir().unwrap();
|
||
let path = directory.path().join("preferences-ui.agnd");
|
||
let mut db = Database::open(&path).unwrap();
|
||
let item = db.add_item("Configured display item").unwrap();
|
||
db.update_item(
|
||
item,
|
||
&ItemChanges {
|
||
when_at: Some(Some("2026-08-17T15:30:00+00:00".into())),
|
||
numeric_value: Some(Some(1234.5)),
|
||
..Default::default()
|
||
},
|
||
)
|
||
.unwrap();
|
||
let preferences = AppPreferences {
|
||
theme: "amber".into(),
|
||
show_command_bar: false,
|
||
item_marker: ">".into(),
|
||
date_format: "us".into(),
|
||
clock_24h: false,
|
||
decimal_separator: ",".into(),
|
||
thousands_separator: ".".into(),
|
||
..Default::default()
|
||
};
|
||
let mut app = App::new_with_preferences(
|
||
db,
|
||
path,
|
||
preferences,
|
||
directory.path().join("preferences.toml"),
|
||
)
|
||
.unwrap();
|
||
let backend = TestBackend::new(100, 30);
|
||
let mut terminal = Terminal::new(backend).unwrap();
|
||
terminal.draw(|frame| draw(frame, &mut app)).unwrap();
|
||
let screen = terminal
|
||
.backend()
|
||
.buffer()
|
||
.content
|
||
.iter()
|
||
.map(|cell| cell.symbol())
|
||
.collect::<String>();
|
||
assert!(screen.contains("> Configured display item"));
|
||
assert!(screen.contains("08/17/2026 3:30pm"));
|
||
assert!(!screen.contains("Cat Mgr"));
|
||
|
||
app.handle_key(KeyEvent::new(KeyCode::F(10), KeyModifiers::NONE))
|
||
.unwrap();
|
||
app.handle_key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE))
|
||
.unwrap();
|
||
assert!(matches!(
|
||
app.mode,
|
||
Mode::Form(crate::app::FormState {
|
||
kind: FormKind::Preferences,
|
||
..
|
||
})
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn predefined_preferences_open_choice_popovers_for_keyboard_and_mouse() {
|
||
let directory = tempdir().unwrap();
|
||
let path = directory.path().join("choice-popovers.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(KeyEvent::new(KeyCode::F(10), KeyModifiers::NONE))
|
||
.unwrap();
|
||
app.handle_key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE))
|
||
.unwrap();
|
||
app.handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE))
|
||
.unwrap();
|
||
assert!(matches!(app.mode, Mode::Choice(_)));
|
||
|
||
let backend = TestBackend::new(100, 30);
|
||
let mut terminal = Terminal::new(backend).unwrap();
|
||
terminal.draw(|frame| draw(frame, &mut app)).unwrap();
|
||
let screen = terminal
|
||
.backend()
|
||
.buffer()
|
||
.content
|
||
.iter()
|
||
.map(|cell| cell.symbol())
|
||
.collect::<String>();
|
||
assert!(screen.contains("Classic — Lotus-inspired blue"));
|
||
assert!(screen.contains("Amber — warm dark"));
|
||
assert!(screen.contains("Greenscreen — muted phosphor green"));
|
||
assert!(screen.contains("Nord — arctic blue dark"));
|
||
assert!(screen.contains("Catppuccin Mocha — cozy pastel dark"));
|
||
|
||
app.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE))
|
||
.unwrap();
|
||
app.handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE))
|
||
.unwrap();
|
||
let Mode::Form(form) = &app.mode else {
|
||
panic!("choice selection did not return to the form");
|
||
};
|
||
assert_eq!(form.values[0], "mono");
|
||
|
||
app.handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE))
|
||
.unwrap();
|
||
terminal.draw(|frame| draw(frame, &mut app)).unwrap();
|
||
let amber_region = app.choice_regions[2];
|
||
app.handle_mouse(MouseEvent {
|
||
kind: MouseEventKind::Down(MouseButton::Left),
|
||
column: amber_region.x,
|
||
row: amber_region.y,
|
||
modifiers: KeyModifiers::NONE,
|
||
})
|
||
.unwrap();
|
||
let Mode::Form(form) = &app.mode else {
|
||
panic!("mouse choice did not return to the form");
|
||
};
|
||
assert_eq!(form.values[0], "amber");
|
||
|
||
assert_eq!(palette("greenscreen").foreground, Color::Rgb(143, 189, 128));
|
||
assert_eq!(palette("nord").primary, Color::Rgb(46, 52, 64));
|
||
assert_eq!(
|
||
palette("catppuccin-mocha").foreground,
|
||
Color::Rgb(205, 214, 244)
|
||
);
|
||
|
||
let trash_choices = form_choices(&FormKind::DocumentSettings, 2).unwrap();
|
||
assert_eq!(
|
||
trash_choices
|
||
.iter()
|
||
.map(|choice| choice.value.as_str())
|
||
.collect::<Vec<_>>(),
|
||
["on-demand", "on-close", "end-of-day", "immediate"]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn additional_themes_keep_selected_rows_at_high_contrast() {
|
||
for theme in ["greenscreen", "nord", "catppuccin-mocha"] {
|
||
let colors = palette(theme);
|
||
let ratio = contrast_ratio(colors.selection_foreground, colors.selection);
|
||
assert!(
|
||
ratio >= 7.0,
|
||
"{theme} selected-row contrast was only {ratio:.2}:1"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn every_theme_keeps_canvas_headings_readable() {
|
||
for theme in [
|
||
"classic",
|
||
"mono",
|
||
"amber",
|
||
"greenscreen",
|
||
"nord",
|
||
"catppuccin-mocha",
|
||
] {
|
||
let colors = palette(theme);
|
||
let ratio = contrast_ratio(colors.canvas_heading, colors.canvas);
|
||
assert!(
|
||
ratio >= 4.5,
|
||
"{theme} canvas-heading contrast was only {ratio:.2}:1"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[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);
|
||
(first.max(second) + 0.05) / (first.min(second) + 0.05)
|
||
}
|
||
|
||
fn relative_luminance(color: Color) -> f64 {
|
||
let (red, green, blue) = match color {
|
||
Color::Black => (0, 0, 0),
|
||
Color::White => (255, 255, 255),
|
||
Color::Gray => (128, 128, 128),
|
||
Color::Rgb(red, green, blue) => (red, green, blue),
|
||
_ => panic!("contrast test requires RGB, black, white, or gray"),
|
||
};
|
||
let linear = |component: u8| {
|
||
let component = f64::from(component) / 255.0;
|
||
if component <= 0.04045 {
|
||
component / 12.92
|
||
} else {
|
||
((component + 0.055) / 1.055).powf(2.4)
|
||
}
|
||
};
|
||
0.2126 * linear(red) + 0.7152 * linear(green) + 0.0722 * linear(blue)
|
||
}
|
||
|
||
#[test]
|
||
fn document_editor_and_confirmation_use_saved_settings() {
|
||
let directory = tempdir().unwrap();
|
||
let path = directory.path().join("document-ui.agnd");
|
||
let mut db = Database::open(&path).unwrap();
|
||
let mut settings = db.document_settings().unwrap();
|
||
settings.note_tab_width = 3;
|
||
settings.trash_policy = crate::model::TrashPolicy::Immediate;
|
||
db.save_document_settings(&settings).unwrap();
|
||
db.add_item("Disposable item").unwrap();
|
||
let mut app = App::new_with_preferences(
|
||
db,
|
||
path,
|
||
AppPreferences::default(),
|
||
directory.path().join("preferences.toml"),
|
||
)
|
||
.unwrap();
|
||
|
||
app.handle_key(KeyEvent::new(KeyCode::F(5), KeyModifiers::NONE))
|
||
.unwrap();
|
||
app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE))
|
||
.unwrap();
|
||
app.handle_key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE))
|
||
.unwrap();
|
||
app.handle_key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL))
|
||
.unwrap();
|
||
assert_eq!(app.items[0].note, " x");
|
||
|
||
app.handle_key(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE))
|
||
.unwrap();
|
||
assert!(matches!(app.mode, Mode::Confirm { .. }));
|
||
app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))
|
||
.unwrap();
|
||
assert_eq!(app.items.len(), 1);
|
||
app.handle_key(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE))
|
||
.unwrap();
|
||
app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
|
||
.unwrap();
|
||
assert!(app.items.is_empty());
|
||
}
|
||
}
|