complete category-scoped values, rules, and report parity

- Add category-bound date and numeric values
- Complete rule execution and conflict handling
- Expand Markdown and HTML report output
This commit is contained in:
Chili Palmer
2026-08-20 21:01:02 +02:00
parent cb0e480b79
commit 16150b5a8f
19 changed files with 2519 additions and 419 deletions

371
src/ui.rs
View File

@@ -146,6 +146,7 @@ fn draw_items(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
area,
);
app.item_rows.clear();
app.item_columns.clear();
if app.items.is_empty() {
frame.render_widget(
Paragraph::new(if app.search.is_empty() {
@@ -162,32 +163,78 @@ fn draw_items(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
draw_datebook(frame, app, area);
return;
}
let mut columns = app.current_view().columns.clone();
if area.width < 70 && columns.len() > 2 {
let item = columns
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(|c| c.field == "item")
.find(|(_, column)| column.field == "item")
.cloned()
.unwrap_or_else(|| columns[0].clone());
let detail = columns
.unwrap_or_else(|| base_columns[0].clone());
let detail = base_columns
.iter()
.find(|c| c.field == "when")
.find(|(_, column)| column.field == "when")
.cloned()
.or_else(|| columns.iter().find(|c| c.field != item.field).cloned());
columns = vec![item];
.or_else(|| {
base_columns
.iter()
.find(|(_, column)| column.field != item.1.field)
.cloned()
});
base_columns = vec![item];
if let Some(detail) = detail {
columns.push(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));
display.push(DisplayLine::Item {
index,
section: (0..app.items.len()).collect(),
});
}
if columns.iter().any(|c| c.aggregate != Aggregate::None) {
if columns
.iter()
.any(|column| column.column.aggregate != Aggregate::None)
{
display.push(DisplayLine::Aggregate((0..app.items.len()).collect()));
}
} else {
@@ -209,16 +256,22 @@ fn draw_items(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
.map(|(i, _)| i)
.collect::<Vec<_>>();
for index in &indices {
display.push(DisplayLine::Item(*index));
display.push(DisplayLine::Item {
index: *index,
section: indices.clone(),
});
}
if columns.iter().any(|c| c.aggregate != Aggregate::None) {
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(i) if *i==app.selected))
.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 {
@@ -238,31 +291,37 @@ fn draw_items(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
.add_modifier(Modifier::BOLD),
))),
DisplayLine::Header => lines.push(cells_line(
columns.iter().map(|c| c.heading.clone()).collect(),
columns.iter().map(DisplayColumn::heading).collect(),
&widths,
Style::default()
.fg(colors.canvas_heading)
.bg(colors.canvas)
.add_modifier(Modifier::BOLD),
)),
DisplayLine::Item(index) => {
DisplayLine::Item { index, section } => {
let item = &app.items[*index];
let style = if *index == app.selected {
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)
} else {
Style::default()
.fg(if item.done_at.is_some() {
Color::DarkGray
} else {
colors.foreground
})
.bg(colors.canvas)
};
let cells = columns.iter().map(|c| column_value(c, item, app)).collect();
lines.push(cells_line(cells, &widths, style));
.add_modifier(Modifier::BOLD),
));
app.item_rows.push((
Rect::new(area.x, area.y + shown as u16, area.width, 1),
item.id,
@@ -271,7 +330,13 @@ fn draw_items(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
DisplayLine::Aggregate(indices) => {
let cells = columns
.iter()
.map(|c| aggregate_value(c, indices, &app.items, app))
.map(|column| {
if column.percent {
String::new()
} else {
aggregate_value(&column.column, indices, &app.items, app)
}
})
.collect();
lines.push(cells_line(
cells,
@@ -293,21 +358,42 @@ fn draw_items(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
enum DisplayLine {
Section(String),
Header,
Item(usize),
Item { index: usize, section: Vec<usize> },
Aggregate(Vec<usize>),
}
fn column_widths(columns: &[ViewColumn], total: u16) -> 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(|c| c.width.max(1) as u32)
.map(|column| column.width().max(1) as u32)
.sum::<u32>()
.max(1);
let mut widths = columns
.iter()
.map(|c| ((available as u32 * c.width.max(1) as u32 / weights).max(1)) as usize)
.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() {
@@ -315,6 +401,31 @@ fn column_widths(columns: &[ViewColumn], total: u16) -> Vec<usize> {
}
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()
@@ -336,7 +447,40 @@ fn fit(value: &str, width: usize) -> String {
}
out
}
fn column_value(column: &ViewColumn, item: &Item, app: &App) -> String {
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) {
@@ -361,9 +505,15 @@ fn column_value(column: &ViewColumn, item: &Item, app: &App) -> String {
" "
},
),
"value" => item
.numeric_value
.map(|v| format_number(v, app))
"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()),
@@ -374,31 +524,8 @@ fn column_value(column: &ViewColumn, item: &Item, app: &App) -> String {
}
}
fn aggregate_value(column: &ViewColumn, indices: &[usize], items: &[Item], app: &App) -> String {
if column.aggregate == Aggregate::None {
return String::new();
}
if column.aggregate == Aggregate::Count {
return format!("count {}", indices.len());
}
let values = indices
.iter()
.filter_map(|i| match column.field.as_str() {
"value" => items[*i].numeric_value,
"priority" => Some(items[*i].priority as f64),
_ => None,
})
.collect::<Vec<_>>();
if values.is_empty() {
return String::new();
}
let value = match column.aggregate {
Aggregate::Sum => values.iter().sum(),
Aggregate::Average => values.iter().sum::<f64>() / values.len() as f64,
Aggregate::Minimum => values.iter().copied().fold(f64::INFINITY, f64::min),
Aggregate::Maximum => values.iter().copied().fold(f64::NEG_INFINITY, f64::max),
Aggregate::None | Aggregate::Count => return String::new(),
};
format!("{} {}", column.aggregate, format_number(value, app))
let _ = app;
column.aggregate_value(indices.iter().map(|index| &items[*index]))
}
fn draw_datebook(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
@@ -460,44 +587,15 @@ fn formatted_when(app: &App, value: Option<&str>) -> String {
)
}
fn format_number(value: f64, app: &App) -> String {
let negative = value.is_sign_negative();
let raw = format!("{:.2}", value.abs());
let (integer, fraction) = raw.split_once('.').unwrap_or((&raw, "00"));
let mut grouped = String::new();
for (index, c) in integer.chars().rev().enumerate() {
if index > 0 && index % 3 == 0 {
grouped.push_str(&app.preferences.thousands_separator);
}
grouped.push(c);
}
let integer = grouped.chars().rev().collect::<String>();
format!(
"{}{}{}{}",
if negative { "-" } else { "" },
integer,
app.preferences.decimal_separator,
fraction
)
}
fn draw_status(frame: &mut Frame<'_>, app: &App, area: Rect) {
let colors = palette(&app.preferences.theme);
let totals = app
.items
.iter()
.filter_map(|i| i.numeric_value)
.collect::<Vec<_>>();
let aggregate = if totals.is_empty() {
String::new()
} else {
format!(
" Σ {} avg {}",
format_number(totals.iter().sum::<f64>(), app),
format_number(totals.iter().sum::<f64>() / totals.len() as f64, app)
)
};
let right = format!("{} item(s){}", app.items.len(), aggregate);
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)
@@ -631,7 +729,6 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &mut App) {
"Priority (1 highest)",
"When (phrase or RFC3339)",
"Alarm (phrase or RFC3339)",
"Numeric value",
"Recurrence (daily/weekly/monthly/every N days)",
];
let lines = names
@@ -670,7 +767,7 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &mut App) {
.collect::<Vec<_>>();
popup(
frame,
center(frame.area(), 78, 18),
center(frame.area(), 78, 16),
"Item Properties",
Paragraph::new(lines),
colors,
@@ -689,7 +786,7 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &mut App) {
"Rule condition",
"Rule action",
],
"Rule: category=Work and priority<=2 → assign:Important",
"Condition files live; action fires on entry (numeric:Hours=7.5)",
),
FormKind::View(_) => (
"Live View Definition",
@@ -700,7 +797,7 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &mut App) {
"Boolean filter",
"Sort key",
"Show done (yes/no)",
"Columns (field:width:heading:aggregate, …)",
"Columns (numeric[Hours]:width:heading:aggregate:…)",
"Sections (heading|filter; …)",
],
"Filters: category=Work and (open or priority<=2)",
@@ -737,6 +834,7 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &mut App) {
"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.",
),
@@ -927,7 +1025,7 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &mut App) {
Mode::Menu => {
popup(
frame,
center(frame.area(), 58, 15),
center(frame.area(), 64, 17),
"Rogue Agenda Menu",
Paragraph::new(vec![
Line::from(" n New item"),
@@ -937,6 +1035,8 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &mut App) {
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"),
@@ -947,6 +1047,61 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &mut App) {
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,
@@ -1346,7 +1501,7 @@ mod tests {
"priority",
true,
"open",
"item:70:Action:none,value:30:Cost:sum",
"item:70:Action:none,value:30:Cost:sum::2:.:comma:minus:percent:right",
"Urgent work|priority=1",
)
.unwrap();
@@ -1372,7 +1527,8 @@ mod tests {
assert!(screen.contains("Urgent work"));
assert!(screen.contains("Action"));
assert!(screen.contains("Cost"));
assert!(screen.contains("sum 75.00"));
assert!(screen.contains("Total 75.00"));
assert!(screen.contains("100.00%"));
}
#[test]
@@ -1527,7 +1683,6 @@ mod tests {
.collect::<String>();
assert!(screen.contains("> Configured display item"));
assert!(screen.contains("08/17/2026 3:30pm"));
assert!(screen.contains("1.234,50"));
assert!(!screen.contains("Cat Mgr"));
app.handle_key(KeyEvent::new(KeyCode::F(10), KeyModifiers::NONE))