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::>(); 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::>(); 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 = 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::>(); 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 }, Aggregate(Vec), } #[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 { 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::() .max(1); let mut widths = columns .iter() .map(|column| ((available as u32 * column.width().max(1) as u32 / weights).max(1)) as usize) .collect::>(); let used = widths.iter().sum::(); if let Some(first) = widths.first_mut() { *first += available as usize - used; } widths } fn cells_line_selected( values: Vec, columns: &[DisplayColumn], widths: &[usize], style: Style, selected: Option, 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, widths: &[usize], style: Style) -> Line<'static> { let text = values .into_iter() .zip(widths) .map(|(v, w)| fit(&v, *w)) .collect::>() .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::(); 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::(); 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 = vec![]; let mut dates: HashMap = 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::>() .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::>(); 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::>(); 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::>(); 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::>(); 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::>(); 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::>(); 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::>(); 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::>(); 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::().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 = 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::>(); 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::(); 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::(); 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::(); 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::()) .collect::>(); 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::(); 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::(); 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::>(), ["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()); } }