working prototype

This commit is contained in:
Hermes Agent
2026-08-16 19:23:59 +00:00
commit ba530a7271
13 changed files with 6787 additions and 0 deletions

812
src/app.rs Normal file
View File

@@ -0,0 +1,812 @@
use std::{collections::HashSet, path::PathBuf};
use anyhow::Result;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
use ratatui::layout::Rect;
use crate::{
db::Database,
model::{Category, Item, ItemChanges, ViewDef},
parser::extract_when,
};
#[derive(Debug, Clone)]
pub enum InputKind {
NewItem,
EditItem(i64),
Note(i64),
Search,
}
#[derive(Debug, Clone)]
pub struct InputState {
pub kind: InputKind,
pub title: String,
pub value: String,
pub multiline: bool,
}
#[derive(Debug, Clone)]
pub struct PropsState {
pub item_id: i64,
pub values: [String; 5],
pub field: usize,
}
#[derive(Debug, Clone)]
pub enum FormKind {
Category(Option<i64>),
View(Option<i64>),
}
#[derive(Debug, Clone)]
pub struct FormState {
pub kind: FormKind,
pub values: Vec<String>,
pub field: usize,
}
#[derive(Debug, Clone)]
pub enum Mode {
Normal,
Help,
Input(InputState),
Properties(PropsState),
Form(FormState),
Dependencies {
item_id: i64,
choices: Vec<Item>,
selected: usize,
},
Categories,
Views,
CategoryManager,
Menu,
}
pub struct App {
pub db: Database,
pub path: PathBuf,
pub views: Vec<ViewDef>,
pub view_index: usize,
pub items: Vec<Item>,
pub selected: usize,
pub scroll: usize,
pub search: String,
pub marked: HashSet<i64>,
pub categories: Vec<Category>,
pub category_selected: usize,
pub view_selected: usize,
pub mode: Mode,
pub status: String,
pub should_quit: bool,
pub item_rows: Vec<(Rect, i64)>,
pub command_regions: Vec<Rect>,
pub view_regions: Vec<Rect>,
}
impl App {
pub fn new(db: Database, path: PathBuf) -> Result<Self> {
let views = db.views()?;
let categories = db.categories()?;
let mut app = Self {
db,
path,
views,
view_index: 0,
items: vec![],
selected: 0,
scroll: 0,
search: String::new(),
marked: HashSet::new(),
categories,
category_selected: 0,
view_selected: 0,
mode: Mode::Normal,
status: "Ready — capture first, organize later".into(),
should_quit: false,
item_rows: vec![],
command_regions: vec![],
view_regions: vec![],
};
app.refresh()?;
Ok(app)
}
pub fn current_view(&self) -> &ViewDef {
&self.views[self.view_index]
}
pub fn selected_item(&self) -> Option<&Item> {
self.items.get(self.selected)
}
pub fn refresh(&mut self) -> Result<()> {
let keep = self.selected_item().map(|i| i.id);
self.views = self.db.views()?;
self.categories = self.db.categories()?;
self.view_index = self.view_index.min(self.views.len().saturating_sub(1));
self.items = self.db.items(&self.views[self.view_index], &self.search)?;
self.selected = keep
.and_then(|id| self.items.iter().position(|i| i.id == id))
.unwrap_or(self.selected.min(self.items.len().saturating_sub(1)));
if self.selected < self.scroll {
self.scroll = self.selected;
}
Ok(())
}
fn chosen_ids(&self) -> Vec<i64> {
if self.marked.is_empty() {
self.selected_item().map(|i| vec![i.id]).unwrap_or_default()
} else {
self.marked.iter().copied().collect()
}
}
pub fn handle_key(&mut self, key: KeyEvent) -> Result<()> {
match self.mode.clone() {
Mode::Normal => self.handle_normal(key),
Mode::Help => {
self.mode = Mode::Normal;
Ok(())
}
Mode::Input(_) => self.handle_input(key),
Mode::Properties(_) => self.handle_properties(key),
Mode::Form(_) => self.handle_form(key),
Mode::Dependencies { .. } => self.handle_dependencies(key),
Mode::Categories => self.handle_categories(key),
Mode::Views => self.handle_views(key),
Mode::CategoryManager => self.handle_category_manager(key),
Mode::Menu => self.handle_menu(key),
}
}
fn handle_normal(&mut self, key: KeyEvent) -> Result<()> {
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('s') {
self.db.checkpoint()?;
self.status = "Saved and checkpointed".into();
return Ok(());
}
match key.code {
KeyCode::Char('q') => self.should_quit = true,
KeyCode::Up | KeyCode::Char('k') => self.move_selection(-1),
KeyCode::Down | KeyCode::Char('j') => self.move_selection(1),
KeyCode::PageUp => self.move_selection(-10),
KeyCode::PageDown => self.move_selection(10),
KeyCode::Home => self.selected = 0,
KeyCode::End => self.selected = self.items.len().saturating_sub(1),
KeyCode::Insert | KeyCode::Char('n') => {
self.open_input(InputKind::NewItem, "New item", String::new(), false)
}
KeyCode::F(2) | KeyCode::Char('e') | KeyCode::Enter => self.edit_selected(),
KeyCode::F(3) | KeyCode::Char('c') => {
if self.selected_item().is_some() {
self.category_selected = 0;
self.mode = Mode::Categories;
}
}
KeyCode::F(4) | KeyCode::Char('d') => self.toggle_done()?,
KeyCode::F(5) => self.edit_note(),
KeyCode::F(6) | KeyCode::Char('p') => self.edit_properties(),
KeyCode::F(7) | KeyCode::Char(' ') => self.toggle_mark(),
KeyCode::F(8) | KeyCode::Char('v') => {
self.view_selected = self.view_index;
self.mode = Mode::Views;
}
KeyCode::F(9) => {
self.category_selected = 0;
self.mode = Mode::CategoryManager;
}
KeyCode::F(10) | KeyCode::Char('m') => self.mode = Mode::Menu,
KeyCode::F(1) | KeyCode::Char('?') => self.mode = Mode::Help,
KeyCode::Char('r') => self.open_dependencies()?,
KeyCode::Char('/') => self.open_input(
InputKind::Search,
"Search current view",
self.search.clone(),
false,
),
KeyCode::Char(']') => self.switch_view(1)?,
KeyCode::Char('[') => self.switch_view(-1)?,
KeyCode::Delete => self.discard_or_restore()?,
KeyCode::Esc if !self.search.is_empty() => {
self.search.clear();
self.refresh()?;
self.status = "Search cleared".into();
}
_ => {}
}
Ok(())
}
fn move_selection(&mut self, delta: isize) {
if self.items.is_empty() {
return;
}
self.selected =
(self.selected as isize + delta).clamp(0, self.items.len() as isize - 1) as usize;
}
fn switch_view(&mut self, delta: isize) -> Result<()> {
let len = self.views.len() as isize;
self.view_index = (self.view_index as isize + delta).rem_euclid(len) as usize;
self.selected = 0;
self.scroll = 0;
self.search.clear();
self.refresh()?;
self.status = format!("View: {}", self.current_view().name);
Ok(())
}
fn open_input(&mut self, kind: InputKind, title: &str, value: String, multiline: bool) {
self.mode = Mode::Input(InputState {
kind,
title: title.into(),
value,
multiline,
});
}
fn edit_selected(&mut self) {
if let Some(i) = self.selected_item() {
self.open_input(
InputKind::EditItem(i.id),
"Edit item",
i.text.clone(),
false,
);
}
}
fn edit_note(&mut self) {
if let Some(i) = self.selected_item() {
self.open_input(
InputKind::Note(i.id),
"Item note (Ctrl-S saves; Enter adds a line)",
i.note.clone(),
true,
);
}
}
fn edit_properties(&mut self) {
if let Some(i) = self.selected_item() {
self.mode = Mode::Properties(PropsState {
item_id: i.id,
values: [
i.priority.to_string(),
i.when_at.clone().unwrap_or_default(),
i.alarm_at.clone().unwrap_or_default(),
i.numeric_value.map(|v| v.to_string()).unwrap_or_default(),
i.recurrence.clone(),
],
field: 0,
});
}
}
fn toggle_mark(&mut self) {
if let Some(id) = self.selected_item().map(|i| i.id) {
if !self.marked.remove(&id) {
self.marked.insert(id);
}
self.status = format!("{} item(s) marked", self.marked.len());
}
}
fn toggle_done(&mut self) -> Result<()> {
let ids = self.chosen_ids();
if !ids.is_empty() {
self.db.toggle_done(&ids)?;
self.marked.clear();
self.refresh()?;
self.status = format!("Updated {} item(s)", ids.len());
}
Ok(())
}
fn discard_or_restore(&mut self) -> Result<()> {
let ids = self.chosen_ids();
if ids.is_empty() {
return Ok(());
}
let restore = self.current_view().kind == "trash";
self.db.discard(&ids, !restore)?;
self.marked.clear();
self.refresh()?;
self.status = if restore {
format!("Restored {} item(s)", ids.len())
} else {
format!("Moved {} item(s) to Trash", ids.len())
};
Ok(())
}
fn handle_input(&mut self, key: KeyEvent) -> Result<()> {
let Mode::Input(mut input) = self.mode.clone() else {
return Ok(());
};
if key.code == KeyCode::Esc {
self.mode = Mode::Normal;
self.status = "Canceled".into();
return Ok(());
}
if input.multiline
&& key.modifiers.contains(KeyModifiers::CONTROL)
&& key.code == KeyCode::Char('s')
{
return self.accept_input(input);
}
match key.code {
KeyCode::Enter if input.multiline => input.value.push('\n'),
KeyCode::Enter => return self.accept_input(input),
KeyCode::Backspace => {
input.value.pop();
}
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
input.value.push(c)
}
_ => {}
}
self.mode = Mode::Input(input);
Ok(())
}
fn accept_input(&mut self, input: InputState) -> Result<()> {
match input.kind {
InputKind::NewItem => {
self.db.add_item(&input.value)?;
self.status = "Item captured and automatically filed".into();
}
InputKind::EditItem(id) => {
self.db.update_item(
id,
&ItemChanges {
text: Some(input.value),
..Default::default()
},
)?;
self.status = "Item updated and filing re-evaluated".into();
}
InputKind::Note(id) => {
self.db.update_item(
id,
&ItemChanges {
note: Some(input.value),
..Default::default()
},
)?;
self.status = "Note saved".into();
}
InputKind::Search => {
self.search = input.value;
self.status = if self.search.is_empty() {
"Search cleared".into()
} else {
format!("Filtering for “{}", self.search)
};
}
}
self.mode = Mode::Normal;
self.refresh()?;
Ok(())
}
fn handle_properties(&mut self, key: KeyEvent) -> Result<()> {
let Mode::Properties(mut props) = self.mode.clone() else {
return Ok(());
};
match key.code {
KeyCode::Esc => {
self.mode = Mode::Normal;
return Ok(());
}
KeyCode::Tab | KeyCode::Down => props.field = (props.field + 1) % 5,
KeyCode::BackTab | KeyCode::Up => props.field = (props.field + 4) % 5,
KeyCode::Backspace => {
props.values[props.field].pop();
}
KeyCode::Enter => {
let priority = props.values[0].parse().unwrap_or(3);
let when_at = normalize_date(&props.values[1]);
let alarm_at = normalize_date(&props.values[2]);
let numeric_value = if props.values[3].trim().is_empty() {
None
} else {
props.values[3].parse().ok()
};
self.db.update_item(
props.item_id,
&ItemChanges {
priority: Some(priority),
when_at: Some(when_at),
alarm_at: Some(alarm_at),
numeric_value: Some(numeric_value),
recurrence: Some(props.values[4].clone()),
..Default::default()
},
)?;
self.mode = Mode::Normal;
self.refresh()?;
self.status = "Properties saved".into();
return Ok(());
}
KeyCode::Char(c) => props.values[props.field].push(c),
_ => {}
}
self.mode = Mode::Properties(props);
Ok(())
}
fn handle_categories(&mut self, key: KeyEvent) -> Result<()> {
match key.code {
KeyCode::Esc | KeyCode::Enter => self.mode = Mode::Normal,
KeyCode::Up | KeyCode::Char('k') => {
self.category_selected = self.category_selected.saturating_sub(1)
}
KeyCode::Down | KeyCode::Char('j') => {
self.category_selected =
(self.category_selected + 1).min(self.categories.len().saturating_sub(1))
}
KeyCode::Char(' ') => {
if let (Some(item), Some(cat)) = (
self.selected_item(),
self.categories.get(self.category_selected),
) {
self.db.toggle_assignment(item.id, cat.id)?;
self.refresh()?;
self.status = "Category assignment changed".into();
}
}
_ => {}
}
Ok(())
}
fn handle_views(&mut self, key: KeyEvent) -> Result<()> {
match key.code {
KeyCode::Esc => self.mode = Mode::Normal,
KeyCode::Up if key.modifiers.contains(KeyModifiers::CONTROL) => {
if let Some(view) = self.views.get(self.view_selected) {
let id = view.id;
self.db.move_view(id, -1)?;
self.refresh()?;
self.view_selected = self.views.iter().position(|v| v.id == id).unwrap_or(0);
self.status = "View moved up".into();
}
}
KeyCode::Down if key.modifiers.contains(KeyModifiers::CONTROL) => {
if let Some(view) = self.views.get(self.view_selected) {
let id = view.id;
self.db.move_view(id, 1)?;
self.refresh()?;
self.view_selected = self.views.iter().position(|v| v.id == id).unwrap_or(0);
self.status = "View moved down".into();
}
}
KeyCode::Up | KeyCode::Char('k') => {
self.view_selected = self.view_selected.saturating_sub(1)
}
KeyCode::Down | KeyCode::Char('j') => {
self.view_selected =
(self.view_selected + 1).min(self.views.len().saturating_sub(1))
}
KeyCode::Enter => {
self.view_index = self.view_selected;
self.selected = 0;
self.scroll = 0;
self.search.clear();
self.mode = Mode::Normal;
self.refresh()?;
self.status = format!("View: {}", self.current_view().name);
}
KeyCode::Char('n') | KeyCode::Insert => self.open_view_form(None),
KeyCode::Char('e') | KeyCode::F(2) => {
if let Some(view) = self.views.get(self.view_selected).cloned() {
self.open_view_form(Some(view));
}
}
KeyCode::Delete => {
if let Some(view) = self.views.get(self.view_selected) {
self.db.delete_view(view.id)?;
self.view_index = 0;
self.view_selected = 0;
self.refresh()?;
self.status = "View deleted".into();
}
}
_ => {}
}
Ok(())
}
fn handle_category_manager(&mut self, key: KeyEvent) -> Result<()> {
match key.code {
KeyCode::Esc => self.mode = Mode::Normal,
KeyCode::Up | KeyCode::Char('k') => {
self.category_selected = self.category_selected.saturating_sub(1)
}
KeyCode::Down | KeyCode::Char('j') => {
self.category_selected =
(self.category_selected + 1).min(self.categories.len().saturating_sub(1))
}
KeyCode::Char('n') | KeyCode::Insert => self.open_category_form(None),
KeyCode::Char('e') | KeyCode::Enter | KeyCode::F(2) => {
if let Some(category) = self.categories.get(self.category_selected).cloned() {
self.open_category_form(Some(category));
}
}
KeyCode::Delete => {
if let Some(category) = self.categories.get(self.category_selected) {
self.db.delete_category(category.id)?;
self.category_selected = self.category_selected.saturating_sub(1);
self.refresh()?;
self.status = "Category deleted; its items were preserved".into();
}
}
_ => {}
}
Ok(())
}
fn handle_menu(&mut self, key: KeyEvent) -> Result<()> {
self.mode = Mode::Normal;
match key.code {
KeyCode::Char('n') => {
self.open_input(InputKind::NewItem, "New item", String::new(), false)
}
KeyCode::Char('s') => {
self.db.checkpoint()?;
self.status = "Saved and checkpointed".into();
}
KeyCode::Char('h') => self.mode = Mode::Help,
KeyCode::Char('r') => self.open_dependencies()?,
KeyCode::Char('q') => self.should_quit = true,
_ => {}
}
Ok(())
}
fn open_category_form(&mut self, category: Option<Category>) {
let id = category.as_ref().map(|c| c.id);
let rule =
id.and_then(|category_id| self.db.category_rules(category_id).ok()?.into_iter().next());
let condition = rule
.as_ref()
.map(|r| r.condition_expr.clone())
.unwrap_or_default();
let action = rule
.map(|r| format!("{}:{}", r.action_kind, r.action_value))
.unwrap_or_default();
let values = if let Some(c) = category {
let parent = c
.parent_id
.and_then(|id| self.categories.iter().find(|p| p.id == id))
.map(|p| p.name.clone())
.unwrap_or_default();
vec![
c.name,
parent,
c.kind,
c.match_text,
yes_no(c.exclusive),
condition,
action,
]
} else {
vec![
String::new(),
String::new(),
"standard".into(),
String::new(),
"no".into(),
String::new(),
String::new(),
]
};
self.mode = Mode::Form(FormState {
kind: FormKind::Category(id),
values,
field: 0,
});
}
fn open_view_form(&mut self, view: Option<ViewDef>) {
let id = view.as_ref().map(|v| v.id);
let values = if let Some(v) = view {
let columns = v.columns_spec();
let sections = v.sections_spec();
vec![
v.name,
v.kind,
v.filter_value,
v.filter_expr,
v.sort_key,
yes_no(v.show_done),
columns,
sections,
]
} else {
vec![
String::new(),
"list".into(),
String::new(),
String::new(),
"manual".into(),
"yes".into(),
"item:48:Items:none,categories:27:Categories:none,when:20:When:none,priority:5:P:none".into(),
String::new(),
]
};
self.mode = Mode::Form(FormState {
kind: FormKind::View(id),
values,
field: 0,
});
}
fn handle_form(&mut self, key: KeyEvent) -> Result<()> {
let Mode::Form(mut form) = self.mode.clone() else {
return Ok(());
};
match key.code {
KeyCode::Esc => {
self.mode = match form.kind {
FormKind::Category(_) => Mode::CategoryManager,
FormKind::View(_) => Mode::Views,
};
return Ok(());
}
KeyCode::Tab | KeyCode::Down => form.field = (form.field + 1) % form.values.len(),
KeyCode::BackTab | KeyCode::Up => {
form.field = (form.field + form.values.len() - 1) % form.values.len()
}
KeyCode::Backspace => {
form.values[form.field].pop();
}
KeyCode::Enter => {
match form.kind {
FormKind::Category(id) => {
let parent_id = if form.values[1].trim().is_empty() {
None
} else {
self.categories
.iter()
.find(|c| c.name.eq_ignore_ascii_case(form.values[1].trim()))
.map(|c| c.id)
};
let category_id = self.db.save_category(
id,
&form.values[0],
parent_id,
form.values[2].trim(),
&form.values[3],
parse_yes(&form.values[4]),
)?;
self.db
.save_primary_rule(category_id, &form.values[5], &form.values[6])?;
self.db.reapply_automatic_assignments()?;
self.refresh()?;
self.mode = Mode::CategoryManager;
self.status = "Category definition saved".into();
}
FormKind::View(id) => {
let saved = self.db.save_view_design(
id,
&form.values[0],
form.values[1].trim(),
&form.values[2],
form.values[4].trim(),
parse_yes(&form.values[5]),
&form.values[3],
&form.values[6],
&form.values[7],
)?;
self.refresh()?;
self.view_selected =
self.views.iter().position(|v| v.id == saved).unwrap_or(0);
self.mode = Mode::Views;
self.status = "Live view saved".into();
}
}
return Ok(());
}
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
form.values[form.field].push(c)
}
_ => {}
}
self.mode = Mode::Form(form);
Ok(())
}
fn open_dependencies(&mut self) -> Result<()> {
let Some(item_id) = self.selected_item().map(|i| i.id) else {
return Ok(());
};
let Some(all) = self.views.iter().find(|v| v.kind == "list") else {
return Ok(());
};
let choices = self
.db
.items(all, "")?
.into_iter()
.filter(|i| i.id != item_id)
.collect();
self.mode = Mode::Dependencies {
item_id,
choices,
selected: 0,
};
Ok(())
}
fn handle_dependencies(&mut self, key: KeyEvent) -> Result<()> {
let Mode::Dependencies {
item_id,
choices,
mut selected,
} = self.mode.clone()
else {
return Ok(());
};
match key.code {
KeyCode::Esc | KeyCode::Enter => {
self.mode = Mode::Normal;
return Ok(());
}
KeyCode::Up | KeyCode::Char('k') => selected = selected.saturating_sub(1),
KeyCode::Down | KeyCode::Char('j') => {
selected = (selected + 1).min(choices.len().saturating_sub(1))
}
KeyCode::Char(' ') => {
if let Some(choice) = choices.get(selected) {
self.db.toggle_dependency(item_id, choice.id)?;
self.status = "Prerequisite changed".into();
}
}
_ => {}
}
self.mode = Mode::Dependencies {
item_id,
choices,
selected,
};
Ok(())
}
pub fn handle_mouse(&mut self, event: MouseEvent) -> Result<()> {
if !matches!(event.kind, MouseEventKind::Down(MouseButton::Left)) {
return Ok(());
}
let (x, y) = (event.column, event.row);
if let Some((_, id)) = self.item_rows.iter().find(|(r, _)| inside(*r, x, y)) {
if let Some(pos) = self.items.iter().position(|i| i.id == *id) {
self.selected = pos;
}
return Ok(());
}
if let Some(i) = self.command_regions.iter().position(|r| inside(*r, x, y)) {
return self.handle_key(KeyEvent::new(KeyCode::F((i + 1) as u8), KeyModifiers::NONE));
}
if let Some(i) = self.view_regions.iter().position(|r| inside(*r, x, y)) {
self.view_index = i;
self.selected = 0;
self.scroll = 0;
self.search.clear();
self.refresh()?;
}
Ok(())
}
}
fn yes_no(value: bool) -> String {
if value { "yes" } else { "no" }.into()
}
fn parse_yes(value: &str) -> bool {
matches!(
value.trim().to_lowercase().as_str(),
"y" | "yes" | "true" | "1" | "on"
)
}
fn normalize_date(value: &str) -> Option<String> {
let s = value.trim();
if s.is_empty() {
None
} else if chrono::DateTime::parse_from_rfc3339(s).is_ok() {
Some(s.into())
} else {
extract_when(&format!("on {s}"))
}
}
fn inside(r: Rect, x: u16, y: u16) -> bool {
x >= r.x && x < r.x + r.width && y >= r.y && y < r.y + r.height
}

