working prototype
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
/target/
|
||||
*.agnd
|
||||
*.agnd-shm
|
||||
*.agnd-wal
|
||||
|
||||
2012
Cargo.lock
generated
Normal file
2012
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
Cargo.toml
Normal file
22
Cargo.toml
Normal 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
22
LICENSE
Normal 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
119
PROJECT.md
Normal 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 F1–F10 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
155
README.md
Normal 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
812
src/app.rs
Normal 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
|
||||
}
|
||||
390
src/filter.rs
Normal file
390
src/filter.rs
Normal 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
113
src/main.rs
Normal 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
110
src/model.rs
Normal 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
179
src/parser.rs
Normal 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
935
src/ui.rs
Normal 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(§ion.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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user