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}, filter, model::{Item, ViewColumn}, parser::format_when, }; #[derive(Clone, Copy)] struct Palette { primary: Color, accent: Color, canvas: Color, canvas_heading: Color, selection: Color, selection_foreground: Color, foreground: Color, } fn palette(theme: &str) -> Palette { match theme { "mono" => Palette { primary: Color::Black, accent: Color::White, canvas: Color::Gray, canvas_heading: Color::Black, selection: Color::DarkGray, selection_foreground: rgb(0xffffff), foreground: Color::Black, }, "amber" => Palette { primary: Color::Black, accent: Color::Rgb(255, 191, 0), canvas: Color::Rgb(32, 24, 0), canvas_heading: Color::Rgb(255, 191, 0), selection: Color::Rgb(112, 56, 0), selection_foreground: rgb(0xffffff), foreground: Color::Rgb(255, 191, 0), }, "greenscreen" => Palette { primary: Color::Black, accent: rgb(0x79a86b), canvas: rgb(0x030704), canvas_heading: rgb(0x9ac58d), selection: rgb(0x284b2f), selection_foreground: rgb(0xdce8d8), foreground: rgb(0x8fbd80), }, "nord" => Palette { primary: rgb(0x2e3440), accent: rgb(0x88c0d0), canvas: rgb(0x3b4252), canvas_heading: rgb(0xeceff4), selection: rgb(0xeceff4), selection_foreground: rgb(0x2e3440), foreground: rgb(0xeceff4), }, "catppuccin-mocha" => Palette { primary: rgb(0x181825), accent: rgb(0xcba6f7), canvas: rgb(0x1e1e2e), canvas_heading: rgb(0xcdd6f4), selection: rgb(0xcba6f7), selection_foreground: rgb(0x11111b), foreground: rgb(0xcdd6f4), }, _ => Palette { primary: Color::Rgb(0, 0, 128), accent: Color::Cyan, canvas: Color::Rgb(192, 192, 192), canvas_heading: Color::Rgb(0, 0, 128), selection: Color::Rgb(128, 0, 0), selection_foreground: rgb(0xffffff), foreground: Color::Black, }, } } const fn rgb(value: u32) -> Color { Color::Rgb( ((value >> 16) & 0xff) as u8, ((value >> 8) & 0xff) as u8, (value & 0xff) as u8, ) } 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(); 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 == "datebook" { draw_datebook(frame, app, area); return; } let mut columns = app.current_view().columns.clone(); if area.width < 70 && columns.len() > 2 { let item = columns .iter() .find(|c| c.field == "item") .cloned() .unwrap_or_else(|| columns[0].clone()); let detail = columns .iter() .find(|c| c.field == "when") .cloned() .or_else(|| columns.iter().find(|c| c.field != item.field).cloned()); columns = vec![item]; if let Some(detail) = detail { columns.push(detail) } } let widths = column_widths(&columns, area.width); 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)); } if columns.iter().any(|c| c.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)); } if columns.iter().any(|c| c.aggregate != "none") { display.push(DisplayLine::Aggregate(indices)); } } } let selected_line = display .iter() .position(|line| matches!(line,DisplayLine::Item(i) if *i==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(|c| c.heading.clone()).collect(), &widths, Style::default() .fg(colors.canvas_heading) .bg(colors.canvas) .add_modifier(Modifier::BOLD), )), DisplayLine::Item(index) => { let item = &app.items[*index]; let style = if *index == app.selected { 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)); 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(|c| aggregate_value(c, 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(usize), Aggregate(Vec), } fn column_widths(columns: &[ViewColumn], 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(|c| c.width.max(1) as u32) .sum::() .max(1); let mut widths = columns .iter() .map(|c| ((available as u32 * c.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(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 column_value(column: &ViewColumn, item: &Item, app: &App) -> String { 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" => item .numeric_value .map(|v| format_number(v, app)) .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 { if column.aggregate == "none" { return String::new(); } if column.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::>(); if values.is_empty() { return String::new(); } let value = match column.aggregate.as_str() { "sum" => values.iter().sum(), "avg" => values.iter().sum::() / values.len() as f64, "min" => values.iter().copied().fold(f64::INFINITY, f64::min), "max" => values.iter().copied().fold(f64::NEG_INFINITY, f64::max), _ => return String::new(), }; format!("{} {}", column.aggregate, format_number(value, app)) } 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 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::(); 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::>(); let aggregate = if totals.is_empty() { String::new() } else { format!( " Σ {} avg {}", format_number(totals.iter().sum::(), app), format_number(totals.iter().sum::() / totals.len() as f64, app) ) }; let right = format!("{} item(s){}", app.items.len(), aggregate); 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)", "Numeric value", "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, 18), "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", ], "Rule: category=Work and priority<=2 → assign:Important", ), FormKind::View(_) => ( "Live View Definition", vec![ "Name", "Kind", "Filter value", "Boolean filter", "Sort key", "Show done (yes/no)", "Columns (field: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)", ], "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(), 58, 15), "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(" 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::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", "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("sum 75.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("1.234,50")); 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 = "immediate".into(); 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()); } }