preferences added
This commit is contained in:
80
Cargo.lock
generated
80
Cargo.lock
generated
@@ -177,9 +177,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc"
|
name = "cc"
|
||||||
version = "1.4.3"
|
version = "1.4.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d"
|
checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"find-msvc-tools",
|
"find-msvc-tools",
|
||||||
"shlex",
|
"shlex",
|
||||||
@@ -506,9 +506,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "find-msvc-tools"
|
name = "find-msvc-tools"
|
||||||
version = "0.1.11"
|
version = "0.1.10"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
|
checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "finl_unicode"
|
name = "finl_unicode"
|
||||||
@@ -664,6 +664,16 @@ version = "1.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "indexmap"
|
||||||
|
version = "2.14.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
|
||||||
|
dependencies = [
|
||||||
|
"equivalent",
|
||||||
|
"hashbrown 0.17.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "indoc"
|
name = "indoc"
|
||||||
version = "2.0.7"
|
version = "2.0.7"
|
||||||
@@ -1305,7 +1315,9 @@ dependencies = [
|
|||||||
"ratatui",
|
"ratatui",
|
||||||
"regex",
|
"regex",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
|
"serde",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
|
"toml",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1409,6 +1421,15 @@ dependencies = [
|
|||||||
"syn 3.0.3",
|
"syn 3.0.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_spanned"
|
||||||
|
version = "1.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sha2"
|
name = "sha2"
|
||||||
version = "0.10.9"
|
version = "0.10.9"
|
||||||
@@ -1703,6 +1724,45 @@ version = "0.1.9"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
|
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml"
|
||||||
|
version = "0.9.12+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
|
||||||
|
dependencies = [
|
||||||
|
"indexmap",
|
||||||
|
"serde_core",
|
||||||
|
"serde_spanned",
|
||||||
|
"toml_datetime",
|
||||||
|
"toml_parser",
|
||||||
|
"toml_writer",
|
||||||
|
"winnow 0.7.15",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_datetime"
|
||||||
|
version = "0.7.5+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_parser"
|
||||||
|
version = "1.1.3+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
|
||||||
|
dependencies = [
|
||||||
|
"winnow 1.0.4",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_writer"
|
||||||
|
version = "1.1.2+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typenum"
|
name = "typenum"
|
||||||
version = "1.20.1"
|
version = "1.20.1"
|
||||||
@@ -2005,6 +2065,18 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winnow"
|
||||||
|
version = "0.7.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winnow"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wit-bindgen"
|
name = "wit-bindgen"
|
||||||
version = "0.57.1"
|
version = "0.57.1"
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ crossterm = "0.29"
|
|||||||
ratatui = "0.30"
|
ratatui = "0.30"
|
||||||
regex = "1.13"
|
regex = "1.13"
|
||||||
rusqlite = "0.40"
|
rusqlite = "0.40"
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
toml = "0.9"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3.27"
|
tempfile = "3.27"
|
||||||
|
|||||||
13
PROJECT.md
13
PROJECT.md
@@ -74,6 +74,14 @@ Research URLs:
|
|||||||
item editor, property editor, notes, category chooser, help, and status messages.
|
item editor, property editor, notes, category chooser, help, and status messages.
|
||||||
- [x] Marking and bulk completion; Trash view, soft discard, and recovery.
|
- [x] Marking and bulk completion; Trash view, soft discard, and recovery.
|
||||||
- [x] Responsive wide/compact rendering and mouse row/command/view interaction.
|
- [x] Responsive wide/compact rendering and mouse row/command/view interaction.
|
||||||
|
- [x] Application Preferences persisted as typed TOML at
|
||||||
|
`~/.config/rogue-agenda/preferences.toml`: theme, command bar, rule/return/item
|
||||||
|
markers, autosave, confirmations, date/time display, and number separators.
|
||||||
|
- [x] Transactional per-document settings embedded in `.agnd`: description,
|
||||||
|
backup-on-open, Trash and completed-item policies, automatic filing, numeric
|
||||||
|
date order, week boundary, natural-language named times, and note tab width.
|
||||||
|
- [x] Consistent manual/startup `.agnd.bak` backups, timed WAL checkpoints,
|
||||||
|
configurable Trash maintenance, and confirmations for permanent operations.
|
||||||
- [x] Text and iCalendar import; view-scoped CSV, JSON, Markdown, styled HTML,
|
- [x] Text and iCalendar import; view-scoped CSV, JSON, Markdown, styled HTML,
|
||||||
and iCalendar export with notes, categories, dates, priority, completion, and
|
and iCalendar export with notes, categories, dates, priority, completion, and
|
||||||
recurring `RRULE` schedules.
|
recurring `RRULE` schedules.
|
||||||
@@ -89,8 +97,9 @@ Research URLs:
|
|||||||
omitted by design, while deterministic filing actions are supported.
|
omitted by design, while deterministic filing actions are supported.
|
||||||
- [x] Printing: view-aware Markdown and styled HTML reports are the portable
|
- [x] Printing: view-aware Markdown and styled HTML reports are the portable
|
||||||
print/preview path, alongside CSV and JSON data exports.
|
print/preview path, alongside CSV and JSON data exports.
|
||||||
- [x] Backups: SQLite durability plus explicit checkpoint replaces proprietary
|
- [x] Backups: SQLite durability is supplemented by explicit/manual and
|
||||||
backup-file rotation. Ordinary filesystem backup tools work on `.agnd` files.
|
backup-on-open `.agnd.bak` snapshots after a full WAL checkpoint. Ordinary
|
||||||
|
filesystem backup tools also work on `.agnd` files.
|
||||||
- [x] Clipboard/accessory capture: terminal paste into the new-item form replaces
|
- [x] Clipboard/accessory capture: terminal paste into the new-item form replaces
|
||||||
the DOS resident accessory.
|
the DOS resident accessory.
|
||||||
- [x] Macros: conventional keys and direct commands replace keystroke-replay macros.
|
- [x] Macros: conventional keys and direct commands replace keystroke-replay macros.
|
||||||
|
|||||||
30
README.md
30
README.md
@@ -38,6 +38,8 @@ database with the `.agnd` extension.
|
|||||||
transactional SQLite persistence.
|
transactional SQLite persistence.
|
||||||
- Responsive compact/wide layouts, keyboard navigation, mouse selection,
|
- Responsive compact/wide layouts, keyboard navigation, mouse selection,
|
||||||
clickable function-key commands, and an in-app help screen.
|
clickable function-key commands, and an in-app help screen.
|
||||||
|
- Application-wide Preferences plus per-document settings for themes, display and
|
||||||
|
locale conventions, autosave, confirmations, dates, filing, Trash, and backups.
|
||||||
|
|
||||||
## Build and run
|
## Build and run
|
||||||
|
|
||||||
@@ -107,6 +109,34 @@ In the `F5` note editor, `Enter` inserts a line, arrows/Home/End move the cursor
|
|||||||
and `Ctrl-S` saves. Mouse clicks select rows, switch views, and activate the
|
and `Ctrl-S` saves. Mouse clicks select rows, switch views, and activate the
|
||||||
bottom command strip.
|
bottom command strip.
|
||||||
|
|
||||||
|
## Preferences and document settings
|
||||||
|
|
||||||
|
Press `F10`, then `p` for application-wide Preferences. Rogue Agenda saves these
|
||||||
|
as typed TOML at `~/.config/rogue-agenda/preferences.toml`. The screen controls:
|
||||||
|
|
||||||
|
- `classic`, `mono`, and `amber` themes
|
||||||
|
- function-key command bar and category-rule detail visibility
|
||||||
|
- carriage-return and item markers
|
||||||
|
- autosave checkpoint interval and destructive-action confirmations
|
||||||
|
- ISO, US, European, or long date display and 12/24-hour time
|
||||||
|
- decimal and thousands separators
|
||||||
|
|
||||||
|
The file is created when Preferences are first saved. It can also be edited with
|
||||||
|
a text editor while Rogue Agenda is closed; unknown keys and invalid values are
|
||||||
|
reported instead of silently ignored.
|
||||||
|
|
||||||
|
Press `F10`, then `d` for settings stored in the current `.agnd` document. These
|
||||||
|
travel with the document and include its description, backup-on-open, Trash
|
||||||
|
retention (`on-demand`, `on-close`, `end-of-day`, or `immediate`), completed-item
|
||||||
|
policy, automatic filing, numeric date order, first day of the week, default and
|
||||||
|
named times, and note-editor tab width. `this week` and `next week` honor the
|
||||||
|
chosen week boundary; `morning`, `afternoon`, and `evening` honor their configured
|
||||||
|
times.
|
||||||
|
|
||||||
|
Use `F10`, then `b` to create a consistent sibling backup named
|
||||||
|
`document.agnd.bak`, or enable backup-on-open. Use `F10`, then `t` to empty Trash.
|
||||||
|
Permanent operations request confirmation unless that preference is disabled.
|
||||||
|
|
||||||
## Designing live views
|
## Designing live views
|
||||||
|
|
||||||
Open the View Manager with `F8`. Press `n` to create a view, `e` to edit one,
|
Open the View Manager with `F8`. Press `n` to create a view, `e` to edit one,
|
||||||
|
|||||||
310
src/app.rs
310
src/app.rs
@@ -1,13 +1,18 @@
|
|||||||
use std::{collections::HashSet, path::PathBuf};
|
use std::{
|
||||||
|
collections::HashSet,
|
||||||
|
path::PathBuf,
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::{Context, Result, bail};
|
||||||
|
use chrono::{Local, NaiveDate};
|
||||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
|
||||||
use ratatui::layout::Rect;
|
use ratatui::layout::Rect;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
db::Database,
|
db::Database,
|
||||||
model::{Category, Item, ItemChanges, ViewDef},
|
model::{Category, DocumentSettings, Item, ItemChanges, ViewDef},
|
||||||
parser::extract_when,
|
preferences::AppPreferences,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -39,6 +44,8 @@ pub struct PropsState {
|
|||||||
pub enum FormKind {
|
pub enum FormKind {
|
||||||
Category(Option<i64>),
|
Category(Option<i64>),
|
||||||
View(Option<i64>),
|
View(Option<i64>),
|
||||||
|
Preferences,
|
||||||
|
DocumentSettings,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -64,11 +71,26 @@ pub enum Mode {
|
|||||||
Views,
|
Views,
|
||||||
CategoryManager,
|
CategoryManager,
|
||||||
Menu,
|
Menu,
|
||||||
|
Confirm {
|
||||||
|
prompt: String,
|
||||||
|
action: ConfirmAction,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum ConfirmAction {
|
||||||
|
DeleteView(i64),
|
||||||
|
DeleteCategory(i64),
|
||||||
|
EmptyTrash,
|
||||||
|
PermanentlyDiscard(Vec<i64>),
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct App {
|
pub struct App {
|
||||||
pub db: Database,
|
pub db: Database,
|
||||||
pub path: PathBuf,
|
pub path: PathBuf,
|
||||||
|
pub preferences: AppPreferences,
|
||||||
|
pub preferences_path: PathBuf,
|
||||||
|
pub document_settings: DocumentSettings,
|
||||||
pub views: Vec<ViewDef>,
|
pub views: Vec<ViewDef>,
|
||||||
pub view_index: usize,
|
pub view_index: usize,
|
||||||
pub items: Vec<Item>,
|
pub items: Vec<Item>,
|
||||||
@@ -85,15 +107,41 @@ pub struct App {
|
|||||||
pub item_rows: Vec<(Rect, i64)>,
|
pub item_rows: Vec<(Rect, i64)>,
|
||||||
pub command_regions: Vec<Rect>,
|
pub command_regions: Vec<Rect>,
|
||||||
pub view_regions: Vec<Rect>,
|
pub view_regions: Vec<Rect>,
|
||||||
|
last_autosave: Instant,
|
||||||
|
current_day: NaiveDate,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
pub fn new(db: Database, path: PathBuf) -> Result<Self> {
|
pub fn new(db: Database, path: PathBuf) -> Result<Self> {
|
||||||
|
let (preferences, preferences_path) = AppPreferences::load()?;
|
||||||
|
Self::build(db, path, preferences, preferences_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn new_with_preferences(
|
||||||
|
db: Database,
|
||||||
|
path: PathBuf,
|
||||||
|
preferences: AppPreferences,
|
||||||
|
preferences_path: PathBuf,
|
||||||
|
) -> Result<Self> {
|
||||||
|
Self::build(db, path, preferences, preferences_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build(
|
||||||
|
db: Database,
|
||||||
|
path: PathBuf,
|
||||||
|
preferences: AppPreferences,
|
||||||
|
preferences_path: PathBuf,
|
||||||
|
) -> Result<Self> {
|
||||||
let views = db.views()?;
|
let views = db.views()?;
|
||||||
let categories = db.categories()?;
|
let categories = db.categories()?;
|
||||||
|
let document_settings = db.document_settings()?;
|
||||||
let mut app = Self {
|
let mut app = Self {
|
||||||
db,
|
db,
|
||||||
path,
|
path,
|
||||||
|
preferences,
|
||||||
|
preferences_path,
|
||||||
|
document_settings,
|
||||||
views,
|
views,
|
||||||
view_index: 0,
|
view_index: 0,
|
||||||
items: vec![],
|
items: vec![],
|
||||||
@@ -110,6 +158,8 @@ impl App {
|
|||||||
item_rows: vec![],
|
item_rows: vec![],
|
||||||
command_regions: vec![],
|
command_regions: vec![],
|
||||||
view_regions: vec![],
|
view_regions: vec![],
|
||||||
|
last_autosave: Instant::now(),
|
||||||
|
current_day: Local::now().date_naive(),
|
||||||
};
|
};
|
||||||
app.refresh()?;
|
app.refresh()?;
|
||||||
Ok(app)
|
Ok(app)
|
||||||
@@ -160,9 +210,35 @@ impl App {
|
|||||||
Mode::Views => self.handle_views(key),
|
Mode::Views => self.handle_views(key),
|
||||||
Mode::CategoryManager => self.handle_category_manager(key),
|
Mode::CategoryManager => self.handle_category_manager(key),
|
||||||
Mode::Menu => self.handle_menu(key),
|
Mode::Menu => self.handle_menu(key),
|
||||||
|
Mode::Confirm { .. } => self.handle_confirm(key),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn tick(&mut self) -> Result<()> {
|
||||||
|
let interval = self.preferences.autosave_minutes;
|
||||||
|
if interval > 0 && self.last_autosave.elapsed() >= Duration::from_secs(interval * 60) {
|
||||||
|
self.db.checkpoint()?;
|
||||||
|
self.last_autosave = Instant::now();
|
||||||
|
self.status = "Autosaved and checkpointed".into();
|
||||||
|
}
|
||||||
|
let today = Local::now().date_naive();
|
||||||
|
if today != self.current_day {
|
||||||
|
self.current_day = today;
|
||||||
|
if self.document_settings.trash_policy == "end-of-day" {
|
||||||
|
let count = self.db.empty_trash()?;
|
||||||
|
if count > 0 {
|
||||||
|
self.status = format!("Emptied {count} Trash item(s) at end of day");
|
||||||
|
self.refresh()?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn shutdown(&self) -> Result<()> {
|
||||||
|
self.db.close_maintenance()
|
||||||
|
}
|
||||||
|
|
||||||
fn handle_normal(&mut self, key: KeyEvent) -> Result<()> {
|
fn handle_normal(&mut self, key: KeyEvent) -> Result<()> {
|
||||||
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('s') {
|
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('s') {
|
||||||
self.db.checkpoint()?;
|
self.db.checkpoint()?;
|
||||||
@@ -307,11 +383,28 @@ impl App {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let restore = self.current_view().kind == "trash";
|
let restore = self.current_view().kind == "trash";
|
||||||
|
if !restore
|
||||||
|
&& self.document_settings.trash_policy == "immediate"
|
||||||
|
&& self.preferences.confirm_destructive
|
||||||
|
{
|
||||||
|
self.mode = Mode::Confirm {
|
||||||
|
prompt: format!("Permanently delete {} item(s)?", ids.len()),
|
||||||
|
action: ConfirmAction::PermanentlyDiscard(ids),
|
||||||
|
};
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
self.apply_discard(ids, restore)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_discard(&mut self, ids: Vec<i64>, restore: bool) -> Result<()> {
|
||||||
|
let permanent = !restore && self.document_settings.trash_policy == "immediate";
|
||||||
self.db.discard(&ids, !restore)?;
|
self.db.discard(&ids, !restore)?;
|
||||||
self.marked.clear();
|
self.marked.clear();
|
||||||
self.refresh()?;
|
self.refresh()?;
|
||||||
self.status = if restore {
|
self.status = if restore {
|
||||||
format!("Restored {} item(s)", ids.len())
|
format!("Restored {} item(s)", ids.len())
|
||||||
|
} else if permanent {
|
||||||
|
format!("Permanently deleted {} item(s)", ids.len())
|
||||||
} else {
|
} else {
|
||||||
format!("Moved {} item(s) to Trash", ids.len())
|
format!("Moved {} item(s) to Trash", ids.len())
|
||||||
};
|
};
|
||||||
@@ -336,6 +429,11 @@ impl App {
|
|||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Enter if input.multiline => insert_at_cursor(&mut input, '\n'),
|
KeyCode::Enter if input.multiline => insert_at_cursor(&mut input, '\n'),
|
||||||
KeyCode::Enter => return self.accept_input(input),
|
KeyCode::Enter => return self.accept_input(input),
|
||||||
|
KeyCode::Tab if input.multiline => {
|
||||||
|
for _ in 0..self.document_settings.note_tab_width {
|
||||||
|
insert_at_cursor(&mut input, ' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
KeyCode::Backspace => delete_before_cursor(&mut input),
|
KeyCode::Backspace => delete_before_cursor(&mut input),
|
||||||
KeyCode::Delete => delete_at_cursor(&mut input),
|
KeyCode::Delete => delete_at_cursor(&mut input),
|
||||||
KeyCode::Left => input.cursor = previous_boundary(&input.value, input.cursor),
|
KeyCode::Left => input.cursor = previous_boundary(&input.value, input.cursor),
|
||||||
@@ -412,8 +510,8 @@ impl App {
|
|||||||
}
|
}
|
||||||
KeyCode::Enter => {
|
KeyCode::Enter => {
|
||||||
let priority = props.values[0].parse().unwrap_or(3);
|
let priority = props.values[0].parse().unwrap_or(3);
|
||||||
let when_at = normalize_date(&props.values[1]);
|
let when_at = normalize_date(&self.db, &props.values[1])?;
|
||||||
let alarm_at = normalize_date(&props.values[2]);
|
let alarm_at = normalize_date(&self.db, &props.values[2])?;
|
||||||
let numeric_value = if props.values[3].trim().is_empty() {
|
let numeric_value = if props.values[3].trim().is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
@@ -510,11 +608,15 @@ impl App {
|
|||||||
}
|
}
|
||||||
KeyCode::Delete => {
|
KeyCode::Delete => {
|
||||||
if let Some(view) = self.views.get(self.view_selected) {
|
if let Some(view) = self.views.get(self.view_selected) {
|
||||||
self.db.delete_view(view.id)?;
|
let id = view.id;
|
||||||
self.view_index = 0;
|
if self.preferences.confirm_destructive {
|
||||||
self.view_selected = 0;
|
self.mode = Mode::Confirm {
|
||||||
self.refresh()?;
|
prompt: format!("Delete view “{}”?", view.name),
|
||||||
self.status = "View deleted".into();
|
action: ConfirmAction::DeleteView(id),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
self.delete_view(id)?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
@@ -539,10 +641,15 @@ impl App {
|
|||||||
}
|
}
|
||||||
KeyCode::Delete => {
|
KeyCode::Delete => {
|
||||||
if let Some(category) = self.categories.get(self.category_selected) {
|
if let Some(category) = self.categories.get(self.category_selected) {
|
||||||
self.db.delete_category(category.id)?;
|
let id = category.id;
|
||||||
self.category_selected = self.category_selected.saturating_sub(1);
|
if self.preferences.confirm_destructive {
|
||||||
self.refresh()?;
|
self.mode = Mode::Confirm {
|
||||||
self.status = "Category deleted; its items were preserved".into();
|
prompt: format!("Delete category “{}”?", category.name),
|
||||||
|
action: ConfirmAction::DeleteCategory(id),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
self.delete_category(id)?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
@@ -561,12 +668,122 @@ impl App {
|
|||||||
}
|
}
|
||||||
KeyCode::Char('h') => self.mode = Mode::Help,
|
KeyCode::Char('h') => self.mode = Mode::Help,
|
||||||
KeyCode::Char('r') => self.open_dependencies()?,
|
KeyCode::Char('r') => self.open_dependencies()?,
|
||||||
|
KeyCode::Char('p') => self.open_preferences_form(),
|
||||||
|
KeyCode::Char('d') => self.open_document_settings_form(),
|
||||||
|
KeyCode::Char('b') => {
|
||||||
|
let path = self.db.backup_now()?;
|
||||||
|
self.status = format!("Backup written to {}", path.display());
|
||||||
|
}
|
||||||
|
KeyCode::Char('t') => {
|
||||||
|
if self.preferences.confirm_destructive {
|
||||||
|
self.mode = Mode::Confirm {
|
||||||
|
prompt: "Permanently empty Trash?".into(),
|
||||||
|
action: ConfirmAction::EmptyTrash,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
self.empty_trash()?;
|
||||||
|
}
|
||||||
|
}
|
||||||
KeyCode::Char('q') => self.should_quit = true,
|
KeyCode::Char('q') => self.should_quit = true,
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn handle_confirm(&mut self, key: KeyEvent) -> Result<()> {
|
||||||
|
let Mode::Confirm { action, .. } = self.mode.clone() else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
|
||||||
|
match action {
|
||||||
|
ConfirmAction::DeleteView(id) => self.delete_view(id)?,
|
||||||
|
ConfirmAction::DeleteCategory(id) => self.delete_category(id)?,
|
||||||
|
ConfirmAction::EmptyTrash => self.empty_trash()?,
|
||||||
|
ConfirmAction::PermanentlyDiscard(ids) => self.apply_discard(ids, false)?,
|
||||||
|
}
|
||||||
|
if matches!(self.mode, Mode::Confirm { .. }) {
|
||||||
|
self.mode = Mode::Normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
|
||||||
|
self.mode = Mode::Normal;
|
||||||
|
self.status = "Canceled".into();
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete_view(&mut self, id: i64) -> Result<()> {
|
||||||
|
self.db.delete_view(id)?;
|
||||||
|
self.view_index = 0;
|
||||||
|
self.view_selected = 0;
|
||||||
|
self.mode = Mode::Views;
|
||||||
|
self.refresh()?;
|
||||||
|
self.status = "View deleted".into();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete_category(&mut self, id: i64) -> Result<()> {
|
||||||
|
self.db.delete_category(id)?;
|
||||||
|
self.category_selected = self.category_selected.saturating_sub(1);
|
||||||
|
self.mode = Mode::CategoryManager;
|
||||||
|
self.refresh()?;
|
||||||
|
self.status = "Category deleted; its items were preserved".into();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn empty_trash(&mut self) -> Result<()> {
|
||||||
|
let count = self.db.empty_trash()?;
|
||||||
|
self.mode = Mode::Normal;
|
||||||
|
self.refresh()?;
|
||||||
|
self.status = format!("Permanently removed {count} Trash item(s)");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_preferences_form(&mut self) {
|
||||||
|
self.mode = Mode::Form(FormState {
|
||||||
|
kind: FormKind::Preferences,
|
||||||
|
values: vec![
|
||||||
|
self.preferences.theme.clone(),
|
||||||
|
yes_no(self.preferences.show_command_bar),
|
||||||
|
yes_no(self.preferences.show_rule_info),
|
||||||
|
yes_no(self.preferences.show_carriage_returns),
|
||||||
|
self.preferences.item_marker.clone(),
|
||||||
|
self.preferences.autosave_minutes.to_string(),
|
||||||
|
yes_no(self.preferences.confirm_destructive),
|
||||||
|
self.preferences.date_format.clone(),
|
||||||
|
yes_no(self.preferences.clock_24h),
|
||||||
|
self.preferences.decimal_separator.clone(),
|
||||||
|
self.preferences.thousands_separator.clone(),
|
||||||
|
],
|
||||||
|
field: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_document_settings_form(&mut self) {
|
||||||
|
let settings = &self.document_settings;
|
||||||
|
self.mode = Mode::Form(FormState {
|
||||||
|
kind: FormKind::DocumentSettings,
|
||||||
|
values: vec![
|
||||||
|
settings.description.clone(),
|
||||||
|
yes_no(settings.backup_on_open),
|
||||||
|
settings.trash_policy.clone(),
|
||||||
|
settings.done_policy.clone(),
|
||||||
|
yes_no(settings.automatic_filing),
|
||||||
|
settings.date_order.clone(),
|
||||||
|
settings.week_start.clone(),
|
||||||
|
settings.default_time.clone(),
|
||||||
|
settings.morning_time.clone(),
|
||||||
|
settings.afternoon_time.clone(),
|
||||||
|
settings.evening_time.clone(),
|
||||||
|
settings.note_tab_width.to_string(),
|
||||||
|
],
|
||||||
|
field: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
fn open_category_form(&mut self, category: Option<Category>) {
|
fn open_category_form(&mut self, category: Option<Category>) {
|
||||||
let id = category.as_ref().map(|c| c.id);
|
let id = category.as_ref().map(|c| c.id);
|
||||||
let rule =
|
let rule =
|
||||||
@@ -654,6 +871,7 @@ impl App {
|
|||||||
self.mode = match form.kind {
|
self.mode = match form.kind {
|
||||||
FormKind::Category(_) => Mode::CategoryManager,
|
FormKind::Category(_) => Mode::CategoryManager,
|
||||||
FormKind::View(_) => Mode::Views,
|
FormKind::View(_) => Mode::Views,
|
||||||
|
FormKind::Preferences | FormKind::DocumentSettings => Mode::Normal,
|
||||||
};
|
};
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -708,6 +926,52 @@ impl App {
|
|||||||
self.mode = Mode::Views;
|
self.mode = Mode::Views;
|
||||||
self.status = "Live view saved".into();
|
self.status = "Live view saved".into();
|
||||||
}
|
}
|
||||||
|
FormKind::Preferences => {
|
||||||
|
let preferences = AppPreferences {
|
||||||
|
theme: form.values[0].trim().to_lowercase(),
|
||||||
|
show_command_bar: parse_yes_no(&form.values[1])?,
|
||||||
|
show_rule_info: parse_yes_no(&form.values[2])?,
|
||||||
|
show_carriage_returns: parse_yes_no(&form.values[3])?,
|
||||||
|
item_marker: form.values[4].clone(),
|
||||||
|
autosave_minutes: form.values[5]
|
||||||
|
.parse()
|
||||||
|
.context("autosave interval must be a number")?,
|
||||||
|
confirm_destructive: parse_yes_no(&form.values[6])?,
|
||||||
|
date_format: form.values[7].trim().to_lowercase(),
|
||||||
|
clock_24h: parse_yes_no(&form.values[8])?,
|
||||||
|
decimal_separator: form.values[9].clone(),
|
||||||
|
thousands_separator: form.values[10].clone(),
|
||||||
|
};
|
||||||
|
preferences.save_to(&self.preferences_path)?;
|
||||||
|
self.preferences = preferences;
|
||||||
|
self.last_autosave = Instant::now();
|
||||||
|
self.mode = Mode::Normal;
|
||||||
|
self.status =
|
||||||
|
format!("Preferences saved to {}", self.preferences_path.display());
|
||||||
|
}
|
||||||
|
FormKind::DocumentSettings => {
|
||||||
|
let settings = DocumentSettings {
|
||||||
|
description: form.values[0].trim().into(),
|
||||||
|
backup_on_open: parse_yes_no(&form.values[1])?,
|
||||||
|
trash_policy: form.values[2].trim().to_lowercase(),
|
||||||
|
done_policy: form.values[3].trim().to_lowercase(),
|
||||||
|
automatic_filing: parse_yes_no(&form.values[4])?,
|
||||||
|
date_order: form.values[5].trim().to_lowercase(),
|
||||||
|
week_start: form.values[6].trim().to_lowercase(),
|
||||||
|
default_time: form.values[7].trim().into(),
|
||||||
|
morning_time: form.values[8].trim().into(),
|
||||||
|
afternoon_time: form.values[9].trim().into(),
|
||||||
|
evening_time: form.values[10].trim().into(),
|
||||||
|
note_tab_width: form.values[11]
|
||||||
|
.parse()
|
||||||
|
.context("note tab width must be a number")?,
|
||||||
|
};
|
||||||
|
self.db.save_document_settings(&settings)?;
|
||||||
|
self.document_settings = settings;
|
||||||
|
self.mode = Mode::Normal;
|
||||||
|
self.refresh()?;
|
||||||
|
self.status = "Document settings saved inside the .agnd file".into();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -810,14 +1074,22 @@ fn parse_yes(value: &str) -> bool {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_date(value: &str) -> Option<String> {
|
fn parse_yes_no(value: &str) -> Result<bool> {
|
||||||
|
match value.trim().to_lowercase().as_str() {
|
||||||
|
"y" | "yes" | "true" | "1" | "on" => Ok(true),
|
||||||
|
"n" | "no" | "false" | "0" | "off" => Ok(false),
|
||||||
|
_ => bail!("expected yes or no, got {value}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_date(db: &Database, value: &str) -> Result<Option<String>> {
|
||||||
let s = value.trim();
|
let s = value.trim();
|
||||||
if s.is_empty() {
|
if s.is_empty() {
|
||||||
None
|
Ok(None)
|
||||||
} else if chrono::DateTime::parse_from_rfc3339(s).is_ok() {
|
} else if chrono::DateTime::parse_from_rfc3339(s).is_ok() {
|
||||||
Some(s.into())
|
Ok(Some(s.into()))
|
||||||
} else {
|
} else {
|
||||||
extract_when(&format!("on {s}"))
|
db.interpret_date(&format!("on {s}"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fn inside(r: Rect, x: u16, y: u16) -> bool {
|
fn inside(r: Rect, x: u16, y: u16) -> bool {
|
||||||
|
|||||||
282
src/db.rs
282
src/db.rs
@@ -1,27 +1,40 @@
|
|||||||
use std::{fs, path::Path};
|
use std::{
|
||||||
|
fs,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
};
|
||||||
|
|
||||||
use anyhow::{Context, Result, bail};
|
use anyhow::{Context, Result, bail};
|
||||||
use chrono::{DateTime, Duration, Local, NaiveDate, NaiveDateTime, TimeZone, Utc};
|
use chrono::{DateTime, Duration, Local, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc};
|
||||||
use rusqlite::{Connection, OptionalExtension, params};
|
use rusqlite::{Connection, OptionalExtension, params};
|
||||||
|
|
||||||
use crate::filter;
|
use crate::filter;
|
||||||
use crate::model::{Category, CategoryRule, Item, ItemChanges, ViewColumn, ViewDef, ViewSection};
|
use crate::model::{
|
||||||
use crate::parser::{extract_when, next_occurrence};
|
Category, CategoryRule, DocumentSettings, Item, ItemChanges, ViewColumn, ViewDef, ViewSection,
|
||||||
|
};
|
||||||
|
use crate::parser::{extract_when_configured, next_occurrence};
|
||||||
|
|
||||||
pub struct Database {
|
pub struct Database {
|
||||||
conn: Connection,
|
conn: Connection,
|
||||||
|
path: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Database {
|
impl Database {
|
||||||
pub fn open(path: &Path) -> Result<Self> {
|
pub fn open(path: &Path) -> Result<Self> {
|
||||||
|
let existed = path.exists() && path.metadata().is_ok_and(|metadata| metadata.len() > 0);
|
||||||
let conn =
|
let conn =
|
||||||
Connection::open(path).with_context(|| format!("could not open {}", path.display()))?;
|
Connection::open(path).with_context(|| format!("could not open {}", path.display()))?;
|
||||||
conn.pragma_update(None, "foreign_keys", "ON")?;
|
conn.pragma_update(None, "foreign_keys", "ON")?;
|
||||||
conn.pragma_update(None, "journal_mode", "WAL")?;
|
conn.pragma_update(None, "journal_mode", "WAL")?;
|
||||||
conn.pragma_update(None, "synchronous", "NORMAL")?;
|
conn.pragma_update(None, "synchronous", "NORMAL")?;
|
||||||
let mut db = Self { conn };
|
let mut db = Self {
|
||||||
|
conn,
|
||||||
|
path: path.to_owned(),
|
||||||
|
};
|
||||||
db.migrate()?;
|
db.migrate()?;
|
||||||
db.seed_defaults()?;
|
db.seed_defaults()?;
|
||||||
|
if existed && db.document_settings()?.backup_on_open {
|
||||||
|
db.backup_now()?;
|
||||||
|
}
|
||||||
Ok(db)
|
Ok(db)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,6 +191,114 @@ impl Database {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn document_settings(&self) -> Result<DocumentSettings> {
|
||||||
|
let defaults = DocumentSettings::default();
|
||||||
|
let settings = DocumentSettings {
|
||||||
|
description: self.meta_value("document.description", &defaults.description)?,
|
||||||
|
backup_on_open: self.meta_bool("document.backup_on_open", defaults.backup_on_open)?,
|
||||||
|
trash_policy: self.meta_value("document.trash_policy", &defaults.trash_policy)?,
|
||||||
|
done_policy: self.meta_value("document.done_policy", &defaults.done_policy)?,
|
||||||
|
automatic_filing: self
|
||||||
|
.meta_bool("document.automatic_filing", defaults.automatic_filing)?,
|
||||||
|
date_order: self.meta_value("document.date_order", &defaults.date_order)?,
|
||||||
|
week_start: self.meta_value("document.week_start", &defaults.week_start)?,
|
||||||
|
default_time: self.meta_value("document.default_time", &defaults.default_time)?,
|
||||||
|
morning_time: self.meta_value("document.morning_time", &defaults.morning_time)?,
|
||||||
|
afternoon_time: self.meta_value("document.afternoon_time", &defaults.afternoon_time)?,
|
||||||
|
evening_time: self.meta_value("document.evening_time", &defaults.evening_time)?,
|
||||||
|
note_tab_width: self
|
||||||
|
.meta_value(
|
||||||
|
"document.note_tab_width",
|
||||||
|
&defaults.note_tab_width.to_string(),
|
||||||
|
)?
|
||||||
|
.parse()?,
|
||||||
|
};
|
||||||
|
validate_document_settings(&settings)?;
|
||||||
|
Ok(settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_document_settings(&mut self, settings: &DocumentSettings) -> Result<()> {
|
||||||
|
validate_document_settings(settings)?;
|
||||||
|
let tx = self.conn.transaction()?;
|
||||||
|
for (key, value) in [
|
||||||
|
("document.description", settings.description.clone()),
|
||||||
|
(
|
||||||
|
"document.backup_on_open",
|
||||||
|
settings.backup_on_open.to_string(),
|
||||||
|
),
|
||||||
|
("document.trash_policy", settings.trash_policy.clone()),
|
||||||
|
("document.done_policy", settings.done_policy.clone()),
|
||||||
|
(
|
||||||
|
"document.automatic_filing",
|
||||||
|
settings.automatic_filing.to_string(),
|
||||||
|
),
|
||||||
|
("document.date_order", settings.date_order.clone()),
|
||||||
|
("document.week_start", settings.week_start.clone()),
|
||||||
|
("document.default_time", settings.default_time.clone()),
|
||||||
|
("document.morning_time", settings.morning_time.clone()),
|
||||||
|
("document.afternoon_time", settings.afternoon_time.clone()),
|
||||||
|
("document.evening_time", settings.evening_time.clone()),
|
||||||
|
(
|
||||||
|
"document.note_tab_width",
|
||||||
|
settings.note_tab_width.to_string(),
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
tx.execute(
|
||||||
|
"INSERT INTO meta(key,value) VALUES(?1,?2) ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
||||||
|
params![key, value],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
tx.commit()?;
|
||||||
|
self.reapply_automatic_assignments()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn meta_value(&self, key: &str, default: &str) -> Result<String> {
|
||||||
|
Ok(self
|
||||||
|
.conn
|
||||||
|
.query_row("SELECT value FROM meta WHERE key=?1", [key], |row| {
|
||||||
|
row.get(0)
|
||||||
|
})
|
||||||
|
.optional()?
|
||||||
|
.unwrap_or_else(|| default.to_owned()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn meta_bool(&self, key: &str, default: bool) -> Result<bool> {
|
||||||
|
Ok(parse_bool(&self.meta_value(key, &default.to_string())?))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn interpret_date(&self, text: &str) -> Result<Option<String>> {
|
||||||
|
let settings = self.document_settings()?;
|
||||||
|
Ok(extract_when_configured(
|
||||||
|
text,
|
||||||
|
&settings.date_order,
|
||||||
|
&settings.week_start,
|
||||||
|
&settings.default_time,
|
||||||
|
&settings.morning_time,
|
||||||
|
&settings.afternoon_time,
|
||||||
|
&settings.evening_time,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn backup_path(&self) -> PathBuf {
|
||||||
|
let mut path = self.path.as_os_str().to_os_string();
|
||||||
|
path.push(".bak");
|
||||||
|
PathBuf::from(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn backup_now(&self) -> Result<PathBuf> {
|
||||||
|
self.conn.execute_batch("PRAGMA wal_checkpoint(FULL)")?;
|
||||||
|
let destination = self.backup_path();
|
||||||
|
fs::copy(&self.path, &destination).with_context(|| {
|
||||||
|
format!(
|
||||||
|
"could not back up {} to {}",
|
||||||
|
self.path.display(),
|
||||||
|
destination.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(destination)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn views(&self) -> Result<Vec<ViewDef>> {
|
pub fn views(&self) -> Result<Vec<ViewDef>> {
|
||||||
let mut stmt = self.conn.prepare("SELECT id,name,kind,filter_value,sort_key,show_done,filter_expr FROM views ORDER BY sort_order,name")?;
|
let mut stmt = self.conn.prepare("SELECT id,name,kind,filter_value,sort_key,show_done,filter_expr FROM views ORDER BY sort_order,name")?;
|
||||||
let mut views = stmt
|
let mut views = stmt
|
||||||
@@ -481,13 +602,18 @@ impl Database {
|
|||||||
bail!("item text cannot be empty");
|
bail!("item text cannot be empty");
|
||||||
}
|
}
|
||||||
let now = Local::now().to_rfc3339();
|
let now = Local::now().to_rfc3339();
|
||||||
let when_at = extract_when(text);
|
let settings = self.document_settings()?;
|
||||||
|
let when_at = self.interpret_date(text)?;
|
||||||
let tx = self.conn.transaction()?;
|
let tx = self.conn.transaction()?;
|
||||||
tx.execute("INSERT INTO items(text,when_at,created_at,updated_at,sort_order) VALUES(?1,?2,?3,?3,(SELECT COALESCE(MAX(sort_order),0)+1 FROM items))", params![text,when_at,now])?;
|
tx.execute("INSERT INTO items(text,when_at,created_at,updated_at,sort_order) VALUES(?1,?2,?3,?3,(SELECT COALESCE(MAX(sort_order),0)+1 FROM items))", params![text,when_at,now])?;
|
||||||
let id = tx.last_insert_rowid();
|
let id = tx.last_insert_rowid();
|
||||||
|
if settings.automatic_filing {
|
||||||
Self::auto_assign_tx(&tx, id, text)?;
|
Self::auto_assign_tx(&tx, id, text)?;
|
||||||
|
}
|
||||||
tx.commit()?;
|
tx.commit()?;
|
||||||
|
if settings.automatic_filing {
|
||||||
self.apply_rules_to_item(id)?;
|
self.apply_rules_to_item(id)?;
|
||||||
|
}
|
||||||
Ok(id)
|
Ok(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -516,6 +642,7 @@ impl Database {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn reapply_automatic_assignments(&mut self) -> Result<()> {
|
pub fn reapply_automatic_assignments(&mut self) -> Result<()> {
|
||||||
|
let automatic_filing = self.document_settings()?.automatic_filing;
|
||||||
let tx = self.conn.transaction()?;
|
let tx = self.conn.transaction()?;
|
||||||
let items: Vec<(i64, String)> = {
|
let items: Vec<(i64, String)> = {
|
||||||
let mut stmt = tx.prepare("SELECT id,text FROM items WHERE discarded=0")?;
|
let mut stmt = tx.prepare("SELECT id,text FROM items WHERE discarded=0")?;
|
||||||
@@ -523,14 +650,24 @@ impl Database {
|
|||||||
.collect::<rusqlite::Result<_>>()?
|
.collect::<rusqlite::Result<_>>()?
|
||||||
};
|
};
|
||||||
for (id, text) in items {
|
for (id, text) in items {
|
||||||
|
if automatic_filing {
|
||||||
Self::auto_assign_tx(&tx, id, &text)?;
|
Self::auto_assign_tx(&tx, id, &text)?;
|
||||||
|
} else {
|
||||||
|
tx.execute(
|
||||||
|
"DELETE FROM item_categories WHERE item_id=?1 AND assignment='automatic'",
|
||||||
|
[id],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
tx.commit()?;
|
tx.commit()?;
|
||||||
|
if automatic_filing {
|
||||||
self.apply_rules_to_all()?;
|
self.apply_rules_to_all()?;
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn update_item(&mut self, id: i64, changes: &ItemChanges) -> Result<()> {
|
pub fn update_item(&mut self, id: i64, changes: &ItemChanges) -> Result<()> {
|
||||||
|
let automatic_filing = self.document_settings()?.automatic_filing;
|
||||||
let tx = self.conn.transaction()?;
|
let tx = self.conn.transaction()?;
|
||||||
if let Some(text) = &changes.text {
|
if let Some(text) = &changes.text {
|
||||||
if text.trim().is_empty() {
|
if text.trim().is_empty() {
|
||||||
@@ -540,8 +677,10 @@ impl Database {
|
|||||||
"UPDATE items SET text=?1,updated_at=?2 WHERE id=?3",
|
"UPDATE items SET text=?1,updated_at=?2 WHERE id=?3",
|
||||||
params![text.trim(), Local::now().to_rfc3339(), id],
|
params![text.trim(), Local::now().to_rfc3339(), id],
|
||||||
)?;
|
)?;
|
||||||
|
if automatic_filing {
|
||||||
Self::auto_assign_tx(&tx, id, text)?;
|
Self::auto_assign_tx(&tx, id, text)?;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if let Some(note) = &changes.note {
|
if let Some(note) = &changes.note {
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"UPDATE items SET note=?1,updated_at=?2 WHERE id=?3",
|
"UPDATE items SET note=?1,updated_at=?2 WHERE id=?3",
|
||||||
@@ -591,7 +730,9 @@ impl Database {
|
|||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
tx.commit()?;
|
tx.commit()?;
|
||||||
|
if automatic_filing {
|
||||||
self.apply_rules_to_item(id)?;
|
self.apply_rules_to_item(id)?;
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -721,7 +862,8 @@ impl Database {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
"when" => {
|
"when" => {
|
||||||
let value = extract_when(&rule.action_value)
|
let value = self
|
||||||
|
.interpret_date(&rule.action_value)?
|
||||||
.context("rule action has no recognizable date")?;
|
.context("rule action has no recognizable date")?;
|
||||||
if item.when_at.as_deref() != Some(value.as_str()) {
|
if item.when_at.as_deref() != Some(value.as_str()) {
|
||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
@@ -734,7 +876,8 @@ impl Database {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
"alarm" => {
|
"alarm" => {
|
||||||
let value = extract_when(&rule.action_value)
|
let value = self
|
||||||
|
.interpret_date(&rule.action_value)?
|
||||||
.context("rule action has no recognizable alarm date")?;
|
.context("rule action has no recognizable alarm date")?;
|
||||||
if item.alarm_at.as_deref() != Some(value.as_str()) {
|
if item.alarm_at.as_deref() != Some(value.as_str()) {
|
||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
@@ -791,6 +934,7 @@ impl Database {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn toggle_done(&mut self, ids: &[i64]) -> Result<()> {
|
pub fn toggle_done(&mut self, ids: &[i64]) -> Result<()> {
|
||||||
|
let settings = self.document_settings()?;
|
||||||
for id in ids {
|
for id in ids {
|
||||||
let Some(item) = self.load_item(*id)? else {
|
let Some(item) = self.load_item(*id)? else {
|
||||||
continue;
|
continue;
|
||||||
@@ -814,13 +958,18 @@ impl Database {
|
|||||||
);
|
);
|
||||||
tx.execute("INSERT INTO items(text,note,priority,when_at,alarm_at,numeric_value,recurrence,created_at,updated_at,sort_order) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?8,(SELECT COALESCE(MAX(sort_order),0)+1 FROM items))",params![&item.text,&item.note,item.priority,next_when,next_alarm,item.numeric_value,&item.recurrence,&now])?;
|
tx.execute("INSERT INTO items(text,note,priority,when_at,alarm_at,numeric_value,recurrence,created_at,updated_at,sort_order) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?8,(SELECT COALESCE(MAX(sort_order),0)+1 FROM items))",params![&item.text,&item.note,item.priority,next_when,next_alarm,item.numeric_value,&item.recurrence,&now])?;
|
||||||
let created = tx.last_insert_rowid();
|
let created = tx.last_insert_rowid();
|
||||||
|
if settings.automatic_filing {
|
||||||
Self::auto_assign_tx(&tx, created, &item.text)?;
|
Self::auto_assign_tx(&tx, created, &item.text)?;
|
||||||
|
}
|
||||||
tx.execute("INSERT OR REPLACE INTO item_categories(item_id,category_id,assignment) SELECT ?1,category_id,assignment FROM item_categories WHERE item_id=?2 AND assignment IN ('explicit','excluded')",params![created,id])?;
|
tx.execute("INSERT OR REPLACE INTO item_categories(item_id,category_id,assignment) SELECT ?1,category_id,assignment FROM item_categories WHERE item_id=?2 AND assignment IN ('explicit','excluded')",params![created,id])?;
|
||||||
next_id = Some(created);
|
next_id = Some(created);
|
||||||
}
|
}
|
||||||
tx.execute("UPDATE items SET done_at=CASE WHEN done_at IS NULL THEN ?1 ELSE NULL END,updated_at=?1 WHERE id=?2",params![now,id])?;
|
let discard_completed = item.done_at.is_none() && settings.done_policy == "trash";
|
||||||
|
tx.execute("UPDATE items SET done_at=CASE WHEN done_at IS NULL THEN ?1 ELSE NULL END,discarded=CASE WHEN ?3 THEN 1 ELSE discarded END,updated_at=?1 WHERE id=?2",params![now,id,discard_completed])?;
|
||||||
tx.commit()?;
|
tx.commit()?;
|
||||||
if let Some(next_id) = next_id {
|
if let Some(next_id) = next_id
|
||||||
|
&& settings.automatic_filing
|
||||||
|
{
|
||||||
self.apply_rules_to_item(next_id)?;
|
self.apply_rules_to_item(next_id)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -828,12 +977,17 @@ impl Database {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn discard(&self, ids: &[i64], discarded: bool) -> Result<()> {
|
pub fn discard(&self, ids: &[i64], discarded: bool) -> Result<()> {
|
||||||
|
let permanent = discarded && self.document_settings()?.trash_policy == "immediate";
|
||||||
for id in ids {
|
for id in ids {
|
||||||
|
if permanent {
|
||||||
|
self.conn.execute("DELETE FROM items WHERE id=?1", [id])?;
|
||||||
|
} else {
|
||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
"UPDATE items SET discarded=?1,updated_at=?2 WHERE id=?3",
|
"UPDATE items SET discarded=?1,updated_at=?2 WHERE id=?3",
|
||||||
params![discarded as i64, Local::now().to_rfc3339(), id],
|
params![discarded as i64, Local::now().to_rfc3339(), id],
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -987,6 +1141,19 @@ impl Database {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn empty_trash(&self) -> Result<usize> {
|
||||||
|
Ok(self
|
||||||
|
.conn
|
||||||
|
.execute("DELETE FROM items WHERE discarded=1", [])?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn close_maintenance(&self) -> Result<()> {
|
||||||
|
if self.document_settings()?.trash_policy == "on-close" {
|
||||||
|
self.empty_trash()?;
|
||||||
|
}
|
||||||
|
self.checkpoint()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn import_path(&mut self, path: &Path) -> Result<usize> {
|
pub fn import_path(&mut self, path: &Path) -> Result<usize> {
|
||||||
if path
|
if path
|
||||||
.extension()
|
.extension()
|
||||||
@@ -1478,6 +1645,37 @@ fn parse_bool(value: &str) -> bool {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn validate_document_settings(settings: &DocumentSettings) -> Result<()> {
|
||||||
|
if !matches!(
|
||||||
|
settings.trash_policy.as_str(),
|
||||||
|
"on-demand" | "on-close" | "end-of-day" | "immediate"
|
||||||
|
) {
|
||||||
|
bail!("trash policy must be on-demand, on-close, end-of-day, or immediate")
|
||||||
|
}
|
||||||
|
if !matches!(settings.done_policy.as_str(), "keep" | "trash") {
|
||||||
|
bail!("done policy must be keep or trash")
|
||||||
|
}
|
||||||
|
if !matches!(settings.date_order.as_str(), "ymd" | "mdy" | "dmy") {
|
||||||
|
bail!("date order must be ymd, mdy, or dmy")
|
||||||
|
}
|
||||||
|
if !matches!(settings.week_start.as_str(), "monday" | "sunday") {
|
||||||
|
bail!("week start must be monday or sunday")
|
||||||
|
}
|
||||||
|
for (name, value) in [
|
||||||
|
("default time", &settings.default_time),
|
||||||
|
("morning time", &settings.morning_time),
|
||||||
|
("afternoon time", &settings.afternoon_time),
|
||||||
|
("evening time", &settings.evening_time),
|
||||||
|
] {
|
||||||
|
NaiveTime::parse_from_str(value, "%H:%M")
|
||||||
|
.with_context(|| format!("{name} must use 24-hour HH:MM format"))?;
|
||||||
|
}
|
||||||
|
if !(1..=16).contains(&settings.note_tab_width) {
|
||||||
|
bail!("note tab width must be between 1 and 16")
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_columns_spec(spec: &str) -> Result<Vec<ViewColumn>> {
|
fn parse_columns_spec(spec: &str) -> Result<Vec<ViewColumn>> {
|
||||||
if spec.trim().is_empty() {
|
if spec.trim().is_empty() {
|
||||||
return Ok(default_columns());
|
return Ok(default_columns());
|
||||||
@@ -1852,6 +2050,70 @@ mod tests {
|
|||||||
assert!(!html.contains("Omit report"));
|
assert!(!html.contains("Omit report"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn document_settings_persist_and_drive_document_behavior() {
|
||||||
|
let directory = tempdir().unwrap();
|
||||||
|
let path = directory.path().join("configured.agnd");
|
||||||
|
{
|
||||||
|
let mut db = Database::open(&path).unwrap();
|
||||||
|
db.save_document_settings(&DocumentSettings {
|
||||||
|
description: "European planning file".into(),
|
||||||
|
backup_on_open: true,
|
||||||
|
trash_policy: "immediate".into(),
|
||||||
|
done_policy: "trash".into(),
|
||||||
|
automatic_filing: false,
|
||||||
|
date_order: "dmy".into(),
|
||||||
|
week_start: "sunday".into(),
|
||||||
|
default_time: "08:30".into(),
|
||||||
|
morning_time: "08:00".into(),
|
||||||
|
afternoon_time: "14:00".into(),
|
||||||
|
evening_time: "19:15".into(),
|
||||||
|
note_tab_width: 8,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut db = Database::open(&path).unwrap();
|
||||||
|
assert!(db.backup_path().exists());
|
||||||
|
let settings = db.document_settings().unwrap();
|
||||||
|
assert_eq!(settings.description, "European planning file");
|
||||||
|
assert_eq!(settings.note_tab_width, 8);
|
||||||
|
let item = db
|
||||||
|
.add_item("Call Ada on 18/08/2026 in the evening")
|
||||||
|
.unwrap();
|
||||||
|
let all = db
|
||||||
|
.views()
|
||||||
|
.unwrap()
|
||||||
|
.into_iter()
|
||||||
|
.find(|view| view.name == "All Items")
|
||||||
|
.unwrap();
|
||||||
|
let items = db.items(&all, "").unwrap();
|
||||||
|
assert!(
|
||||||
|
items[0]
|
||||||
|
.when_at
|
||||||
|
.as_deref()
|
||||||
|
.unwrap()
|
||||||
|
.contains("2026-08-18T19:15:00")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!items[0]
|
||||||
|
.categories
|
||||||
|
.iter()
|
||||||
|
.any(|category| category.name == "Calls")
|
||||||
|
);
|
||||||
|
|
||||||
|
db.toggle_done(&[item]).unwrap();
|
||||||
|
let trash = db
|
||||||
|
.views()
|
||||||
|
.unwrap()
|
||||||
|
.into_iter()
|
||||||
|
.find(|view| view.name == "Trash")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(db.items(&trash, "").unwrap().len(), 1);
|
||||||
|
db.discard(&[item], true).unwrap();
|
||||||
|
assert!(db.items(&trash, "").unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ical_roundtrip_preserves_planner_fields() {
|
fn ical_roundtrip_preserves_planner_fields() {
|
||||||
let d = tempdir().unwrap();
|
let d = tempdir().unwrap();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ mod db;
|
|||||||
mod filter;
|
mod filter;
|
||||||
mod model;
|
mod model;
|
||||||
mod parser;
|
mod parser;
|
||||||
|
mod preferences;
|
||||||
mod ui;
|
mod ui;
|
||||||
|
|
||||||
use std::{io::stdout, path::PathBuf, time::Duration};
|
use std::{io::stdout, path::PathBuf, time::Duration};
|
||||||
@@ -80,6 +81,7 @@ fn run_tui(mut app: App) -> Result<()> {
|
|||||||
terminal.clear()?;
|
terminal.clear()?;
|
||||||
let result = (|| -> Result<()> {
|
let result = (|| -> Result<()> {
|
||||||
loop {
|
loop {
|
||||||
|
app.tick()?;
|
||||||
terminal.draw(|f| ui::draw(f, &mut app))?;
|
terminal.draw(|f| ui::draw(f, &mut app))?;
|
||||||
if app.should_quit {
|
if app.should_quit {
|
||||||
break;
|
break;
|
||||||
@@ -93,7 +95,7 @@ fn run_tui(mut app: App) -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
app.db.checkpoint()?;
|
app.shutdown()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
})();
|
})();
|
||||||
terminal.show_cursor()?;
|
terminal.show_cursor()?;
|
||||||
|
|||||||
35
src/model.rs
35
src/model.rs
@@ -108,3 +108,38 @@ pub struct ItemChanges {
|
|||||||
pub numeric_value: Option<Option<f64>>,
|
pub numeric_value: Option<Option<f64>>,
|
||||||
pub recurrence: Option<String>,
|
pub recurrence: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct DocumentSettings {
|
||||||
|
pub description: String,
|
||||||
|
pub backup_on_open: bool,
|
||||||
|
pub trash_policy: String,
|
||||||
|
pub done_policy: String,
|
||||||
|
pub automatic_filing: bool,
|
||||||
|
pub date_order: String,
|
||||||
|
pub week_start: String,
|
||||||
|
pub default_time: String,
|
||||||
|
pub morning_time: String,
|
||||||
|
pub afternoon_time: String,
|
||||||
|
pub evening_time: String,
|
||||||
|
pub note_tab_width: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for DocumentSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
description: String::new(),
|
||||||
|
backup_on_open: false,
|
||||||
|
trash_policy: "on-demand".into(),
|
||||||
|
done_policy: "keep".into(),
|
||||||
|
automatic_filing: true,
|
||||||
|
date_order: "ymd".into(),
|
||||||
|
week_start: "monday".into(),
|
||||||
|
default_time: "09:00".into(),
|
||||||
|
morning_time: "09:00".into(),
|
||||||
|
afternoon_time: "13:00".into(),
|
||||||
|
evening_time: "18:00".into(),
|
||||||
|
note_tab_width: 4,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
137
src/parser.rs
137
src/parser.rs
@@ -4,14 +4,46 @@ use chrono::{
|
|||||||
};
|
};
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
|
|
||||||
/// Extracts the first recognizable date/time phrase and returns local RFC 3339.
|
#[cfg(test)]
|
||||||
/// Deliberately favors useful, predictable planner phrases over pretending to be
|
pub fn extract_when_from(text: &str, now: NaiveDateTime) -> Option<String> {
|
||||||
/// a complete natural-language parser.
|
extract_when_from_configured(
|
||||||
pub fn extract_when(text: &str) -> Option<String> {
|
text, now, "ymd", "monday", "09:00", "09:00", "13:00", "18:00",
|
||||||
extract_when_from(text, Local::now().naive_local())
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn extract_when_from(text: &str, now: NaiveDateTime) -> Option<String> {
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn extract_when_configured(
|
||||||
|
text: &str,
|
||||||
|
date_order: &str,
|
||||||
|
week_start: &str,
|
||||||
|
default_time: &str,
|
||||||
|
morning_time: &str,
|
||||||
|
afternoon_time: &str,
|
||||||
|
evening_time: &str,
|
||||||
|
) -> Option<String> {
|
||||||
|
extract_when_from_configured(
|
||||||
|
text,
|
||||||
|
Local::now().naive_local(),
|
||||||
|
date_order,
|
||||||
|
week_start,
|
||||||
|
default_time,
|
||||||
|
morning_time,
|
||||||
|
afternoon_time,
|
||||||
|
evening_time,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn extract_when_from_configured(
|
||||||
|
text: &str,
|
||||||
|
now: NaiveDateTime,
|
||||||
|
date_order: &str,
|
||||||
|
week_start: &str,
|
||||||
|
default_time: &str,
|
||||||
|
morning_time: &str,
|
||||||
|
afternoon_time: &str,
|
||||||
|
evening_time: &str,
|
||||||
|
) -> Option<String> {
|
||||||
let lower = text.to_lowercase();
|
let lower = text.to_lowercase();
|
||||||
let mut date = None;
|
let mut date = None;
|
||||||
|
|
||||||
@@ -20,6 +52,24 @@ pub fn extract_when_from(text: &str, now: NaiveDateTime) -> Option<String> {
|
|||||||
date = NaiveDate::from_ymd_opt(c[1].parse().ok()?, c[2].parse().ok()?, c[3].parse().ok()?);
|
date = NaiveDate::from_ymd_opt(c[1].parse().ok()?, c[2].parse().ok()?, c[3].parse().ok()?);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if date.is_none() {
|
||||||
|
let numeric = Regex::new(r"\b(\d{1,4})[./-](\d{1,2})[./-](\d{1,4})\b").unwrap();
|
||||||
|
if let Some(c) = numeric.captures(&lower) {
|
||||||
|
let values = [
|
||||||
|
c[1].parse::<i32>().ok()?,
|
||||||
|
c[2].parse::<i32>().ok()?,
|
||||||
|
c[3].parse::<i32>().ok()?,
|
||||||
|
];
|
||||||
|
let (year, month, day) = match date_order {
|
||||||
|
"mdy" => (values[2], values[0], values[1]),
|
||||||
|
"dmy" => (values[2], values[1], values[0]),
|
||||||
|
_ => (values[0], values[1], values[2]),
|
||||||
|
};
|
||||||
|
let year = if year < 100 { year + 2000 } else { year };
|
||||||
|
date = NaiveDate::from_ymd_opt(year, month as u32, day as u32);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if date.is_none() {
|
if date.is_none() {
|
||||||
let in_days = Regex::new(r"\bin\s+(\d+)\s+days?\b").unwrap();
|
let in_days = Regex::new(r"\bin\s+(\d+)\s+days?\b").unwrap();
|
||||||
if let Some(c) = in_days.captures(&lower) {
|
if let Some(c) = in_days.captures(&lower) {
|
||||||
@@ -33,6 +83,20 @@ pub fn extract_when_from(text: &str, now: NaiveDateTime) -> Option<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if date.is_none() && (lower.contains("this week") || lower.contains("next week")) {
|
||||||
|
let day_from_start = if week_start == "sunday" {
|
||||||
|
now.weekday().num_days_from_sunday() as i64
|
||||||
|
} else {
|
||||||
|
now.weekday().num_days_from_monday() as i64
|
||||||
|
};
|
||||||
|
let current_start = now.date() - Duration::days(day_from_start);
|
||||||
|
date = Some(if lower.contains("next week") {
|
||||||
|
current_start + Duration::weeks(1)
|
||||||
|
} else {
|
||||||
|
current_start
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if date.is_none() {
|
if date.is_none() {
|
||||||
let weekdays = [
|
let weekdays = [
|
||||||
("monday", Weekday::Mon),
|
("monday", Weekday::Mon),
|
||||||
@@ -76,20 +140,43 @@ pub fn extract_when_from(text: &str, now: NaiveDateTime) -> Option<String> {
|
|||||||
NaiveTime::from_hms_opt(hour, minute, 0)?
|
NaiveTime::from_hms_opt(hour, minute, 0)?
|
||||||
} else if let Some(c) = time24_re.captures(&lower) {
|
} else if let Some(c) = time24_re.captures(&lower) {
|
||||||
NaiveTime::from_hms_opt(c[1].parse().ok()?, c[2].parse().ok()?, 0)?
|
NaiveTime::from_hms_opt(c[1].parse().ok()?, c[2].parse().ok()?, 0)?
|
||||||
|
} else if lower.contains("morning") {
|
||||||
|
parse_clock(morning_time)?
|
||||||
|
} else if lower.contains("afternoon") {
|
||||||
|
parse_clock(afternoon_time)?
|
||||||
|
} else if lower.contains("evening") || lower.contains("tonight") {
|
||||||
|
parse_clock(evening_time)?
|
||||||
} else {
|
} else {
|
||||||
NaiveTime::from_hms_opt(9, 0, 0)?
|
parse_clock(default_time)?
|
||||||
};
|
};
|
||||||
|
|
||||||
let local = Local.from_local_datetime(&date.and_time(time)).single()?;
|
let local = Local.from_local_datetime(&date.and_time(time)).single()?;
|
||||||
Some(local.to_rfc3339())
|
Some(local.to_rfc3339())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn pretty_when(value: Option<&str>) -> String {
|
fn parse_clock(value: &str) -> Option<NaiveTime> {
|
||||||
|
NaiveTime::parse_from_str(value, "%H:%M").ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn format_when(value: Option<&str>, date_format: &str, clock_24h: bool) -> String {
|
||||||
let Some(value) = value else {
|
let Some(value) = value else {
|
||||||
return String::new();
|
return String::new();
|
||||||
};
|
};
|
||||||
chrono::DateTime::parse_from_rfc3339(value)
|
chrono::DateTime::parse_from_rfc3339(value)
|
||||||
.map(|d| d.format("%Y-%m-%d %H:%M").to_string())
|
.map(|d| {
|
||||||
|
let date = match date_format {
|
||||||
|
"us" => d.format("%m/%d/%Y").to_string(),
|
||||||
|
"european" => d.format("%d/%m/%Y").to_string(),
|
||||||
|
"long" => d.format("%b %-d, %Y").to_string(),
|
||||||
|
_ => d.format("%Y-%m-%d").to_string(),
|
||||||
|
};
|
||||||
|
let time = if clock_24h {
|
||||||
|
d.format("%H:%M").to_string()
|
||||||
|
} else {
|
||||||
|
d.format("%-I:%M%P").to_string()
|
||||||
|
};
|
||||||
|
format!("{date} {time}")
|
||||||
|
})
|
||||||
.unwrap_or_else(|_| value.to_owned())
|
.unwrap_or_else(|_| value.to_owned())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,4 +263,36 @@ mod tests {
|
|||||||
.contains("2026-08-30T09:00:00")
|
.contains("2026-08-30T09:00:00")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn honors_document_date_and_named_time_settings() {
|
||||||
|
let got = extract_when_from_configured(
|
||||||
|
"Planning on 18/08/2026 in the evening",
|
||||||
|
base(),
|
||||||
|
"dmy",
|
||||||
|
"monday",
|
||||||
|
"08:30",
|
||||||
|
"08:00",
|
||||||
|
"14:00",
|
||||||
|
"19:15",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(got.contains("2026-08-18T19:15:00"));
|
||||||
|
assert_eq!(
|
||||||
|
format_when(Some(&got), "european", false),
|
||||||
|
"18/08/2026 7:15pm"
|
||||||
|
);
|
||||||
|
let next_week = extract_when_from_configured(
|
||||||
|
"Review next week",
|
||||||
|
base(),
|
||||||
|
"ymd",
|
||||||
|
"sunday",
|
||||||
|
"09:00",
|
||||||
|
"09:00",
|
||||||
|
"13:00",
|
||||||
|
"18:00",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(next_week.contains("2026-08-23T09:00:00"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
154
src/preferences.rs
Normal file
154
src/preferences.rs
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
use std::{
|
||||||
|
env, fs,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(default, deny_unknown_fields)]
|
||||||
|
pub struct AppPreferences {
|
||||||
|
pub theme: String,
|
||||||
|
pub show_command_bar: bool,
|
||||||
|
pub show_rule_info: bool,
|
||||||
|
pub show_carriage_returns: bool,
|
||||||
|
pub item_marker: String,
|
||||||
|
pub autosave_minutes: u64,
|
||||||
|
pub confirm_destructive: bool,
|
||||||
|
pub date_format: String,
|
||||||
|
pub clock_24h: bool,
|
||||||
|
pub decimal_separator: String,
|
||||||
|
pub thousands_separator: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AppPreferences {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
theme: "classic".into(),
|
||||||
|
show_command_bar: true,
|
||||||
|
show_rule_info: true,
|
||||||
|
show_carriage_returns: false,
|
||||||
|
item_marker: "•".into(),
|
||||||
|
autosave_minutes: 5,
|
||||||
|
confirm_destructive: true,
|
||||||
|
date_format: "iso".into(),
|
||||||
|
clock_24h: true,
|
||||||
|
decimal_separator: ".".into(),
|
||||||
|
thousands_separator: ",".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppPreferences {
|
||||||
|
pub fn path() -> Result<PathBuf> {
|
||||||
|
let home =
|
||||||
|
env::var_os("HOME").context("HOME is not set; cannot locate user preferences")?;
|
||||||
|
Ok(PathBuf::from(home)
|
||||||
|
.join(".config")
|
||||||
|
.join("rogue-agenda")
|
||||||
|
.join("preferences.toml"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load() -> Result<(Self, PathBuf)> {
|
||||||
|
let path = Self::path()?;
|
||||||
|
let preferences = if path.exists() {
|
||||||
|
Self::load_from(&path)?
|
||||||
|
} else {
|
||||||
|
Self::default()
|
||||||
|
};
|
||||||
|
Ok((preferences, path))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_from(path: &Path) -> Result<Self> {
|
||||||
|
let source = fs::read_to_string(path)
|
||||||
|
.with_context(|| format!("could not read preferences from {}", path.display()))?;
|
||||||
|
let value: Self = toml::from_str(&source)
|
||||||
|
.with_context(|| format!("invalid TOML in {}", path.display()))?;
|
||||||
|
value.validate()?;
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_to(&self, path: &Path) -> Result<()> {
|
||||||
|
self.validate()?;
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)
|
||||||
|
.with_context(|| format!("could not create {}", parent.display()))?;
|
||||||
|
}
|
||||||
|
let body = format!(
|
||||||
|
"# Rogue Agenda user preferences\n{}",
|
||||||
|
toml::to_string_pretty(self).context("could not serialize user preferences")?
|
||||||
|
);
|
||||||
|
let temporary = path.with_extension("toml.tmp");
|
||||||
|
fs::write(&temporary, body)
|
||||||
|
.with_context(|| format!("could not write {}", temporary.display()))?;
|
||||||
|
fs::rename(&temporary, path)
|
||||||
|
.with_context(|| format!("could not replace {}", path.display()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate(&self) -> Result<()> {
|
||||||
|
if !matches!(self.theme.as_str(), "classic" | "mono" | "amber") {
|
||||||
|
bail!("theme must be classic, mono, or amber")
|
||||||
|
}
|
||||||
|
if !matches!(
|
||||||
|
self.date_format.as_str(),
|
||||||
|
"iso" | "us" | "european" | "long"
|
||||||
|
) {
|
||||||
|
bail!("date format must be iso, us, european, or long")
|
||||||
|
}
|
||||||
|
if self.item_marker.chars().count() != 1 {
|
||||||
|
bail!("item marker must be exactly one character")
|
||||||
|
}
|
||||||
|
if self.autosave_minutes > 60 {
|
||||||
|
bail!("autosave interval must be between 0 and 60 minutes")
|
||||||
|
}
|
||||||
|
for (name, separator) in [
|
||||||
|
("decimal separator", &self.decimal_separator),
|
||||||
|
("thousands separator", &self.thousands_separator),
|
||||||
|
] {
|
||||||
|
if separator.chars().count() != 1 {
|
||||||
|
bail!("{name} must be exactly one character")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if self.decimal_separator == self.thousands_separator {
|
||||||
|
bail!("decimal and thousands separators must be different")
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preferences_roundtrip_in_user_config_format() {
|
||||||
|
let directory = tempdir().unwrap();
|
||||||
|
let path = directory.path().join("preferences.toml");
|
||||||
|
let preferences = AppPreferences {
|
||||||
|
theme: "amber".into(),
|
||||||
|
item_marker: ">".into(),
|
||||||
|
autosave_minutes: 12,
|
||||||
|
date_format: "long".into(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
preferences.save_to(&path).unwrap();
|
||||||
|
assert_eq!(AppPreferences::load_from(&path).unwrap(), preferences);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preferences_reject_unknown_and_ambiguous_values() {
|
||||||
|
let directory = tempdir().unwrap();
|
||||||
|
let path = directory.path().join("preferences.toml");
|
||||||
|
fs::write(&path, "unknown = true\n").unwrap();
|
||||||
|
assert!(AppPreferences::load_from(&path).is_err());
|
||||||
|
let invalid = AppPreferences {
|
||||||
|
decimal_separator: ",".into(),
|
||||||
|
thousands_separator: ",".into(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(invalid.validate().is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
491
src/ui.rs
491
src/ui.rs
@@ -13,15 +13,46 @@ use crate::{
|
|||||||
app::{App, FormKind, Mode},
|
app::{App, FormKind, Mode},
|
||||||
filter,
|
filter,
|
||||||
model::{Item, ViewColumn},
|
model::{Item, ViewColumn},
|
||||||
parser::pretty_when,
|
parser::format_when,
|
||||||
};
|
};
|
||||||
|
|
||||||
const BLUE: Color = Color::Rgb(0, 0, 128);
|
#[derive(Clone, Copy)]
|
||||||
const CYAN: Color = Color::Cyan;
|
struct Palette {
|
||||||
const CANVAS: Color = Color::Rgb(192, 192, 192);
|
primary: Color,
|
||||||
const RED: Color = Color::Rgb(128, 0, 0);
|
accent: Color,
|
||||||
|
canvas: Color,
|
||||||
|
selection: Color,
|
||||||
|
foreground: Color,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn palette(theme: &str) -> Palette {
|
||||||
|
match theme {
|
||||||
|
"mono" => Palette {
|
||||||
|
primary: Color::Black,
|
||||||
|
accent: Color::White,
|
||||||
|
canvas: Color::Gray,
|
||||||
|
selection: Color::DarkGray,
|
||||||
|
foreground: Color::Black,
|
||||||
|
},
|
||||||
|
"amber" => Palette {
|
||||||
|
primary: Color::Black,
|
||||||
|
accent: Color::Rgb(255, 191, 0),
|
||||||
|
canvas: Color::Rgb(32, 24, 0),
|
||||||
|
selection: Color::Rgb(112, 56, 0),
|
||||||
|
foreground: Color::Rgb(255, 191, 0),
|
||||||
|
},
|
||||||
|
_ => Palette {
|
||||||
|
primary: Color::Rgb(0, 0, 128),
|
||||||
|
accent: Color::Cyan,
|
||||||
|
canvas: Color::Rgb(192, 192, 192),
|
||||||
|
selection: Color::Rgb(128, 0, 0),
|
||||||
|
foreground: Color::Black,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn draw(frame: &mut Frame<'_>, app: &mut App) {
|
pub fn draw(frame: &mut Frame<'_>, app: &mut App) {
|
||||||
|
let colors = palette(&app.preferences.theme);
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Block::default().style(Style::default().bg(Color::Black)),
|
Block::default().style(Style::default().bg(Color::Black)),
|
||||||
frame.area(),
|
frame.area(),
|
||||||
@@ -30,7 +61,7 @@ pub fn draw(frame: &mut Frame<'_>, app: &mut App) {
|
|||||||
if height < 10 || frame.area().width < 40 {
|
if height < 10 || frame.area().width < 40 {
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Paragraph::new("Rogue Agenda needs at least 40×10")
|
Paragraph::new("Rogue Agenda needs at least 40×10")
|
||||||
.style(Style::default().fg(Color::Yellow).bg(BLUE))
|
.style(Style::default().fg(colors.accent).bg(colors.primary))
|
||||||
.alignment(Alignment::Center),
|
.alignment(Alignment::Center),
|
||||||
frame.area(),
|
frame.area(),
|
||||||
);
|
);
|
||||||
@@ -39,7 +70,11 @@ pub fn draw(frame: &mut Frame<'_>, app: &mut App) {
|
|||||||
let chunks = Layout::default()
|
let chunks = Layout::default()
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
.constraints([
|
.constraints([
|
||||||
Constraint::Length(2),
|
Constraint::Length(if app.preferences.show_command_bar {
|
||||||
|
2
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}),
|
||||||
Constraint::Length(1),
|
Constraint::Length(1),
|
||||||
Constraint::Min(3),
|
Constraint::Min(3),
|
||||||
Constraint::Length(1),
|
Constraint::Length(1),
|
||||||
@@ -50,16 +85,21 @@ pub fn draw(frame: &mut Frame<'_>, app: &mut App) {
|
|||||||
draw_view_tabs(frame, app, chunks[1]);
|
draw_view_tabs(frame, app, chunks[1]);
|
||||||
draw_items(frame, app, chunks[2]);
|
draw_items(frame, app, chunks[2]);
|
||||||
draw_status(frame, app, chunks[3]);
|
draw_status(frame, app, chunks[3]);
|
||||||
|
if app.preferences.show_command_bar {
|
||||||
draw_commands(frame, app, chunks[4]);
|
draw_commands(frame, app, chunks[4]);
|
||||||
|
} else {
|
||||||
|
app.command_regions.clear();
|
||||||
|
}
|
||||||
draw_overlay(frame, app);
|
draw_overlay(frame, app);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_header(frame: &mut Frame<'_>, app: &App, area: Rect) {
|
fn draw_header(frame: &mut Frame<'_>, app: &App, area: Rect) {
|
||||||
|
let colors = palette(&app.preferences.theme);
|
||||||
let alarms = app.db.due_alarm_count().unwrap_or(0);
|
let alarms = app.db.due_alarm_count().unwrap_or(0);
|
||||||
|
let now = Local::now().to_rfc3339();
|
||||||
let right = format!(
|
let right = format!(
|
||||||
"{} {}{}",
|
"{}{}",
|
||||||
Local::now().format("%Y-%m-%d"),
|
formatted_when(app, Some(&now)),
|
||||||
Local::now().format("%H:%M"),
|
|
||||||
if alarms > 0 {
|
if alarms > 0 {
|
||||||
format!(" ALARM:{alarms}")
|
format!(" ALARM:{alarms}")
|
||||||
} else {
|
} else {
|
||||||
@@ -67,7 +107,15 @@ fn draw_header(frame: &mut Frame<'_>, app: &App, area: Rect) {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
let width = area.width as usize;
|
let width = area.width as usize;
|
||||||
let left1 = format!("File: {}", app.path.display());
|
let left1 = format!(
|
||||||
|
"File: {}{}",
|
||||||
|
app.path.display(),
|
||||||
|
if app.document_settings.description.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!(" — {}", app.document_settings.description)
|
||||||
|
}
|
||||||
|
);
|
||||||
let pad = width.saturating_sub(left1.chars().count() + right.chars().count());
|
let pad = width.saturating_sub(left1.chars().count() + right.chars().count());
|
||||||
let line1 = format!("{left1}{}{right}", " ".repeat(pad));
|
let line1 = format!("{left1}{}{right}", " ".repeat(pad));
|
||||||
let line2 = format!(
|
let line2 = format!(
|
||||||
@@ -83,7 +131,7 @@ fn draw_header(frame: &mut Frame<'_>, app: &App, area: Rect) {
|
|||||||
Paragraph::new(vec![Line::from(line1), Line::from(line2)]).style(
|
Paragraph::new(vec![Line::from(line1), Line::from(line2)]).style(
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(Color::White)
|
.fg(Color::White)
|
||||||
.bg(BLUE)
|
.bg(colors.primary)
|
||||||
.add_modifier(Modifier::BOLD),
|
.add_modifier(Modifier::BOLD),
|
||||||
),
|
),
|
||||||
area,
|
area,
|
||||||
@@ -91,9 +139,13 @@ fn draw_header(frame: &mut Frame<'_>, app: &App, area: Rect) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn draw_view_tabs(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
|
fn draw_view_tabs(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
|
||||||
|
let colors = palette(&app.preferences.theme);
|
||||||
app.view_regions.clear();
|
app.view_regions.clear();
|
||||||
let mut x = area.x;
|
let mut x = area.x;
|
||||||
frame.render_widget(Block::default().style(Style::default().bg(CANVAS)), area);
|
frame.render_widget(
|
||||||
|
Block::default().style(Style::default().bg(colors.canvas)),
|
||||||
|
area,
|
||||||
|
);
|
||||||
for (i, v) in app.views.iter().enumerate() {
|
for (i, v) in app.views.iter().enumerate() {
|
||||||
let w = (v.name.chars().count() + 2).min(20) as u16;
|
let w = (v.name.chars().count() + 2).min(20) as u16;
|
||||||
if x + w > area.right() {
|
if x + w > area.right() {
|
||||||
@@ -103,11 +155,11 @@ fn draw_view_tabs(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
|
|||||||
app.view_regions.push(r);
|
app.view_regions.push(r);
|
||||||
let st = if i == app.view_index {
|
let st = if i == app.view_index {
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(BLUE)
|
.fg(colors.primary)
|
||||||
.bg(Color::White)
|
.bg(Color::White)
|
||||||
.add_modifier(Modifier::BOLD)
|
.add_modifier(Modifier::BOLD)
|
||||||
} else {
|
} else {
|
||||||
Style::default().fg(Color::White).bg(BLUE)
|
Style::default().fg(Color::White).bg(colors.primary)
|
||||||
};
|
};
|
||||||
frame.render_widget(Paragraph::new(format!(" {} ", v.name)).style(st), r);
|
frame.render_widget(Paragraph::new(format!(" {} ", v.name)).style(st), r);
|
||||||
x += w + 1;
|
x += w + 1;
|
||||||
@@ -115,7 +167,11 @@ fn draw_view_tabs(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn draw_items(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
|
fn draw_items(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
|
||||||
frame.render_widget(Block::default().style(Style::default().bg(CANVAS)), area);
|
let colors = palette(&app.preferences.theme);
|
||||||
|
frame.render_widget(
|
||||||
|
Block::default().style(Style::default().bg(colors.canvas)),
|
||||||
|
area,
|
||||||
|
);
|
||||||
app.item_rows.clear();
|
app.item_rows.clear();
|
||||||
if app.items.is_empty() {
|
if app.items.is_empty() {
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
@@ -124,7 +180,7 @@ fn draw_items(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
|
|||||||
} else {
|
} else {
|
||||||
" No items match this search. Press Esc to clear it."
|
" No items match this search. Press Esc to clear it."
|
||||||
})
|
})
|
||||||
.style(Style::default().fg(BLUE).bg(CANVAS)),
|
.style(Style::default().fg(colors.primary).bg(colors.canvas)),
|
||||||
area,
|
area,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -204,16 +260,16 @@ fn draw_items(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
|
|||||||
DisplayLine::Section(heading) => lines.push(Line::from(Span::styled(
|
DisplayLine::Section(heading) => lines.push(Line::from(Span::styled(
|
||||||
format!("── {heading} "),
|
format!("── {heading} "),
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(BLUE)
|
.fg(colors.primary)
|
||||||
.bg(CANVAS)
|
.bg(colors.canvas)
|
||||||
.add_modifier(Modifier::BOLD),
|
.add_modifier(Modifier::BOLD),
|
||||||
))),
|
))),
|
||||||
DisplayLine::Header => lines.push(cells_line(
|
DisplayLine::Header => lines.push(cells_line(
|
||||||
columns.iter().map(|c| c.heading.clone()).collect(),
|
columns.iter().map(|c| c.heading.clone()).collect(),
|
||||||
&widths,
|
&widths,
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(BLUE)
|
.fg(colors.primary)
|
||||||
.bg(CANVAS)
|
.bg(colors.canvas)
|
||||||
.add_modifier(Modifier::BOLD),
|
.add_modifier(Modifier::BOLD),
|
||||||
)),
|
)),
|
||||||
DisplayLine::Item(index) => {
|
DisplayLine::Item(index) => {
|
||||||
@@ -221,16 +277,16 @@ fn draw_items(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
|
|||||||
let style = if *index == app.selected {
|
let style = if *index == app.selected {
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(Color::White)
|
.fg(Color::White)
|
||||||
.bg(RED)
|
.bg(colors.selection)
|
||||||
.add_modifier(Modifier::BOLD)
|
.add_modifier(Modifier::BOLD)
|
||||||
} else {
|
} else {
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(if item.done_at.is_some() {
|
.fg(if item.done_at.is_some() {
|
||||||
Color::DarkGray
|
Color::DarkGray
|
||||||
} else {
|
} else {
|
||||||
Color::Black
|
colors.foreground
|
||||||
})
|
})
|
||||||
.bg(CANVAS)
|
.bg(colors.canvas)
|
||||||
};
|
};
|
||||||
let cells = columns.iter().map(|c| column_value(c, item, app)).collect();
|
let cells = columns.iter().map(|c| column_value(c, item, app)).collect();
|
||||||
lines.push(cells_line(cells, &widths, style));
|
lines.push(cells_line(cells, &widths, style));
|
||||||
@@ -242,21 +298,21 @@ fn draw_items(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
|
|||||||
DisplayLine::Aggregate(indices) => {
|
DisplayLine::Aggregate(indices) => {
|
||||||
let cells = columns
|
let cells = columns
|
||||||
.iter()
|
.iter()
|
||||||
.map(|c| aggregate_value(c, indices, &app.items))
|
.map(|c| aggregate_value(c, indices, &app.items, app))
|
||||||
.collect();
|
.collect();
|
||||||
lines.push(cells_line(
|
lines.push(cells_line(
|
||||||
cells,
|
cells,
|
||||||
&widths,
|
&widths,
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(BLUE)
|
.fg(colors.primary)
|
||||||
.bg(CANVAS)
|
.bg(colors.canvas)
|
||||||
.add_modifier(Modifier::BOLD),
|
.add_modifier(Modifier::BOLD),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Paragraph::new(lines).style(Style::default().bg(CANVAS)),
|
Paragraph::new(lines).style(Style::default().bg(colors.canvas)),
|
||||||
area,
|
area,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -317,27 +373,34 @@ fn column_value(column: &ViewColumn, item: &Item, app: &App) -> String {
|
|||||||
} else if app.db.unmet_dependencies(item.id).unwrap_or(0) > 0 {
|
} else if app.db.unmet_dependencies(item.id).unwrap_or(0) > 0 {
|
||||||
"◦"
|
"◦"
|
||||||
} else {
|
} else {
|
||||||
"•"
|
&app.preferences.item_marker
|
||||||
};
|
};
|
||||||
format!("{marker} {}", item.text.replace('\n', " "))
|
format!("{marker} {}", item.text.replace('\n', " "))
|
||||||
}
|
}
|
||||||
"categories" => format_categories(item.category_names()),
|
"categories" => format_categories(item.category_names()),
|
||||||
"when" => pretty_when(item.when_at.as_deref()),
|
"when" => formatted_when(app, item.when_at.as_deref()),
|
||||||
"priority" => item.priority.to_string(),
|
"priority" => item.priority.to_string(),
|
||||||
"note" => item.note.replace('\n', " "),
|
"note" => item.note.replace(
|
||||||
|
'\n',
|
||||||
|
if app.preferences.show_carriage_returns {
|
||||||
|
"↵ "
|
||||||
|
} else {
|
||||||
|
" "
|
||||||
|
},
|
||||||
|
),
|
||||||
"value" => item
|
"value" => item
|
||||||
.numeric_value
|
.numeric_value
|
||||||
.map(|v| format!("{v:.2}"))
|
.map(|v| format_number(v, app))
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
"done" => pretty_when(item.done_at.as_deref()),
|
"done" => formatted_when(app, item.done_at.as_deref()),
|
||||||
"alarm" => pretty_when(item.alarm_at.as_deref()),
|
"alarm" => formatted_when(app, item.alarm_at.as_deref()),
|
||||||
"recurrence" => item.recurrence.clone(),
|
"recurrence" => item.recurrence.clone(),
|
||||||
"created" => pretty_when(Some(&item.created_at)),
|
"created" => formatted_when(app, Some(&item.created_at)),
|
||||||
"updated" => pretty_when(Some(&item.updated_at)),
|
"updated" => formatted_when(app, Some(&item.updated_at)),
|
||||||
_ => String::new(),
|
_ => String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fn aggregate_value(column: &ViewColumn, indices: &[usize], items: &[Item]) -> String {
|
fn aggregate_value(column: &ViewColumn, indices: &[usize], items: &[Item], app: &App) -> String {
|
||||||
if column.aggregate == "none" {
|
if column.aggregate == "none" {
|
||||||
return String::new();
|
return String::new();
|
||||||
}
|
}
|
||||||
@@ -362,50 +425,43 @@ fn aggregate_value(column: &ViewColumn, indices: &[usize], items: &[Item]) -> St
|
|||||||
"max" => values.iter().copied().fold(f64::NEG_INFINITY, f64::max),
|
"max" => values.iter().copied().fold(f64::NEG_INFINITY, f64::max),
|
||||||
_ => return String::new(),
|
_ => return String::new(),
|
||||||
};
|
};
|
||||||
format!("{} {value:.2}", column.aggregate)
|
format!("{} {}", column.aggregate, format_number(value, app))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_datebook(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
|
fn draw_datebook(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
|
||||||
|
let colors = palette(&app.preferences.theme);
|
||||||
let mut lines: Vec<Line> = vec![];
|
let mut lines: Vec<Line> = vec![];
|
||||||
let mut dates: HashMap<String, bool> = HashMap::new();
|
let mut dates: HashMap<String, bool> = HashMap::new();
|
||||||
for i in &app.items {
|
for i in &app.items {
|
||||||
let date = pretty_when(i.when_at.as_deref())
|
let formatted = formatted_when(app, i.when_at.as_deref());
|
||||||
.chars()
|
let (date, time) = formatted.rsplit_once(' ').unwrap_or((&formatted, ""));
|
||||||
.take(10)
|
let date = date.to_owned();
|
||||||
.collect::<String>();
|
|
||||||
if !dates.contains_key(&date) {
|
if !dates.contains_key(&date) {
|
||||||
lines.push(Line::from(Span::styled(
|
lines.push(Line::from(Span::styled(
|
||||||
format!("── {date} ──"),
|
format!("── {date} ──"),
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(BLUE)
|
.fg(colors.primary)
|
||||||
.bg(CANVAS)
|
.bg(colors.canvas)
|
||||||
.add_modifier(Modifier::BOLD),
|
.add_modifier(Modifier::BOLD),
|
||||||
)));
|
)));
|
||||||
dates.insert(date.clone(), true);
|
dates.insert(date.clone(), true);
|
||||||
}
|
}
|
||||||
let selected = app.items.get(app.selected).map(|s| s.id) == Some(i.id);
|
let selected = app.items.get(app.selected).map(|s| s.id) == Some(i.id);
|
||||||
let style = if selected {
|
let style = if selected {
|
||||||
Style::default().fg(Color::White).bg(RED)
|
Style::default().fg(Color::White).bg(colors.selection)
|
||||||
} else {
|
} else {
|
||||||
Style::default().fg(Color::Black).bg(CANVAS)
|
Style::default().fg(colors.foreground).bg(colors.canvas)
|
||||||
};
|
};
|
||||||
let y = area.y + lines.len() as u16;
|
let y = area.y + lines.len() as u16;
|
||||||
app.item_rows
|
app.item_rows
|
||||||
.push((Rect::new(area.x, y, area.width, 1), i.id));
|
.push((Rect::new(area.x, y, area.width, 1), i.id));
|
||||||
lines.push(Line::from(Span::styled(
|
lines.push(Line::from(Span::styled(
|
||||||
format!(
|
format!(" {} {}", time, i.text),
|
||||||
" {} {}",
|
|
||||||
pretty_when(i.when_at.as_deref())
|
|
||||||
.chars()
|
|
||||||
.skip(11)
|
|
||||||
.collect::<String>(),
|
|
||||||
i.text
|
|
||||||
),
|
|
||||||
style,
|
style,
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Paragraph::new(lines).style(Style::default().bg(CANVAS)),
|
Paragraph::new(lines).style(Style::default().bg(colors.canvas)),
|
||||||
area,
|
area,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -421,7 +477,37 @@ fn format_categories(s: String) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn formatted_when(app: &App, value: Option<&str>) -> String {
|
||||||
|
format_when(
|
||||||
|
value,
|
||||||
|
&app.preferences.date_format,
|
||||||
|
app.preferences.clock_24h,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_number(value: f64, app: &App) -> String {
|
||||||
|
let negative = value.is_sign_negative();
|
||||||
|
let raw = format!("{:.2}", value.abs());
|
||||||
|
let (integer, fraction) = raw.split_once('.').unwrap_or((&raw, "00"));
|
||||||
|
let mut grouped = String::new();
|
||||||
|
for (index, c) in integer.chars().rev().enumerate() {
|
||||||
|
if index > 0 && index % 3 == 0 {
|
||||||
|
grouped.push_str(&app.preferences.thousands_separator);
|
||||||
|
}
|
||||||
|
grouped.push(c);
|
||||||
|
}
|
||||||
|
let integer = grouped.chars().rev().collect::<String>();
|
||||||
|
format!(
|
||||||
|
"{}{}{}{}",
|
||||||
|
if negative { "-" } else { "" },
|
||||||
|
integer,
|
||||||
|
app.preferences.decimal_separator,
|
||||||
|
fraction
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn draw_status(frame: &mut Frame<'_>, app: &App, area: Rect) {
|
fn draw_status(frame: &mut Frame<'_>, app: &App, area: Rect) {
|
||||||
|
let colors = palette(&app.preferences.theme);
|
||||||
let totals = app
|
let totals = app
|
||||||
.items
|
.items
|
||||||
.iter()
|
.iter()
|
||||||
@@ -431,9 +517,9 @@ fn draw_status(frame: &mut Frame<'_>, app: &App, area: Rect) {
|
|||||||
String::new()
|
String::new()
|
||||||
} else {
|
} else {
|
||||||
format!(
|
format!(
|
||||||
" Σ {:.2} avg {:.2}",
|
" Σ {} avg {}",
|
||||||
totals.iter().sum::<f64>(),
|
format_number(totals.iter().sum::<f64>(), app),
|
||||||
totals.iter().sum::<f64>() / totals.len() as f64
|
format_number(totals.iter().sum::<f64>() / totals.len() as f64, app)
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
let right = format!("{} item(s){}", app.items.len(), aggregate);
|
let right = format!("{} item(s){}", app.items.len(), aggregate);
|
||||||
@@ -443,12 +529,13 @@ fn draw_status(frame: &mut Frame<'_>, app: &App, area: Rect) {
|
|||||||
as usize;
|
as usize;
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Paragraph::new(format!(" {}{}{right}", app.status, " ".repeat(pad)))
|
Paragraph::new(format!(" {}{}{right}", app.status, " ".repeat(pad)))
|
||||||
.style(Style::default().fg(Color::Yellow).bg(BLUE)),
|
.style(Style::default().fg(colors.accent).bg(colors.primary)),
|
||||||
area,
|
area,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_commands(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
|
fn draw_commands(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
|
||||||
|
let colors = palette(&app.preferences.theme);
|
||||||
let labels = [
|
let labels = [
|
||||||
"Help", "Edit", "Choices", "Done", "Note", "Props", "Mark", "Vw Mgr", "Cat Mgr", "Menu",
|
"Help", "Edit", "Choices", "Done", "Note", "Props", "Mark", "Vw Mgr", "Cat Mgr", "Menu",
|
||||||
];
|
];
|
||||||
@@ -465,20 +552,23 @@ fn draw_commands(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
|
|||||||
Line::from(labels[i]),
|
Line::from(labels[i]),
|
||||||
])
|
])
|
||||||
.alignment(Alignment::Center)
|
.alignment(Alignment::Center)
|
||||||
.style(Style::default().fg(Color::White).bg(BLUE)),
|
.style(Style::default().fg(Color::White).bg(colors.primary)),
|
||||||
*r,
|
*r,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_overlay(frame: &mut Frame<'_>, app: &App) {
|
fn draw_overlay(frame: &mut Frame<'_>, app: &App) {
|
||||||
|
let colors = palette(&app.preferences.theme);
|
||||||
match &app.mode {
|
match &app.mode {
|
||||||
Mode::Normal => {}
|
Mode::Normal => {}
|
||||||
Mode::Help => {
|
Mode::Help => {
|
||||||
let text = Text::from(vec![
|
let text = Text::from(vec![
|
||||||
Line::from(Span::styled(
|
Line::from(Span::styled(
|
||||||
"ROGUE AGENDA HELP",
|
"ROGUE AGENDA HELP",
|
||||||
Style::default().fg(CYAN).add_modifier(Modifier::BOLD),
|
Style::default()
|
||||||
|
.fg(colors.accent)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
)),
|
)),
|
||||||
Line::from(""),
|
Line::from(""),
|
||||||
Line::from(
|
Line::from(
|
||||||
@@ -499,6 +589,7 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &App) {
|
|||||||
center(frame.area(), 74, 15),
|
center(frame.area(), 74, 15),
|
||||||
"Help",
|
"Help",
|
||||||
Paragraph::new(text).wrap(Wrap { trim: false }),
|
Paragraph::new(text).wrap(Wrap { trim: false }),
|
||||||
|
colors,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Mode::Input(input) => {
|
Mode::Input(input) => {
|
||||||
@@ -522,7 +613,13 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &App) {
|
|||||||
Style::default().fg(Color::DarkGray),
|
Style::default().fg(Color::DarkGray),
|
||||||
)))
|
)))
|
||||||
} else {
|
} else {
|
||||||
Text::from(input.value.as_str())
|
Text::from(
|
||||||
|
if app.preferences.show_carriage_returns && input.multiline {
|
||||||
|
input.value.replace('\n', "↵\n")
|
||||||
|
} else {
|
||||||
|
input.value.clone()
|
||||||
|
},
|
||||||
|
)
|
||||||
};
|
};
|
||||||
popup(
|
popup(
|
||||||
frame,
|
frame,
|
||||||
@@ -532,6 +629,7 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &App) {
|
|||||||
vertical_scroll.min(u16::MAX as usize) as u16,
|
vertical_scroll.min(u16::MAX as usize) as u16,
|
||||||
horizontal_scroll.min(u16::MAX as usize) as u16,
|
horizontal_scroll.min(u16::MAX as usize) as u16,
|
||||||
)),
|
)),
|
||||||
|
colors,
|
||||||
);
|
);
|
||||||
frame.set_cursor_position((
|
frame.set_cursor_position((
|
||||||
inner.x + cursor_column.saturating_sub(horizontal_scroll) as u16,
|
inner.x + cursor_column.saturating_sub(horizontal_scroll) as u16,
|
||||||
@@ -553,14 +651,18 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &App) {
|
|||||||
[
|
[
|
||||||
Line::from(Span::styled(
|
Line::from(Span::styled(
|
||||||
*n,
|
*n,
|
||||||
Style::default().fg(if i == p.field { Color::Yellow } else { CYAN }),
|
Style::default().fg(if i == p.field {
|
||||||
|
Color::Yellow
|
||||||
|
} else {
|
||||||
|
colors.accent
|
||||||
|
}),
|
||||||
)),
|
)),
|
||||||
Line::from(Span::styled(
|
Line::from(Span::styled(
|
||||||
format!("> {}", p.values[i]),
|
format!("> {}", p.values[i]),
|
||||||
Style::default().fg(Color::White).bg(if i == p.field {
|
Style::default().fg(Color::White).bg(if i == p.field {
|
||||||
RED
|
colors.selection
|
||||||
} else {
|
} else {
|
||||||
BLUE
|
colors.primary
|
||||||
}),
|
}),
|
||||||
)),
|
)),
|
||||||
]
|
]
|
||||||
@@ -575,6 +677,7 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &App) {
|
|||||||
center(frame.area(), 78, 18),
|
center(frame.area(), 78, 18),
|
||||||
"Item Properties",
|
"Item Properties",
|
||||||
Paragraph::new(lines),
|
Paragraph::new(lines),
|
||||||
|
colors,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Mode::Form(form) => {
|
Mode::Form(form) => {
|
||||||
@@ -606,35 +709,79 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &App) {
|
|||||||
],
|
],
|
||||||
"Filters: category=Work and (open or priority<=2)",
|
"Filters: category=Work and (open or priority<=2)",
|
||||||
),
|
),
|
||||||
|
FormKind::Preferences => (
|
||||||
|
"Preferences (~/.config/rogue-agenda/preferences.toml)",
|
||||||
|
vec![
|
||||||
|
"Theme (classic/mono/amber)",
|
||||||
|
"Show command bar (yes/no)",
|
||||||
|
"Show rule info (yes/no)",
|
||||||
|
"Show return markers (yes/no)",
|
||||||
|
"Item marker (one character)",
|
||||||
|
"Autosave minutes (0-60)",
|
||||||
|
"Confirm destructive actions",
|
||||||
|
"Date format (iso/us/european/long)",
|
||||||
|
"24-hour clock (yes/no)",
|
||||||
|
"Decimal separator",
|
||||||
|
"Thousands separator",
|
||||||
|
],
|
||||||
|
"Application-wide settings are stored as TOML in your home directory.",
|
||||||
|
),
|
||||||
|
FormKind::DocumentSettings => (
|
||||||
|
"Document Settings (stored inside this .agnd)",
|
||||||
|
vec![
|
||||||
|
"Description",
|
||||||
|
"Backup on open (yes/no)",
|
||||||
|
"Trash (on-demand/on-close/end-of-day/immediate)",
|
||||||
|
"Completed items (keep/trash)",
|
||||||
|
"Automatic filing (yes/no)",
|
||||||
|
"Numeric date order (ymd/mdy/dmy)",
|
||||||
|
"Week starts (monday/sunday)",
|
||||||
|
"Default time (HH:MM)",
|
||||||
|
"Morning time (HH:MM)",
|
||||||
|
"Afternoon time (HH:MM)",
|
||||||
|
"Evening time (HH:MM)",
|
||||||
|
"Note tab width (1-16)",
|
||||||
|
],
|
||||||
|
"These settings travel with the SQLite document.",
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
let preview = match form.kind {
|
||||||
|
FormKind::Category(_) | FormKind::View(_) => {
|
||||||
let filter_source = match form.kind {
|
let filter_source = match form.kind {
|
||||||
FormKind::Category(_) => form.values.get(5),
|
FormKind::Category(_) => form.values.get(5),
|
||||||
FormKind::View(_) => form.values.get(3),
|
FormKind::View(_) => form.values.get(3),
|
||||||
|
FormKind::Preferences | FormKind::DocumentSettings => None,
|
||||||
}
|
}
|
||||||
.map(String::as_str)
|
.map(String::as_str)
|
||||||
.unwrap_or("");
|
.unwrap_or("");
|
||||||
let preview = match app.db.count_matching_filter(filter_source) {
|
match app.db.count_matching_filter(filter_source) {
|
||||||
Ok(n) => format!("Preview: {n} existing item(s) match"),
|
Ok(n) => format!("Preview: {n} existing item(s) match"),
|
||||||
Err(e) => format!("Preview error: {e}"),
|
Err(e) => format!("Preview error: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FormKind::Preferences => format!("File: {}", app.preferences_path.display()),
|
||||||
|
FormKind::DocumentSettings => {
|
||||||
|
"Document preferences are saved transactionally".into()
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let lines = names
|
let lines = names
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.flat_map(|(i, name)| {
|
.map(|(i, name)| {
|
||||||
[
|
|
||||||
Line::from(Span::styled(
|
Line::from(Span::styled(
|
||||||
*name,
|
format!("{name:<50} {}", form.values[i]),
|
||||||
Style::default().fg(if i == form.field { Color::Yellow } else { CYAN }),
|
Style::default()
|
||||||
)),
|
.fg(if i == form.field {
|
||||||
Line::from(Span::styled(
|
Color::White
|
||||||
format!("> {}", form.values[i]),
|
|
||||||
Style::default().fg(Color::White).bg(if i == form.field {
|
|
||||||
RED
|
|
||||||
} else {
|
} else {
|
||||||
BLUE
|
colors.accent
|
||||||
|
})
|
||||||
|
.bg(if i == form.field {
|
||||||
|
colors.selection
|
||||||
|
} else {
|
||||||
|
colors.primary
|
||||||
}),
|
}),
|
||||||
)),
|
))
|
||||||
]
|
|
||||||
})
|
})
|
||||||
.chain([
|
.chain([
|
||||||
Line::from(""),
|
Line::from(""),
|
||||||
@@ -645,9 +792,10 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &App) {
|
|||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
popup(
|
popup(
|
||||||
frame,
|
frame,
|
||||||
center(frame.area(), 96, 24),
|
center(frame.area(), 110, (names.len() as u16 + 6).clamp(12, 22)),
|
||||||
title,
|
title,
|
||||||
Paragraph::new(lines),
|
Paragraph::new(lines),
|
||||||
|
colors,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Mode::Dependencies {
|
Mode::Dependencies {
|
||||||
@@ -678,12 +826,12 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &App) {
|
|||||||
.block(
|
.block(
|
||||||
Block::bordered()
|
Block::bordered()
|
||||||
.title(" Prerequisites (Space toggles · Enter closes) ")
|
.title(" Prerequisites (Space toggles · Enter closes) ")
|
||||||
.style(Style::default().fg(Color::White).bg(BLUE)),
|
.style(Style::default().fg(Color::White).bg(colors.primary)),
|
||||||
)
|
)
|
||||||
.highlight_style(
|
.highlight_style(
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(Color::White)
|
.fg(Color::White)
|
||||||
.bg(RED)
|
.bg(colors.selection)
|
||||||
.add_modifier(Modifier::BOLD),
|
.add_modifier(Modifier::BOLD),
|
||||||
),
|
),
|
||||||
area,
|
area,
|
||||||
@@ -720,12 +868,12 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &App) {
|
|||||||
.title(
|
.title(
|
||||||
" View Manager (n add · e edit · Ctrl-↑↓ move · Delete discard) ",
|
" View Manager (n add · e edit · Ctrl-↑↓ move · Delete discard) ",
|
||||||
)
|
)
|
||||||
.style(Style::default().fg(Color::White).bg(BLUE)),
|
.style(Style::default().fg(Color::White).bg(colors.primary)),
|
||||||
)
|
)
|
||||||
.highlight_style(
|
.highlight_style(
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(Color::White)
|
.fg(Color::White)
|
||||||
.bg(RED)
|
.bg(colors.selection)
|
||||||
.add_modifier(Modifier::BOLD),
|
.add_modifier(Modifier::BOLD),
|
||||||
),
|
),
|
||||||
area,
|
area,
|
||||||
@@ -735,24 +883,43 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &App) {
|
|||||||
Mode::Menu => {
|
Mode::Menu => {
|
||||||
popup(
|
popup(
|
||||||
frame,
|
frame,
|
||||||
center(frame.area(), 46, 10),
|
center(frame.area(), 58, 14),
|
||||||
"Rogue Agenda Menu",
|
"Rogue Agenda Menu",
|
||||||
Paragraph::new(vec![
|
Paragraph::new(vec![
|
||||||
Line::from(" n New item"),
|
Line::from(" n New item"),
|
||||||
Line::from(" s Save / checkpoint"),
|
Line::from(" s Save / checkpoint"),
|
||||||
Line::from(" h Help"),
|
Line::from(" h Help"),
|
||||||
Line::from(" r Prerequisites"),
|
Line::from(" r Prerequisites"),
|
||||||
|
Line::from(" p Preferences (user TOML)"),
|
||||||
|
Line::from(" d Document settings (.agnd)"),
|
||||||
|
Line::from(" b Back up document now"),
|
||||||
|
Line::from(" t Empty Trash permanently"),
|
||||||
Line::from(" q Quit"),
|
Line::from(" q Quit"),
|
||||||
Line::from(""),
|
Line::from(""),
|
||||||
Line::from(" Esc closes the menu"),
|
Line::from(" Esc closes the menu"),
|
||||||
])
|
])
|
||||||
.style(Style::default().fg(Color::White)),
|
.style(Style::default().fg(Color::White)),
|
||||||
|
colors,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Mode::Confirm { prompt, .. } => {
|
||||||
|
popup(
|
||||||
|
frame,
|
||||||
|
center(frame.area(), 66, 7),
|
||||||
|
"Confirm",
|
||||||
|
Paragraph::new(vec![
|
||||||
|
Line::from(prompt.as_str()),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from("Enter/Y confirms · N/Esc cancels"),
|
||||||
|
]),
|
||||||
|
colors,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_category_list(frame: &mut Frame<'_>, app: &App, title: &str, assign: bool) {
|
fn draw_category_list(frame: &mut Frame<'_>, app: &App, title: &str, assign: bool) {
|
||||||
|
let colors = palette(&app.preferences.theme);
|
||||||
let assigned = app
|
let assigned = app
|
||||||
.selected_item()
|
.selected_item()
|
||||||
.and_then(|i| app.db.item_category_ids(i.id).ok())
|
.and_then(|i| app.db.item_category_ids(i.id).ok())
|
||||||
@@ -785,8 +952,8 @@ fn draw_category_list(frame: &mut Frame<'_>, app: &App, title: &str, assign: boo
|
|||||||
.and_then(|p| names.get(&p))
|
.and_then(|p| names.get(&p))
|
||||||
.map(|p| format!(" ({p})"))
|
.map(|p| format!(" ({p})"))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let rule = app
|
let rule = if app.preferences.show_rule_info {
|
||||||
.db
|
app.db
|
||||||
.category_rules(c.id)
|
.category_rules(c.id)
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|rules| rules.into_iter().next())
|
.and_then(|rules| rules.into_iter().next())
|
||||||
@@ -796,7 +963,10 @@ fn draw_category_list(frame: &mut Frame<'_>, app: &App, title: &str, assign: boo
|
|||||||
r.condition_expr, r.action_kind, r.action_value
|
r.condition_expr, r.action_kind, r.action_value
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default()
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
ListItem::new(format!(
|
ListItem::new(format!(
|
||||||
"{marker} {indent}{}{} {}{}",
|
"{marker} {indent}{}{} {}{}",
|
||||||
c.name,
|
c.name,
|
||||||
@@ -818,12 +988,12 @@ fn draw_category_list(frame: &mut Frame<'_>, app: &App, title: &str, assign: boo
|
|||||||
.block(
|
.block(
|
||||||
Block::bordered()
|
Block::bordered()
|
||||||
.title(format!(" {title} "))
|
.title(format!(" {title} "))
|
||||||
.style(Style::default().fg(Color::White).bg(BLUE)),
|
.style(Style::default().fg(Color::White).bg(colors.primary)),
|
||||||
)
|
)
|
||||||
.highlight_style(
|
.highlight_style(
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(Color::White)
|
.fg(Color::White)
|
||||||
.bg(RED)
|
.bg(colors.selection)
|
||||||
.add_modifier(Modifier::BOLD),
|
.add_modifier(Modifier::BOLD),
|
||||||
),
|
),
|
||||||
area,
|
area,
|
||||||
@@ -831,18 +1001,18 @@ fn draw_category_list(frame: &mut Frame<'_>, app: &App, title: &str, assign: boo
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn popup(frame: &mut Frame<'_>, area: Rect, title: &str, widget: Paragraph<'_>) {
|
fn popup(frame: &mut Frame<'_>, area: Rect, title: &str, widget: Paragraph<'_>, colors: Palette) {
|
||||||
frame.render_widget(Clear, area);
|
frame.render_widget(Clear, area);
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Block::default()
|
Block::default()
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.title(format!(" {title} "))
|
.title(format!(" {title} "))
|
||||||
.border_style(Style::default().fg(CYAN))
|
.border_style(Style::default().fg(colors.accent))
|
||||||
.style(Style::default().bg(BLUE)),
|
.style(Style::default().bg(colors.primary)),
|
||||||
area,
|
area,
|
||||||
);
|
);
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
widget.style(Style::default().fg(Color::White).bg(BLUE)),
|
widget.style(Style::default().fg(Color::White).bg(colors.primary)),
|
||||||
area.inner(Margin {
|
area.inner(Margin {
|
||||||
horizontal: 1,
|
horizontal: 1,
|
||||||
vertical: 1,
|
vertical: 1,
|
||||||
@@ -863,7 +1033,7 @@ fn center(area: Rect, width: u16, height: u16) -> Rect {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::{app::App, db::Database, model::ItemChanges};
|
use crate::{app::App, db::Database, model::ItemChanges, preferences::AppPreferences};
|
||||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
Terminal,
|
Terminal,
|
||||||
@@ -876,7 +1046,13 @@ mod tests {
|
|||||||
let p = d.path().join("ui.agnd");
|
let p = d.path().join("ui.agnd");
|
||||||
let mut db = Database::open(&p).unwrap();
|
let mut db = Database::open(&p).unwrap();
|
||||||
db.add_item("Call Grace tomorrow").unwrap();
|
db.add_item("Call Grace tomorrow").unwrap();
|
||||||
let mut app = App::new(db, p).unwrap();
|
let mut app = App::new_with_preferences(
|
||||||
|
db,
|
||||||
|
p,
|
||||||
|
AppPreferences::default(),
|
||||||
|
d.path().join("preferences.toml"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
let backend = TestBackend::new(100, 30);
|
let backend = TestBackend::new(100, 30);
|
||||||
let mut term = Terminal::new(backend).unwrap();
|
let mut term = Terminal::new(backend).unwrap();
|
||||||
term.draw(|f| draw(f, &mut app)).unwrap();
|
term.draw(|f| draw(f, &mut app)).unwrap();
|
||||||
@@ -920,7 +1096,13 @@ mod tests {
|
|||||||
"Urgent work|priority=1",
|
"Urgent work|priority=1",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let mut app = App::new(db, p).unwrap();
|
let mut app = App::new_with_preferences(
|
||||||
|
db,
|
||||||
|
p,
|
||||||
|
AppPreferences::default(),
|
||||||
|
d.path().join("preferences.toml"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
app.view_index = app.views.iter().position(|v| v.id == view_id).unwrap();
|
app.view_index = app.views.iter().position(|v| v.id == view_id).unwrap();
|
||||||
app.refresh().unwrap();
|
app.refresh().unwrap();
|
||||||
let backend = TestBackend::new(90, 24);
|
let backend = TestBackend::new(90, 24);
|
||||||
@@ -945,7 +1127,13 @@ mod tests {
|
|||||||
let p = d.path().join("compact.agnd");
|
let p = d.path().join("compact.agnd");
|
||||||
let mut db = Database::open(&p).unwrap();
|
let mut db = Database::open(&p).unwrap();
|
||||||
db.add_item("Compact item tomorrow").unwrap();
|
db.add_item("Compact item tomorrow").unwrap();
|
||||||
let mut app = App::new(db, p).unwrap();
|
let mut app = App::new_with_preferences(
|
||||||
|
db,
|
||||||
|
p,
|
||||||
|
AppPreferences::default(),
|
||||||
|
d.path().join("preferences.toml"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
let backend = TestBackend::new(50, 15);
|
let backend = TestBackend::new(50, 15);
|
||||||
let mut term = Terminal::new(backend).unwrap();
|
let mut term = Terminal::new(backend).unwrap();
|
||||||
term.draw(|f| draw(f, &mut app)).unwrap();
|
term.draw(|f| draw(f, &mut app)).unwrap();
|
||||||
@@ -966,7 +1154,13 @@ mod tests {
|
|||||||
let p = d.path().join("notes.agnd");
|
let p = d.path().join("notes.agnd");
|
||||||
let mut db = Database::open(&p).unwrap();
|
let mut db = Database::open(&p).unwrap();
|
||||||
db.add_item("Write release notes").unwrap();
|
db.add_item("Write release notes").unwrap();
|
||||||
let mut app = App::new(db, p).unwrap();
|
let mut app = App::new_with_preferences(
|
||||||
|
db,
|
||||||
|
p,
|
||||||
|
AppPreferences::default(),
|
||||||
|
d.path().join("preferences.toml"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
app.handle_key(KeyEvent::new(KeyCode::F(5), KeyModifiers::NONE))
|
app.handle_key(KeyEvent::new(KeyCode::F(5), KeyModifiers::NONE))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
for c in "first line".chars() {
|
for c in "first line".chars() {
|
||||||
@@ -1006,4 +1200,105 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(app.items[0].note, "first line\nsecond line");
|
assert_eq!(app.items[0].note, "first line\nsecond line");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn application_preferences_change_the_workspace_and_open_from_the_menu() {
|
||||||
|
let directory = tempdir().unwrap();
|
||||||
|
let path = directory.path().join("preferences-ui.agnd");
|
||||||
|
let mut db = Database::open(&path).unwrap();
|
||||||
|
let item = db.add_item("Configured display item").unwrap();
|
||||||
|
db.update_item(
|
||||||
|
item,
|
||||||
|
&ItemChanges {
|
||||||
|
when_at: Some(Some("2026-08-17T15:30:00+00:00".into())),
|
||||||
|
numeric_value: Some(Some(1234.5)),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let preferences = AppPreferences {
|
||||||
|
theme: "amber".into(),
|
||||||
|
show_command_bar: false,
|
||||||
|
item_marker: ">".into(),
|
||||||
|
date_format: "us".into(),
|
||||||
|
clock_24h: false,
|
||||||
|
decimal_separator: ",".into(),
|
||||||
|
thousands_separator: ".".into(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let mut app = App::new_with_preferences(
|
||||||
|
db,
|
||||||
|
path,
|
||||||
|
preferences,
|
||||||
|
directory.path().join("preferences.toml"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let backend = TestBackend::new(100, 30);
|
||||||
|
let mut terminal = Terminal::new(backend).unwrap();
|
||||||
|
terminal.draw(|frame| draw(frame, &mut app)).unwrap();
|
||||||
|
let screen = terminal
|
||||||
|
.backend()
|
||||||
|
.buffer()
|
||||||
|
.content
|
||||||
|
.iter()
|
||||||
|
.map(|cell| cell.symbol())
|
||||||
|
.collect::<String>();
|
||||||
|
assert!(screen.contains("> Configured display item"));
|
||||||
|
assert!(screen.contains("08/17/2026 3:30pm"));
|
||||||
|
assert!(screen.contains("1.234,50"));
|
||||||
|
assert!(!screen.contains("Cat Mgr"));
|
||||||
|
|
||||||
|
app.handle_key(KeyEvent::new(KeyCode::F(10), KeyModifiers::NONE))
|
||||||
|
.unwrap();
|
||||||
|
app.handle_key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE))
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
app.mode,
|
||||||
|
Mode::Form(crate::app::FormState {
|
||||||
|
kind: FormKind::Preferences,
|
||||||
|
..
|
||||||
|
})
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn document_editor_and_confirmation_use_saved_settings() {
|
||||||
|
let directory = tempdir().unwrap();
|
||||||
|
let path = directory.path().join("document-ui.agnd");
|
||||||
|
let mut db = Database::open(&path).unwrap();
|
||||||
|
let mut settings = db.document_settings().unwrap();
|
||||||
|
settings.note_tab_width = 3;
|
||||||
|
settings.trash_policy = "immediate".into();
|
||||||
|
db.save_document_settings(&settings).unwrap();
|
||||||
|
db.add_item("Disposable item").unwrap();
|
||||||
|
let mut app = App::new_with_preferences(
|
||||||
|
db,
|
||||||
|
path,
|
||||||
|
AppPreferences::default(),
|
||||||
|
directory.path().join("preferences.toml"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
app.handle_key(KeyEvent::new(KeyCode::F(5), KeyModifiers::NONE))
|
||||||
|
.unwrap();
|
||||||
|
app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE))
|
||||||
|
.unwrap();
|
||||||
|
app.handle_key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE))
|
||||||
|
.unwrap();
|
||||||
|
app.handle_key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(app.items[0].note, " x");
|
||||||
|
|
||||||
|
app.handle_key(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE))
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(app.mode, Mode::Confirm { .. }));
|
||||||
|
app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(app.items.len(), 1);
|
||||||
|
app.handle_key(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE))
|
||||||
|
.unwrap();
|
||||||
|
app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
|
||||||
|
.unwrap();
|
||||||
|
assert!(app.items.is_empty());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user