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:
371
src/ui.rs
371
src/ui.rs
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user