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

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
/target/
*.agnd
*.agnd-shm
*.agnd-wal

2012
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

22
Cargo.toml Normal file
View File

@@ -0,0 +1,22 @@
[package]
name = "rogue-agenda"
version = "0.1.0"
edition = "2024"
description = "A category-driven personal information manager for the terminal"
license = "MIT"
[dependencies]
anyhow = "1.0"
chrono = { version = "0.4", features = ["clock"] }
clap = { version = "4.6", features = ["derive"] }
crossterm = "0.29"
ratatui = "0.30"
regex = "1.13"
rusqlite = "0.40"
[dev-dependencies]
tempfile = "3.27"
[profile.release]
lto = "thin"
strip = true

22
LICENSE Normal file
View File

@@ -0,0 +1,22 @@
MIT License
Copyright (c) 2026 Rogue Agenda contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

119
PROJECT.md Normal file
View File

@@ -0,0 +1,119 @@
# Rogue Agenda project ledger
Last updated: 2026-08-16
## Product direction
Rogue Agenda is a Rust, Ratatui, and SQLite reimagining of Lotus Agenda 2.0.
The goal is functional fidelity to its information-management ideas and visual
rhythm, not binary or document compatibility. Documents use `.agnd`; each is a
self-contained SQLite database. Every product string uses **Rogue Agenda**.
The central principle is “capture first, organize later.” An item is stored once
and may participate in many category relationships and saved views. The TUI keeps
the original's high-information gray workspace, blue title bands, red selection,
columnar item view, and function-key strip, while adding dynamic layout, mouse
interaction, conventional shortcuts, UTF-8, and modern durability.
## Research basis
- Lotus Development Corporation, *Lotus Agenda User's Guide 2.0* (1989), scanned
at Internet Archive. The contents identify items, notes, hierarchical and
mutually exclusive categories, explicit/conditional assignments, sections,
live views, datebook views, alarms, numeric calculations, filters, dependencies,
bulk marking, sorting, automatic conditions/actions, import/export, printing,
backups, managers, macros, and accessories.
- Lotus Development Corporation, *Starter Applications 2.0* (1990), scanned at
Internet Archive. It documents Activities Planner, Account Manager, People
Manager, priorities, contacts, expenses, weekly tracking, and status reports.
- Tavis Ormandy's hands-on “Lotus Agenda” article and animations. These establish
the actual screen grammar: two-line blue file/view header, gray unboxed canvas,
section and column headings, dot-prefixed category values, red selection,
blue F1F10 command tiles, and examples of natural-language extraction.
- Bob Newell's Agenda FAQ and James Fallows' contemporary account. These clarify
the distinctive behavior: single-copy items, category inheritance, negative
assignment, aliases/abbreviations, incremental structure, and named live views.
- Period reviews from the Los Angeles Times and HUMANIST archives corroborate
long notes, free-form entry, priorities, Boolean reports, and automatic filing.
Research URLs:
- https://archive.org/details/lotus-agenda-users-guide
- https://lock.cmpxchg8b.com/lotusagenda.html
- https://www.bobnewell.net/agenda/agfaq.txt.html
- https://www.bobnewell.net/agenda/atlantic.txt.html
- https://dhhumanist.org/archives/Virginia/v02/0410.html
- https://www.latimes.com/archives/la-xpm-1988-07-28-fi-9745-story.html
## Implemented in this prototype
- [x] `.agnd` SQLite documents, schema migration metadata, foreign keys, WAL,
transactions, integrity-friendly soft deletion, and automatic checkpoints.
- [x] Items with free text, note, priority, When, Done, alarm, numeric value,
recurring schedule, creation/update timestamps, manual order, and prerequisite
links. Completion advances recurring work and carries alarms and filing forward.
- [x] Categories with hierarchy, inheritance in queries, kinds (standard, date,
numeric), match phrases, mutually exclusive siblings, and many-to-many links.
- [x] Explicit assignment and automatic text-based assignment.
- [x] Explicit negative category assignments that override automatic filing.
- [x] Natural-language date extraction for common relative, weekday, and ISO forms.
- [x] Live saved views, datebook presentation, category/done/date/priority filters,
search filtering, configurable sort, and responsive columns.
- [x] Interactive creation, editing, and deletion of saved live views and category
definitions, including hierarchy, match phrases, kinds, and exclusivity.
- [x] Persistent per-view fields, headings, widths, order, numeric aggregates,
filtered multi-section layouts, collapsed sections, and view reordering.
- [x] Parsed Boolean expressions with `and`/`or`/`not`, parentheses, text/note/
category matching, numeric comparisons, state predicates, and relative due dates.
- [x] User-authored category conditions with live match preview and deterministic
assign, exclude, priority, numeric value, When, alarm, recurrence, and Done
actions. Rules apply iteratively, respect explicit overrides, and detect
non-convergence.
- [x] Default All Items, Tasks, Upcoming, Recently Done, and Datebook views.
- [x] Agenda-style item workspace, View Manager, Category Manager, command menu,
item editor, property editor, notes, category chooser, help, and status messages.
- [x] Marking and bulk completion; Trash view, soft discard, and recovery.
- [x] Responsive wide/compact rendering and mouse row/command/view interaction.
- [x] Text and iCalendar import; view-scoped CSV, JSON, Markdown, styled HTML,
and iCalendar export with notes, categories, dates, priority, completion, and
recurring `RRULE` schedules.
- [x] Demo data, CLI help, README, MIT license, and unit/integration tests.
## Supported with a modern interpretation
- [x] Numeric calculations: per-item numeric values and view total/average are
supported; arbitrary category formula programs are intentionally represented
by the safer aggregate model.
- [x] Conditions/actions: category match phrases and date parsing run on item
changes; assignments are immediately re-evaluated. Shell-executing actions are
omitted by design, while deterministic filing actions are supported.
- [x] Printing: view-aware Markdown and styled HTML reports are the portable
print/preview path, alongside CSV and JSON data exports.
- [x] Backups: SQLite durability plus explicit checkpoint replaces proprietary
backup-file rotation. Ordinary filesystem backup tools work on `.agnd` files.
- [x] Clipboard/accessory capture: terminal paste into the new-item form replaces
the DOS resident accessory.
- [x] Macros: conventional keys and direct commands replace keystroke-replay macros.
## Delayed / unsupported
These are the few features that either depend on obsolete DOS integration or need
a larger follow-on implementation:
- [ ] Arbitrary formula columns that reference several numeric categories. Current
view columns support `sum`, `avg`, `count`, `min`, and `max` aggregates.
- [ ] Rich-text/font markers, printer control sequences, page-layout preview, and
named printer configurations.
- [ ] Lotus Metro/List Manager/STF compatibility, DOS accessories, and Agenda macro
compatibility (explicitly outside the project's format-compatibility goal).
- [ ] Password encryption. A description field is supported by schema metadata;
weak historic password protection will not be imitated without real encryption.
- [ ] Desktop notifications while the application is closed. Due alarms are shown
prominently while Rogue Agenda is running.
## Near-term plan
1. Add desktop alarm integration and in-application snooze controls.
2. Add encrypted documents and secure unlock handling.
3. Add property-based tests for complex hierarchy and rule combinations.
4. Add a structured text definition-file importer for unusual legacy datasets.

155
README.md Normal file
View File

@@ -0,0 +1,155 @@
# Rogue Agenda
Rogue Agenda is a modern terminal reimagining of the ideas behind Lotus Agenda.
It captures information first and lets structure emerge later: enter a plain
sentence, then use categories, dates, saved views, notes, priorities, and live
filters to see it from different angles.
The interface deliberately recalls Agenda's dense blue-and-gray DOS workspace,
including the function-key command strip, while behaving like a current TUI:
it resizes fluidly, supports mouse selection and command clicks, understands
common keyboard conventions, and stores each document as an ordinary SQLite
database with the `.agnd` extension.
## Features
- Free-form items with multiline notes, priority, due dates, done dates, alarms,
numeric values, recurring schedules, and prerequisites. Completing a recurring
item creates its next dated occurrence and carries its alarm and filing forward.
- Hierarchical categories with inherited membership and explicit many-to-many
assignment and negative exclusions: an item exists once but can appear in many
contexts.
- Automatic filing from category match phrases and natural date phrases such as
`today`, `tomorrow`, `next friday`, `in 3 days`, and ISO dates.
- Live views for all items, tasks, upcoming work, completed work, and a datebook.
- Full-text search plus composable Boolean filters over text, notes, categories,
dates, done state, priority, numeric value, alarms, recurrence, and relative due
windows.
- Agenda-style View Manager and Category Manager screens with editable live
view definitions, hierarchy, category kinds, match phrases, and exclusivity.
- Persistent per-view columns, custom headings and widths, numeric aggregates,
filtered multi-section layouts, collapsed sections, and view reordering.
- User-authored category rules with a live match preview and deterministic actions
for assignments, exclusions, priority, value, dates, and completion.
- Marking and bulk completion, soft deletion with Trash recovery, sorting, and
dependency visibility.
- View-scoped CSV, JSON, Markdown, HTML, and iCalendar export, text/iCalendar
import (including standard recurrence rules), autosave, WAL journaling, and
transactional SQLite persistence.
- Responsive compact/wide layouts, keyboard navigation, mouse selection,
clickable function-key commands, and an in-app help screen.
## Build and run
You need a current stable Rust toolchain and SQLite development libraries.
```sh
cargo run -- notes.agnd
```
If the path has no extension, Rogue Agenda appends `.agnd`. A new document is
initialized with useful categories and saved views. To explore a populated file:
```sh
cargo run -- demo.agnd --demo
```
Noninteractive transfer commands are also available:
```sh
cargo run -- notes.agnd --import inbox.txt
cargo run -- notes.agnd --export csv --output notes.csv
cargo run -- notes.agnd --export json --output notes.json
cargo run -- notes.agnd --export markdown --view Tasks --output tasks.md
cargo run -- notes.agnd --export html --view Datebook --output agenda.html
cargo run -- notes.agnd --export ics --output tasks.ics
cargo run -- imported.agnd --import tasks.ics
```
## Essential keys
| Key | Action |
| --- | --- |
| Arrows or `h j k l` | Move selection / scroll |
| `Insert` or `n` | Add a free-form item |
| `F2` or `e` | Edit the selected item |
| `F3` or `c` | Assign categories |
| `F4` or `d` | Toggle done |
| `F5` | Edit the item's note |
| `F6` or `p` | Edit item properties |
| `F7` or `Space` | Mark for bulk operations |
| `F8` or `v` | View Manager |
| `F9` | Category Manager |
| `F10` or `m` | Command menu |
| `r` | Edit prerequisites for the selected item |
| `/` | Search the current view |
| `[` / `]` | Previous / next saved view |
| `Ctrl-S` | Save/checkpoint |
| `?` or `F1` | Help |
| `q` | Quit |
Inside forms, `Tab` moves between fields, `Enter` accepts, and `Esc` cancels.
Mouse clicks select rows, switch views, and activate the bottom command strip.
## Designing live views
Open the View Manager with `F8`. Press `n` to create a view, `e` to edit one,
`Ctrl-Up`/`Ctrl-Down` to reorder it, or `Delete` to remove it.
Boolean filters use `and`, `or`, `not`, and parentheses. Available predicates
include `text~rust`, `note~budget`, `category=Work`, `priority<=2`, `value>100`,
`done`, `open`, `dated`, `undated`, `due<=7d`, `overdue`, `has-note`, `alarm`,
`recurring`, and `nonrecurring`.
For example:
```text
category=Work and open and (priority<=2 or due<=3d)
```
Columns use `field:width:heading:aggregate`, separated by commas. Fields are
`item`, `categories`, `when`, `priority`, `note`, `value`, `done`, `alarm`,
`recurrence`, `created`, and `updated`; aggregates are `none`, `sum`, `avg`,
`count`, `min`, or `max`.
```text
item:55:Action:none,when:25:Due:none,value:20:Cost:sum
```
Sections use `heading|filter`, separated by semicolons. Add `|collapsed` to make
a section initially collapsed.
```text
Urgent|priority=1;Upcoming|due<=7d;Someday|undated|collapsed
```
## Automatic category rules
Open the Category Manager with `F9`, then create or edit a category. In addition
to simple match phrases, each category can have a Boolean rule condition and an
action. The form previews how many existing items match before saving.
Actions use `action:value`: `assign:Category`, `exclude:Category`, `priority:1`,
`value:125.50`, `when:tomorrow at 3pm`, `alarm:tomorrow at 2pm`,
`repeat:weekdays`, or `done:true`. `assign:` with no value assigns the category
being edited. Rules are reapplied to existing information and whenever an item
changes.
## Recurring work
Open item properties with `F6` and set a recurrence to `daily`, `weekdays`,
`weekly`, `monthly`, `yearly`, or a rule such as `every 2 weeks`. The item must
have a When date. Marking it done preserves the completed occurrence and creates
the next open occurrence. Rogue Agenda shifts its alarm by the same interval and
copies its note, priority, numeric value, recurrence, and explicit category
assignments. iCalendar import and export maps these schedules to `RRULE`.
## Design notes
Rogue Agenda is not file-format compatible with Lotus Agenda and uses no Lotus
code. It reinterprets the interaction model around items, categories, views,
automatic assignment, and information sifting. See [PROJECT.md](PROJECT.md) for
the research record and detailed implementation matrix.
## License
MIT. See [LICENSE](LICENSE).

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"));
}
}