1913
src/db.rs Normal file

File diff suppressed because it is too large Load Diff

390
src/filter.rs Normal file
View File

@@ -0,0 +1,390 @@
use anyhow::{Result, bail};
use chrono::{DateTime, Duration, Local};
use crate::model::Item;
#[derive(Debug, Clone, PartialEq)]
pub enum Expr {
True,
Predicate(Predicate),
Not(Box<Expr>),
And(Box<Expr>, Box<Expr>),
Or(Box<Expr>, Box<Expr>),
}
#[derive(Debug, Clone, PartialEq)]
pub enum Predicate {
TextContains(String),
NoteContains(String),
Category(String),
Priority(Compare, i64),
Value(Compare, f64),
Done(bool),
HasNote,
HasDate(bool),
DueWithin(i64),
Overdue,
Alarm,
Recurring(bool),
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Compare {
Eq,
Ne,
Lt,
Le,
Gt,
Ge,
}
pub fn parse(source: &str) -> Result<Expr> {
if source.trim().is_empty() {
return Ok(Expr::True);
}
let tokens = tokenize(source)?;
let mut parser = Parser { tokens, at: 0 };
let expr = parser.parse_or()?;
if parser.at != parser.tokens.len() {
bail!("unexpected token {:?}", parser.tokens[parser.at]);
}
Ok(expr)
}
pub fn matches(source: &str, item: &Item) -> Result<bool> {
Ok(evaluate(&parse(source)?, item))
}
pub fn evaluate(expr: &Expr, item: &Item) -> bool {
match expr {
Expr::True => true,
Expr::Predicate(p) => predicate(p, item),
Expr::Not(inner) => !evaluate(inner, item),
Expr::And(a, b) => evaluate(a, item) && evaluate(b, item),
Expr::Or(a, b) => evaluate(a, item) || evaluate(b, item),
}
}
fn predicate(p: &Predicate, item: &Item) -> bool {
match p {
Predicate::TextContains(s) => item.text.to_lowercase().contains(&s.to_lowercase()),
Predicate::NoteContains(s) => item.note.to_lowercase().contains(&s.to_lowercase()),
Predicate::Category(s) => item
.categories
.iter()
.any(|c| c.name.eq_ignore_ascii_case(s)),
Predicate::Priority(op, n) => compare_i64(item.priority, *op, *n),
Predicate::Value(op, n) => item.numeric_value.is_some_and(|v| compare_f64(v, *op, *n)),
Predicate::Done(expected) => item.done_at.is_some() == *expected,
Predicate::HasNote => !item.note.trim().is_empty(),
Predicate::HasDate(expected) => item.when_at.is_some() == *expected,
Predicate::DueWithin(days) => item
.when_at
.as_deref()
.and_then(parse_date)
.is_some_and(|d| d >= Local::now() && d <= Local::now() + Duration::days(*days)),
Predicate::Overdue => {
item.done_at.is_none()
&& item
.when_at
.as_deref()
.and_then(parse_date)
.is_some_and(|d| d < Local::now())
}
Predicate::Alarm => item.alarm_at.is_some(),
Predicate::Recurring(expected) => !item.recurrence.is_empty() == *expected,
}
}
fn parse_date(value: &str) -> Option<DateTime<Local>> {
DateTime::parse_from_rfc3339(value)
.ok()
.map(|d| d.with_timezone(&Local))
}
fn compare_i64(a: i64, op: Compare, b: i64) -> bool {
match op {
Compare::Eq => a == b,
Compare::Ne => a != b,
Compare::Lt => a < b,
Compare::Le => a <= b,
Compare::Gt => a > b,
Compare::Ge => a >= b,
}
}
fn compare_f64(a: f64, op: Compare, b: f64) -> bool {
match op {
Compare::Eq => (a - b).abs() < f64::EPSILON,
Compare::Ne => (a - b).abs() >= f64::EPSILON,
Compare::Lt => a < b,
Compare::Le => a <= b,
Compare::Gt => a > b,
Compare::Ge => a >= b,
}
}
#[derive(Debug, Clone, PartialEq)]
enum Token {
Word(String),
String(String),
And,
Or,
Not,
LParen,
RParen,
Op(Compare),
}
fn tokenize(source: &str) -> Result<Vec<Token>> {
let chars: Vec<char> = source.chars().collect();
let mut out = vec![];
let mut i = 0;
while i < chars.len() {
if chars[i].is_whitespace() {
i += 1;
continue;
}
match chars[i] {
'(' => {
out.push(Token::LParen);
i += 1
}
')' => {
out.push(Token::RParen);
i += 1
}
'"' | '\'' => {
let quote = chars[i];
i += 1;
let mut s = String::new();
while i < chars.len() && chars[i] != quote {
if chars[i] == '\\' && i + 1 < chars.len() {
i += 1;
}
s.push(chars[i]);
i += 1;
}
if i == chars.len() {
bail!("unterminated quoted string")
}
i += 1;
out.push(Token::String(s));
}
'&' if chars.get(i + 1) == Some(&'&') => {
out.push(Token::And);
i += 2
}
'|' if chars.get(i + 1) == Some(&'|') => {
out.push(Token::Or);
i += 2
}
'!' if chars.get(i + 1) == Some(&'=') => {
out.push(Token::Op(Compare::Ne));
i += 2
}
'!' => {
out.push(Token::Not);
i += 1
}
'<' if chars.get(i + 1) == Some(&'=') => {
out.push(Token::Op(Compare::Le));
i += 2
}
'>' if chars.get(i + 1) == Some(&'=') => {
out.push(Token::Op(Compare::Ge));
i += 2
}
'<' => {
out.push(Token::Op(Compare::Lt));
i += 1
}
'>' => {
out.push(Token::Op(Compare::Gt));
i += 1
}
'=' => {
out.push(Token::Op(Compare::Eq));
i += 1
}
'~' => {
out.push(Token::Op(Compare::Eq));
i += 1
}
_ => {
let start = i;
while i < chars.len()
&& !chars[i].is_whitespace()
&& !"()=!<>~&|".contains(chars[i])
{
i += 1;
}
let word: String = chars[start..i].iter().collect();
match word.to_lowercase().as_str() {
"and" => out.push(Token::And),
"or" => out.push(Token::Or),
"not" => out.push(Token::Not),
_ => out.push(Token::Word(word)),
}
}
}
}
Ok(out)
}
struct Parser {
tokens: Vec<Token>,
at: usize,
}
impl Parser {
fn parse_or(&mut self) -> Result<Expr> {
let mut left = self.parse_and()?;
while self.take(&Token::Or) {
left = Expr::Or(Box::new(left), Box::new(self.parse_and()?));
}
Ok(left)
}
fn parse_and(&mut self) -> Result<Expr> {
let mut left = self.parse_not()?;
while self.take(&Token::And) {
left = Expr::And(Box::new(left), Box::new(self.parse_not()?));
}
Ok(left)
}
fn parse_not(&mut self) -> Result<Expr> {
if self.take(&Token::Not) {
Ok(Expr::Not(Box::new(self.parse_not()?)))
} else {
self.parse_primary()
}
}
fn parse_primary(&mut self) -> Result<Expr> {
if self.take(&Token::LParen) {
let e = self.parse_or()?;
if !self.take(&Token::RParen) {
bail!("missing closing parenthesis")
}
return Ok(e);
}
self.parse_predicate().map(Expr::Predicate)
}
fn parse_predicate(&mut self) -> Result<Predicate> {
let field = self.word()?.to_lowercase();
match field.as_str() {
"done" => Ok(Predicate::Done(true)),
"open" => Ok(Predicate::Done(false)),
"has-note" => Ok(Predicate::HasNote),
"dated" => Ok(Predicate::HasDate(true)),
"undated" => Ok(Predicate::HasDate(false)),
"overdue" => Ok(Predicate::Overdue),
"alarm" => Ok(Predicate::Alarm),
"recurring" => Ok(Predicate::Recurring(true)),
"nonrecurring" => Ok(Predicate::Recurring(false)),
"text" | "note" | "category" => {
if self.optional_op() == Some(Compare::Ne) {
bail!("use 'not {field}=value' for a negative predicate")
}
let value = self.word()?;
Ok(match field.as_str() {
"text" => Predicate::TextContains(value),
"note" => Predicate::NoteContains(value),
_ => Predicate::Category(value),
})
}
"priority" => {
let op = self.required_op()?;
Ok(Predicate::Priority(op, self.word()?.parse()?))
}
"value" => {
let op = self.required_op()?;
Ok(Predicate::Value(op, self.word()?.parse()?))
}
"due" => {
let _ = self.optional_op();
let raw = self.word()?.to_lowercase();
let days = raw.strip_suffix('d').unwrap_or(&raw).parse()?;
Ok(Predicate::DueWithin(days))
}
_ => bail!("unknown filter predicate {field}"),
}
}
fn word(&mut self) -> Result<String> {
let token = self.tokens.get(self.at).cloned();
match token {
Some(Token::Word(s)) | Some(Token::String(s)) => {
self.at += 1;
Ok(s)
}
_ => bail!("expected a value"),
}
}
fn required_op(&mut self) -> Result<Compare> {
match self.tokens.get(self.at).cloned() {
Some(Token::Op(op)) => {
self.at += 1;
Ok(op)
}
_ => bail!("expected a comparison operator"),
}
}
fn optional_op(&mut self) -> Option<Compare> {
if let Some(Token::Op(op)) = self.tokens.get(self.at).cloned() {
self.at += 1;
Some(op)
} else {
None
}
}
fn take(&mut self, expected: &Token) -> bool {
if self.tokens.get(self.at) == Some(expected) {
self.at += 1;
true
} else {
false
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::Category;
fn item() -> Item {
Item {
id: 1,
text: "Call Ada about Rust".into(),
note: "budget is 42".into(),
priority: 2,
when_at: None,
done_at: None,
alarm_at: None,
numeric_value: Some(42.0),
recurrence: String::new(),
created_at: String::new(),
updated_at: String::new(),
discarded: false,
categories: vec![Category {
id: 1,
name: "Work".into(),
parent_id: None,
kind: "standard".into(),
match_text: String::new(),
exclusive: false,
}],
}
}
#[test]
fn evaluates_boolean_expressions() {
let i = item();
assert!(matches("category=Work and (priority<=2 or done)", &i).unwrap());
assert!(!matches("done or text='missing'", &i).unwrap());
}
#[test]
fn supports_text_and_numeric_predicates() {
let i = item();
assert!(matches("text~rust and note~budget and value>=40", &i).unwrap());
}
#[test]
fn rejects_invalid_expressions() {
assert!(parse("priority nope 3").is_err());
assert!(parse("(done or open").is_err());
}
}

113
src/main.rs Normal file
View File

@@ -0,0 +1,113 @@
mod app;
mod db;
mod filter;
mod model;
mod parser;
mod ui;
use std::{io::stdout, path::PathBuf, time::Duration};
use anyhow::{Context, Result};
use app::App;
use clap::Parser;
use crossterm::{
event::{self, Event, KeyEventKind},
event::{DisableMouseCapture, EnableMouseCapture},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use db::Database;
use ratatui::{Terminal, backend::CrosstermBackend};
#[derive(Debug, Parser)]
#[command(
name = "rogue-agenda",
version,
about = "A category-driven personal information manager for the terminal"
)]
struct Args {
/// Rogue Agenda SQLite document (the .agnd extension is added if absent)
#[arg(default_value = "rogue.agnd")]
document: PathBuf,
/// Populate a new document with representative items
#[arg(long)]
demo: bool,
/// Import a text or iCalendar (.ics) file, then exit
#[arg(long)]
import: Option<PathBuf>,
/// Export as csv, json, markdown, html, or ics, then exit
#[arg(long, value_name = "FORMAT")]
export: Option<String>,
/// Destination for --export
#[arg(long)]
output: Option<PathBuf>,
/// Saved view to use for export (defaults to All Items)
#[arg(long, requires = "export")]
view: Option<String>,
}
fn main() -> Result<()> {
let args = Args::parse();
let mut path = args.document;
if path.extension().is_none() {
path.set_extension("agnd");
}
let mut db = Database::open(&path)?;
if args.demo {
db.seed_demo()?;
}
if let Some(import) = args.import {
let count = db.import_path(&import)?;
println!("Imported {count} item(s) into {}", path.display());
return Ok(());
}
if let Some(format) = args.export {
let output = args.output.context("--output is required with --export")?;
let count = db.export(&format, &output, args.view.as_deref())?;
println!("Exported {count} item(s) to {}", output.display());
return Ok(());
}
run_tui(App::new(db, path)?)
}
fn run_tui(mut app: App) -> Result<()> {
enable_raw_mode()?;
let _guard = TerminalGuard;
let mut out = stdout();
execute!(out, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(out);
let mut terminal = Terminal::new(backend)?;
terminal.clear()?;
let result = (|| -> Result<()> {
loop {
terminal.draw(|f| ui::draw(f, &mut app))?;
if app.should_quit {
break;
}
if event::poll(Duration::from_millis(250))? {
match event::read()? {
Event::Key(k) if k.kind == KeyEventKind::Press => app.handle_key(k)?,
Event::Mouse(m) => app.handle_mouse(m)?,
Event::Resize(_, _) => {}
_ => {}
}
}
}
app.db.checkpoint()?;
Ok(())
})();
terminal.show_cursor()?;
if let Err(e) = result {
return Err(e.context("Rogue Agenda session ended unexpectedly"));
}
Ok(())
}
struct TerminalGuard;
impl Drop for TerminalGuard {
fn drop(&mut self) {
let _ = disable_raw_mode();
let _ = execute!(stdout(), DisableMouseCapture, LeaveAlternateScreen);
}
}

110
src/model.rs Normal file
View File

@@ -0,0 +1,110 @@
#[derive(Debug, Clone, PartialEq)]
pub struct Item {
pub id: i64,
pub text: String,
pub note: String,
pub priority: i64,
pub when_at: Option<String>,
pub done_at: Option<String>,
pub alarm_at: Option<String>,
pub numeric_value: Option<f64>,
pub recurrence: String,
pub created_at: String,
pub updated_at: String,
pub discarded: bool,
pub categories: Vec<Category>,
}
impl Item {
pub fn category_names(&self) -> String {
self.categories
.iter()
.map(|c| c.name.as_str())
.collect::<Vec<_>>()
.join(", ")
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Category {
pub id: i64,
pub name: String,
pub parent_id: Option<i64>,
pub kind: String,
pub match_text: String,
pub exclusive: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ViewDef {
pub id: i64,
pub name: String,
pub kind: String,
pub filter_value: String,
pub sort_key: String,
pub show_done: bool,
pub filter_expr: String,
pub columns: Vec<ViewColumn>,
pub sections: Vec<ViewSection>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ViewColumn {
pub field: String,
pub heading: String,
pub width: u16,
pub aggregate: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ViewSection {
pub id: i64,
pub heading: String,
pub filter_expr: String,
pub collapsed: bool,
}
impl ViewDef {
pub fn columns_spec(&self) -> String {
self.columns
.iter()
.map(|c| format!("{}:{}:{}:{}", c.field, c.width, c.heading, c.aggregate))
.collect::<Vec<_>>()
.join(",")
}
pub fn sections_spec(&self) -> String {
self.sections
.iter()
.map(|s| {
format!(
"{}|{}{}",
s.heading,
s.filter_expr,
if s.collapsed { "|collapsed" } else { "" }
)
})
.collect::<Vec<_>>()
.join(";")
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct CategoryRule {
pub id: i64,
pub category_id: i64,
pub condition_expr: String,
pub action_kind: String,
pub action_value: String,
pub enabled: bool,
}
#[derive(Debug, Clone, Default)]
pub struct ItemChanges {
pub text: Option<String>,
pub note: Option<String>,
pub priority: Option<i64>,
pub when_at: Option<Option<String>>,
pub alarm_at: Option<Option<String>>,
pub numeric_value: Option<Option<f64>>,
pub recurrence: Option<String>,
}

179
src/parser.rs Normal file
View File

@@ -0,0 +1,179 @@
use anyhow::{Result, bail};
use chrono::{
Datelike, Duration, Local, Months, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Weekday,
};
use regex::Regex;
/// Extracts the first recognizable date/time phrase and returns local RFC 3339.
/// Deliberately favors useful, predictable planner phrases over pretending to be
/// a complete natural-language parser.
pub fn extract_when(text: &str) -> Option<String> {
extract_when_from(text, Local::now().naive_local())
}
pub fn extract_when_from(text: &str, now: NaiveDateTime) -> Option<String> {
let lower = text.to_lowercase();
let mut date = None;
let iso = Regex::new(r"\b(\d{4})-(\d{2})-(\d{2})\b").unwrap();
if let Some(c) = iso.captures(&lower) {
date = NaiveDate::from_ymd_opt(c[1].parse().ok()?, c[2].parse().ok()?, c[3].parse().ok()?);
}
if date.is_none() {
let in_days = Regex::new(r"\bin\s+(\d+)\s+days?\b").unwrap();
if let Some(c) = in_days.captures(&lower) {
date = Some(now.date() + Duration::days(c[1].parse().ok()?));
} else if lower.contains("day after tomorrow") {
date = Some(now.date() + Duration::days(2));
} else if lower.contains("tomorrow") {
date = Some(now.date() + Duration::days(1));
} else if lower.contains("today") || lower.contains("tonight") {
date = Some(now.date());
}
}
if date.is_none() {
let weekdays = [
("monday", Weekday::Mon),
("tuesday", Weekday::Tue),
("wednesday", Weekday::Wed),
("thursday", Weekday::Thu),
("friday", Weekday::Fri),
("saturday", Weekday::Sat),
("sunday", Weekday::Sun),
];
for (word, weekday) in weekdays {
if lower.contains(word) {
let mut delta = (weekday.num_days_from_monday() as i64
- now.weekday().num_days_from_monday() as i64
+ 7)
% 7;
// Treat both "Friday" and "next Friday" as the next occurrence.
// This mirrors planner expectations and avoids surprising dates
// almost two weeks away when the weekday is still ahead.
if delta == 0 {
delta += 7;
}
date = Some(now.date() + Duration::days(delta));
break;
}
}
}
let date = date?;
let time_re = Regex::new(r"\b(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)\b").unwrap();
let time24_re = Regex::new(r"\b(?:at\s+)([01]?\d|2[0-3]):([0-5]\d)\b").unwrap();
let time = if let Some(c) = time_re.captures(&lower) {
let mut hour: u32 = c[1].parse().ok()?;
let minute: u32 = c.get(2).map_or("0", |v| v.as_str()).parse().ok()?;
if hour == 12 {
hour = 0;
}
if &c[3] == "pm" {
hour += 12;
}
NaiveTime::from_hms_opt(hour, minute, 0)?
} else if let Some(c) = time24_re.captures(&lower) {
NaiveTime::from_hms_opt(c[1].parse().ok()?, c[2].parse().ok()?, 0)?
} else {
NaiveTime::from_hms_opt(9, 0, 0)?
};
let local = Local.from_local_datetime(&date.and_time(time)).single()?;
Some(local.to_rfc3339())
}
pub fn pretty_when(value: Option<&str>) -> String {
let Some(value) = value else {
return String::new();
};
chrono::DateTime::parse_from_rfc3339(value)
.map(|d| d.format("%Y-%m-%d %H:%M").to_string())
.unwrap_or_else(|_| value.to_owned())
}
pub fn next_occurrence(current: &str, recurrence: &str) -> Result<String> {
let current = chrono::DateTime::parse_from_rfc3339(current)?.with_timezone(&Local);
let rule = recurrence.trim().to_lowercase();
let next = match rule.as_str() {
"daily" => current + Duration::days(1),
"weekdays" => {
let mut next = current + Duration::days(1);
while matches!(next.weekday(), Weekday::Sat | Weekday::Sun) {
next += Duration::days(1)
}
next
}
"weekly" => current + Duration::weeks(1),
"monthly" => current
.checked_add_months(Months::new(1))
.ok_or_else(|| anyhow::anyhow!("monthly date is out of range"))?,
"yearly" | "annually" => current
.checked_add_months(Months::new(12))
.ok_or_else(|| anyhow::anyhow!("yearly date is out of range"))?,
_ => {
let re = Regex::new(r"^every\s+(\d+)\s+(days?|weeks?|months?|years?)$").unwrap();
let Some(c) = re.captures(&rule) else {
bail!(
"recurrence must be daily, weekdays, weekly, monthly, yearly, or 'every N days/weeks/months/years'"
)
};
let n: u32 = c[1].parse()?;
match &c[2] {
"day" | "days" => current + Duration::days(n as i64),
"week" | "weeks" => current + Duration::weeks(n as i64),
"month" | "months" => current
.checked_add_months(Months::new(n))
.ok_or_else(|| anyhow::anyhow!("monthly date is out of range"))?,
_ => current
.checked_add_months(Months::new(n.saturating_mul(12)))
.ok_or_else(|| anyhow::anyhow!("yearly date is out of range"))?,
}
}
};
Ok(next.to_rfc3339())
}
#[cfg(test)]
mod tests {
use super::*;
fn base() -> NaiveDateTime {
NaiveDate::from_ymd_opt(2026, 8, 16)
.unwrap()
.and_hms_opt(12, 0, 0)
.unwrap()
}
#[test]
fn parses_relative_dates_and_time() {
let got = extract_when_from("Call Ada tomorrow at 3:30pm", base()).unwrap();
assert!(got.contains("2026-08-17T15:30:00"));
}
#[test]
fn parses_next_weekday() {
let got = extract_when_from("Review next Friday", base()).unwrap();
assert!(got.contains("2026-08-21T09:00:00"));
}
#[test]
fn ignores_undated_text() {
assert_eq!(extract_when_from("An evergreen idea", base()), None);
}
#[test]
fn advances_recurring_dates() {
assert!(
next_occurrence("2026-08-14T09:00:00+00:00", "weekdays")
.unwrap()
.contains("2026-08-17T09:00:00")
);
assert!(
next_occurrence("2026-08-16T09:00:00+00:00", "every 2 weeks")
.unwrap()
.contains("2026-08-30T09:00:00")
);
}
}

935
src/ui.rs Normal file
View File

@@ -0,0 +1,935 @@
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, Mode},
filter,
model::{Item, ViewColumn},
parser::pretty_when,
};
const BLUE: Color = Color::Rgb(0, 0, 128);
const CYAN: Color = Color::Cyan;
const CANVAS: Color = Color::Rgb(192, 192, 192);
const RED: Color = Color::Rgb(128, 0, 0);
pub fn draw(frame: &mut Frame<'_>, app: &mut App) {
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(Color::Yellow).bg(BLUE))
.alignment(Alignment::Center),
frame.area(),
);
return;
}
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(2),
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]);
draw_commands(frame, app, chunks[4]);
draw_overlay(frame, app);
}
fn draw_header(frame: &mut Frame<'_>, app: &App, area: Rect) {
let alarms = app.db.due_alarm_count().unwrap_or(0);
let right = format!(
"{} {}{}",
Local::now().format("%Y-%m-%d"),
Local::now().format("%H:%M"),
if alarms > 0 {
format!(" ALARM:{alarms}")
} else {
String::new()
}
);
let width = area.width as usize;
let left1 = format!("File: {}", app.path.display());
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)
}
);
frame.render_widget(
Paragraph::new(vec![Line::from(line1), Line::from(line2)]).style(
Style::default()
.fg(Color::White)
.bg(BLUE)
.add_modifier(Modifier::BOLD),
),
area,
);
}
fn draw_view_tabs(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
app.view_regions.clear();
let mut x = area.x;
frame.render_widget(Block::default().style(Style::default().bg(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(BLUE)
.bg(Color::White)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White).bg(BLUE)
};
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) {
frame.render_widget(Block::default().style(Style::default().bg(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(BLUE).bg(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<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));
}
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(&section.filter_expr, item).unwrap_or(false))
.map(|(i, _)| i)
.collect::<Vec<_>>();
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(BLUE)
.bg(CANVAS)
.add_modifier(Modifier::BOLD),
))),
DisplayLine::Header => lines.push(cells_line(
columns.iter().map(|c| c.heading.clone()).collect(),
&widths,
Style::default()
.fg(BLUE)
.bg(CANVAS)
.add_modifier(Modifier::BOLD),
)),
DisplayLine::Item(index) => {
let item = &app.items[*index];
let style = if *index == app.selected {
Style::default()
.fg(Color::White)
.bg(RED)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
.fg(if item.done_at.is_some() {
Color::DarkGray
} else {
Color::Black
})
.bg(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))
.collect();
lines.push(cells_line(
cells,
&widths,
Style::default()
.fg(BLUE)
.bg(CANVAS)
.add_modifier(Modifier::BOLD),
));
}
}
}
frame.render_widget(
Paragraph::new(lines).style(Style::default().bg(CANVAS)),
area,
);
}
enum DisplayLine {
Section(String),
Header,
Item(usize),
Aggregate(Vec<usize>),
}
fn column_widths(columns: &[ViewColumn], 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)
.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)
.collect::<Vec<_>>();
let used = widths.iter().sum::<usize>();
if let Some(first) = widths.first_mut() {
*first += available as usize - used;
}
widths
}
fn cells_line(values: Vec<String>, widths: &[usize], style: Style) -> Line<'static> {
let text = values
.into_iter()
.zip(widths)
.map(|(v, w)| fit(&v, *w))
.collect::<Vec<_>>()
.join(" ");
Line::from(Span::styled(text, style))
}
fn fit(value: &str, width: usize) -> String {
let mut chars = value.chars();
let mut out = chars.by_ref().take(width).collect::<String>();
let count = out.chars().count();
if chars.next().is_some() && width > 0 {
out.pop();
out.push('…')
} else if count < width {
out.push_str(&" ".repeat(width - count))
}
out
}
fn 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 {
""
};
format!("{marker} {}", item.text.replace('\n', " "))
}
"categories" => format_categories(item.category_names()),
"when" => pretty_when(item.when_at.as_deref()),
"priority" => item.priority.to_string(),
"note" => item.note.replace('\n', " "),
"value" => item
.numeric_value
.map(|v| format!("{v:.2}"))
.unwrap_or_default(),
"done" => pretty_when(item.done_at.as_deref()),
"alarm" => pretty_when(item.alarm_at.as_deref()),
"recurrence" => item.recurrence.clone(),
"created" => pretty_when(Some(&item.created_at)),
"updated" => pretty_when(Some(&item.updated_at)),
_ => String::new(),
}
}
fn aggregate_value(column: &ViewColumn, indices: &[usize], items: &[Item]) -> 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::<Vec<_>>();
if values.is_empty() {
return String::new();
}
let value = match column.aggregate.as_str() {
"sum" => values.iter().sum(),
"avg" => values.iter().sum::<f64>() / 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!("{} {value:.2}", column.aggregate)
}
fn draw_datebook(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
let mut lines: Vec<Line> = vec![];
let mut dates: HashMap<String, bool> = HashMap::new();
for i in &app.items {
let date = pretty_when(i.when_at.as_deref())
.chars()
.take(10)
.collect::<String>();
if !dates.contains_key(&date) {
lines.push(Line::from(Span::styled(
format!("── {date} ──"),
Style::default()
.fg(BLUE)
.bg(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(Color::White).bg(RED)
} else {
Style::default().fg(Color::Black).bg(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!(
" {} {}",
pretty_when(i.when_at.as_deref())
.chars()
.skip(11)
.collect::<String>(),
i.text
),
style,
)));
}
frame.render_widget(
Paragraph::new(lines).style(Style::default().bg(CANVAS)),
area,
);
}
fn format_categories(s: String) -> String {
if s.is_empty() {
String::new()
} else {
s.split(", ")
.map(|v| format!("·{v}"))
.collect::<Vec<_>>()
.join(" ")
}
}
fn draw_status(frame: &mut Frame<'_>, app: &App, area: Rect) {
let totals = app
.items
.iter()
.filter_map(|i| i.numeric_value)
.collect::<Vec<_>>();
let aggregate = if totals.is_empty() {
String::new()
} else {
format!(
" Σ {:.2} avg {:.2}",
totals.iter().sum::<f64>(),
totals.iter().sum::<f64>() / totals.len() as f64
)
};
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(Color::Yellow).bg(BLUE)),
area,
);
}
fn draw_commands(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
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(BLUE)),
*r,
);
}
}
fn draw_overlay(frame: &mut Frame<'_>, app: &App) {
match &app.mode {
Mode::Normal => {}
Mode::Help => {
let text = Text::from(vec![
Line::from(Span::styled(
"ROGUE AGENDA HELP",
Style::default().fg(CYAN).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("[ ] views r prerequisites Delete trash/recover"),
Line::from("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 }),
);
}
Mode::Input(input) => {
let h = if input.multiline { 18 } else { 7 };
let shown = if input.value.is_empty() {
Span::styled("Type here…", Style::default().fg(Color::DarkGray))
} else {
Span::raw(input.value.clone())
};
popup(
frame,
center(frame.area(), 78, h),
&input.title,
Paragraph::new(Line::from(shown)).wrap(Wrap { trim: false }),
);
}
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 { CYAN }),
)),
Line::from(Span::styled(
format!("> {}", p.values[i]),
Style::default().fg(Color::White).bg(if i == p.field {
RED
} else {
BLUE
}),
)),
]
})
.chain([
Line::from(""),
Line::from("Tab fields · Enter save · Esc cancel"),
])
.collect::<Vec<_>>();
popup(
frame,
center(frame.area(), 78, 18),
"Item Properties",
Paragraph::new(lines),
);
}
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)",
),
};
let filter_source = match form.kind {
FormKind::Category(_) => form.values.get(5),
FormKind::View(_) => form.values.get(3),
}
.map(String::as_str)
.unwrap_or("");
let preview = match app.db.count_matching_filter(filter_source) {
Ok(n) => format!("Preview: {n} existing item(s) match"),
Err(e) => format!("Preview error: {e}"),
};
let lines = names
.iter()
.enumerate()
.flat_map(|(i, name)| {
[
Line::from(Span::styled(
*name,
Style::default().fg(if i == form.field { Color::Yellow } else { CYAN }),
)),
Line::from(Span::styled(
format!("> {}", form.values[i]),
Style::default().fg(Color::White).bg(if i == form.field {
RED
} else {
BLUE
}),
)),
]
})
.chain([
Line::from(""),
Line::from(hint),
Line::from(preview),
Line::from("Tab fields · Enter save · Esc back"),
])
.collect::<Vec<_>>();
popup(
frame,
center(frame.area(), 96, 24),
title,
Paragraph::new(lines),
);
}
Mode::Dependencies {
item_id,
choices,
selected,
} => {
let assigned = app.db.dependency_ids(*item_id).unwrap_or_default();
let items = choices
.iter()
.map(|item| {
ListItem::new(format!(
"{} {}",
if assigned.contains(&item.id) {
"[×]"
} else {
"[ ]"
},
item.text
))
})
.collect::<Vec<_>>();
let mut state = ListState::default().with_selected(Some(*selected));
let area = center(frame.area(), 76, (choices.len() as u16 + 4).clamp(8, 22));
frame.render_widget(Clear, area);
frame.render_stateful_widget(
List::new(items)
.block(
Block::bordered()
.title(" Prerequisites (Space toggles · Enter closes) ")
.style(Style::default().fg(Color::White).bg(BLUE)),
)
.highlight_style(
Style::default()
.fg(Color::White)
.bg(RED)
.add_modifier(Modifier::BOLD),
),
area,
&mut state,
);
}
Mode::Categories => draw_category_list(
frame,
app,
"Assign Categories (Space toggles · Enter closes)",
true,
),
Mode::CategoryManager => draw_category_list(
frame,
app,
"Category Manager (n add · e edit · Delete discard)",
false,
),
Mode::Views => {
let items = app
.views
.iter()
.map(|v| {
ListItem::new(format!("{:<18} {:<9} sort: {}", v.name, v.kind, v.sort_key))
})
.collect::<Vec<_>>();
let mut state = ListState::default().with_selected(Some(app.view_selected));
let area = center(frame.area(), 64, (app.views.len() as u16 + 4).min(20));
frame.render_widget(Clear, area);
frame.render_stateful_widget(
List::new(items)
.block(
Block::bordered()
.title(
" View Manager (n add · e edit · Ctrl-↑↓ move · Delete discard) ",
)
.style(Style::default().fg(Color::White).bg(BLUE)),
)
.highlight_style(
Style::default()
.fg(Color::White)
.bg(RED)
.add_modifier(Modifier::BOLD),
),
area,
&mut state,
);
}
Mode::Menu => {
popup(
frame,
center(frame.area(), 46, 10),
"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(" q Quit"),
Line::from(""),
Line::from(" Esc closes the menu"),
])
.style(Style::default().fg(Color::White)),
);
}
}
}
fn draw_category_list(frame: &mut Frame<'_>, app: &App, title: &str, assign: bool) {
let assigned = app
.selected_item()
.and_then(|i| app.db.item_category_ids(i.id).ok())
.unwrap_or_default();
let excluded = app
.selected_item()
.and_then(|i| app.db.excluded_category_ids(i.id).ok())
.unwrap_or_default();
let names: HashMap<i64, &str> = app
.categories
.iter()
.map(|c| (c.id, c.name.as_str()))
.collect();
let items = app
.categories
.iter()
.map(|c| {
let indent = if c.parent_id.is_some() { "" } else { "" };
let marker = if assign && assigned.contains(&c.id) {
"[×]"
} else if assign && excluded.contains(&c.id) {
"[-]"
} else if assign {
"[ ]"
} else {
""
};
let parent = c
.parent_id
.and_then(|p| names.get(&p))
.map(|p| format!(" ({p})"))
.unwrap_or_default();
let rule = 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();
ListItem::new(format!(
"{marker} {indent}{}{} {}{}",
c.name,
parent,
if c.match_text.is_empty() {
String::new()
} else {
format!("matches: {}", c.match_text)
},
rule
))
})
.collect::<Vec<_>>();
let mut state = ListState::default().with_selected(Some(app.category_selected));
let area = center(frame.area(), 78, (app.categories.len() as u16 + 4).min(22));
frame.render_widget(Clear, area);
frame.render_stateful_widget(
List::new(items)
.block(
Block::bordered()
.title(format!(" {title} "))
.style(Style::default().fg(Color::White).bg(BLUE)),
)
.highlight_style(
Style::default()
.fg(Color::White)
.bg(RED)
.add_modifier(Modifier::BOLD),
),
area,
&mut state,
);
}
fn popup(frame: &mut Frame<'_>, area: Rect, title: &str, widget: Paragraph<'_>) {
frame.render_widget(Clear, area);
frame.render_widget(
Block::default()
.borders(Borders::ALL)
.title(format!(" {title} "))
.border_style(Style::default().fg(CYAN))
.style(Style::default().bg(BLUE)),
area,
);
frame.render_widget(
widget.style(Style::default().fg(Color::White).bg(BLUE)),
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};
use ratatui::{Terminal, 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(db, p).unwrap();
let backend = TestBackend::new(100, 30);
let mut term = Terminal::new(backend).unwrap();
term.draw(|f| draw(f, &mut app)).unwrap();
let screen = term
.backend()
.buffer()
.content
.iter()
.map(|c| c.symbol())
.collect::<String>();
assert!(screen.contains("View: All Items"));
assert!(screen.contains("Call Grace"));
assert!(screen.contains("Cat Mgr"));
}
#[test]
fn renders_persistent_columns_sections_and_aggregates() {
let d = tempdir().unwrap();
let p = d.path().join("designed.agnd");
let mut db = Database::open(&p).unwrap();
let item = db.add_item("File the urgent report").unwrap();
db.update_item(
item,
&ItemChanges {
priority: Some(1),
numeric_value: Some(Some(75.0)),
..Default::default()
},
)
.unwrap();
let view_id = db
.save_view_design(
None,
"Dashboard",
"list",
"",
"priority",
true,
"open",
"item:70:Action:none,value:30:Cost:sum",
"Urgent work|priority=1",
)
.unwrap();
let mut app = App::new(db, p).unwrap();
app.view_index = app.views.iter().position(|v| v.id == view_id).unwrap();
app.refresh().unwrap();
let backend = TestBackend::new(90, 24);
let mut term = Terminal::new(backend).unwrap();
term.draw(|f| draw(f, &mut app)).unwrap();
let screen = term
.backend()
.buffer()
.content
.iter()
.map(|c| c.symbol())
.collect::<String>();
assert!(screen.contains("Urgent work"));
assert!(screen.contains("Action"));
assert!(screen.contains("Cost"));
assert!(screen.contains("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(db, p).unwrap();
let backend = TestBackend::new(50, 15);
let mut term = Terminal::new(backend).unwrap();
term.draw(|f| draw(f, &mut app)).unwrap();
let screen = term
.backend()
.buffer()
.content
.iter()
.map(|c| c.symbol())
.collect::<String>();
assert!(screen.contains("Compact item"));
assert!(screen.contains("When"));
}
}