From f53ed36f31b13d27cc7e10940a17b7ef27c33276 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sun, 16 Aug 2026 20:08:27 +0000 Subject: [PATCH] preferences added --- Cargo.lock | 80 ++++++- Cargo.toml | 2 + PROJECT.md | 13 +- README.md | 30 +++ src/app.rs | 310 ++++++++++++++++++++++++-- src/db.rs | 304 ++++++++++++++++++++++++-- src/main.rs | 4 +- src/model.rs | 35 +++ src/parser.rs | 137 +++++++++++- src/preferences.rs | 154 +++++++++++++ src/ui.rs | 529 +++++++++++++++++++++++++++++++++++---------- 11 files changed, 1425 insertions(+), 173 deletions(-) create mode 100644 src/preferences.rs diff --git a/Cargo.lock b/Cargo.lock index 59b0a19..2ae62fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -177,9 +177,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.4.3" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" +checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e" dependencies = [ "find-msvc-tools", "shlex", @@ -506,9 +506,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.11" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" +checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" [[package]] name = "finl_unicode" @@ -664,6 +664,16 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "indoc" version = "2.0.7" @@ -1305,7 +1315,9 @@ dependencies = [ "ratatui", "regex", "rusqlite", + "serde", "tempfile", + "toml", ] [[package]] @@ -1409,6 +1421,15 @@ dependencies = [ "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]] name = "sha2" version = "0.10.9" @@ -1703,6 +1724,45 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "typenum" version = "1.20.1" @@ -2005,6 +2065,18 @@ dependencies = [ "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]] name = "wit-bindgen" version = "0.57.1" diff --git a/Cargo.toml b/Cargo.toml index c67eafb..ae295cd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,8 @@ crossterm = "0.29" ratatui = "0.30" regex = "1.13" rusqlite = "0.40" +serde = { version = "1.0", features = ["derive"] } +toml = "0.9" [dev-dependencies] tempfile = "3.27" diff --git a/PROJECT.md b/PROJECT.md index 25d6707..25e90f2 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -74,6 +74,14 @@ Research URLs: item editor, property editor, notes, category chooser, help, and status messages. - [x] Marking and bulk completion; Trash view, soft discard, and recovery. - [x] Responsive wide/compact rendering and mouse row/command/view interaction. +- [x] 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, and iCalendar export with notes, categories, dates, priority, completion, and recurring `RRULE` schedules. @@ -89,8 +97,9 @@ Research URLs: omitted by design, while deterministic filing actions are supported. - [x] Printing: view-aware Markdown and styled HTML reports are the portable print/preview path, alongside CSV and JSON data exports. -- [x] Backups: SQLite durability plus explicit checkpoint replaces proprietary - backup-file rotation. Ordinary filesystem backup tools work on `.agnd` files. +- [x] Backups: SQLite durability is supplemented by explicit/manual and + 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 the DOS resident accessory. - [x] Macros: conventional keys and direct commands replace keystroke-replay macros. diff --git a/README.md b/README.md index 5b19b49..a04e615 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,8 @@ database with the `.agnd` extension. transactional SQLite persistence. - Responsive compact/wide layouts, keyboard navigation, mouse selection, 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 @@ -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 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 Open the View Manager with `F8`. Press `n` to create a view, `e` to edit one, diff --git a/src/app.rs b/src/app.rs index 99160f7..3371ffc 100644 --- a/src/app.rs +++ b/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 ratatui::layout::Rect; use crate::{ db::Database, - model::{Category, Item, ItemChanges, ViewDef}, - parser::extract_when, + model::{Category, DocumentSettings, Item, ItemChanges, ViewDef}, + preferences::AppPreferences, }; #[derive(Debug, Clone)] @@ -39,6 +44,8 @@ pub struct PropsState { pub enum FormKind { Category(Option), View(Option), + Preferences, + DocumentSettings, } #[derive(Debug, Clone)] @@ -64,11 +71,26 @@ pub enum Mode { Views, CategoryManager, Menu, + Confirm { + prompt: String, + action: ConfirmAction, + }, +} + +#[derive(Debug, Clone)] +pub enum ConfirmAction { + DeleteView(i64), + DeleteCategory(i64), + EmptyTrash, + PermanentlyDiscard(Vec), } pub struct App { pub db: Database, pub path: PathBuf, + pub preferences: AppPreferences, + pub preferences_path: PathBuf, + pub document_settings: DocumentSettings, pub views: Vec, pub view_index: usize, pub items: Vec, @@ -85,15 +107,41 @@ pub struct App { pub item_rows: Vec<(Rect, i64)>, pub command_regions: Vec, pub view_regions: Vec, + last_autosave: Instant, + current_day: NaiveDate, } impl App { pub fn new(db: Database, path: PathBuf) -> Result { + 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::build(db, path, preferences, preferences_path) + } + + fn build( + db: Database, + path: PathBuf, + preferences: AppPreferences, + preferences_path: PathBuf, + ) -> Result { let views = db.views()?; let categories = db.categories()?; + let document_settings = db.document_settings()?; let mut app = Self { db, path, + preferences, + preferences_path, + document_settings, views, view_index: 0, items: vec![], @@ -110,6 +158,8 @@ impl App { item_rows: vec![], command_regions: vec![], view_regions: vec![], + last_autosave: Instant::now(), + current_day: Local::now().date_naive(), }; app.refresh()?; Ok(app) @@ -160,9 +210,35 @@ impl App { Mode::Views => self.handle_views(key), Mode::CategoryManager => self.handle_category_manager(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<()> { if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('s') { self.db.checkpoint()?; @@ -307,11 +383,28 @@ impl App { return Ok(()); } 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, restore: bool) -> Result<()> { + let permanent = !restore && self.document_settings.trash_policy == "immediate"; self.db.discard(&ids, !restore)?; self.marked.clear(); self.refresh()?; self.status = if restore { format!("Restored {} item(s)", ids.len()) + } else if permanent { + format!("Permanently deleted {} item(s)", ids.len()) } else { format!("Moved {} item(s) to Trash", ids.len()) }; @@ -336,6 +429,11 @@ impl App { match key.code { KeyCode::Enter if input.multiline => insert_at_cursor(&mut input, '\n'), 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::Delete => delete_at_cursor(&mut input), KeyCode::Left => input.cursor = previous_boundary(&input.value, input.cursor), @@ -412,8 +510,8 @@ impl App { } KeyCode::Enter => { let priority = props.values[0].parse().unwrap_or(3); - let when_at = normalize_date(&props.values[1]); - let alarm_at = normalize_date(&props.values[2]); + let when_at = normalize_date(&self.db, &props.values[1])?; + let alarm_at = normalize_date(&self.db, &props.values[2])?; let numeric_value = if props.values[3].trim().is_empty() { None } else { @@ -510,11 +608,15 @@ impl App { } KeyCode::Delete => { if let Some(view) = self.views.get(self.view_selected) { - self.db.delete_view(view.id)?; - self.view_index = 0; - self.view_selected = 0; - self.refresh()?; - self.status = "View deleted".into(); + let id = view.id; + if self.preferences.confirm_destructive { + self.mode = Mode::Confirm { + prompt: format!("Delete view “{}”?", view.name), + action: ConfirmAction::DeleteView(id), + }; + } else { + self.delete_view(id)?; + } } } _ => {} @@ -539,10 +641,15 @@ impl App { } KeyCode::Delete => { if let Some(category) = self.categories.get(self.category_selected) { - self.db.delete_category(category.id)?; - self.category_selected = self.category_selected.saturating_sub(1); - self.refresh()?; - self.status = "Category deleted; its items were preserved".into(); + let id = category.id; + if self.preferences.confirm_destructive { + self.mode = Mode::Confirm { + 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('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, _ => {} } 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) { let id = category.as_ref().map(|c| c.id); let rule = @@ -654,6 +871,7 @@ impl App { self.mode = match form.kind { FormKind::Category(_) => Mode::CategoryManager, FormKind::View(_) => Mode::Views, + FormKind::Preferences | FormKind::DocumentSettings => Mode::Normal, }; return Ok(()); } @@ -708,6 +926,52 @@ impl App { self.mode = Mode::Views; 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(()); } @@ -810,14 +1074,22 @@ fn parse_yes(value: &str) -> bool { ) } -fn normalize_date(value: &str) -> Option { +fn parse_yes_no(value: &str) -> Result { + 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> { let s = value.trim(); if s.is_empty() { - None + Ok(None) } else if chrono::DateTime::parse_from_rfc3339(s).is_ok() { - Some(s.into()) + Ok(Some(s.into())) } else { - extract_when(&format!("on {s}")) + db.interpret_date(&format!("on {s}")) } } fn inside(r: Rect, x: u16, y: u16) -> bool { diff --git a/src/db.rs b/src/db.rs index 9807bed..f337b9d 100644 --- a/src/db.rs +++ b/src/db.rs @@ -1,27 +1,40 @@ -use std::{fs, path::Path}; +use std::{ + fs, + path::{Path, PathBuf}, +}; 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 crate::filter; -use crate::model::{Category, CategoryRule, Item, ItemChanges, ViewColumn, ViewDef, ViewSection}; -use crate::parser::{extract_when, next_occurrence}; +use crate::model::{ + Category, CategoryRule, DocumentSettings, Item, ItemChanges, ViewColumn, ViewDef, ViewSection, +}; +use crate::parser::{extract_when_configured, next_occurrence}; pub struct Database { conn: Connection, + path: PathBuf, } impl Database { pub fn open(path: &Path) -> Result { + let existed = path.exists() && path.metadata().is_ok_and(|metadata| metadata.len() > 0); let conn = Connection::open(path).with_context(|| format!("could not open {}", path.display()))?; conn.pragma_update(None, "foreign_keys", "ON")?; conn.pragma_update(None, "journal_mode", "WAL")?; conn.pragma_update(None, "synchronous", "NORMAL")?; - let mut db = Self { conn }; + let mut db = Self { + conn, + path: path.to_owned(), + }; db.migrate()?; db.seed_defaults()?; + if existed && db.document_settings()?.backup_on_open { + db.backup_now()?; + } Ok(db) } @@ -178,6 +191,114 @@ impl Database { Ok(()) } + pub fn document_settings(&self) -> Result { + 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 { + 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 { + Ok(parse_bool(&self.meta_value(key, &default.to_string())?)) + } + + pub fn interpret_date(&self, text: &str) -> Result> { + 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 { + 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> { 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 @@ -481,13 +602,18 @@ impl Database { bail!("item text cannot be empty"); } 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()?; 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(); - Self::auto_assign_tx(&tx, id, text)?; + if settings.automatic_filing { + Self::auto_assign_tx(&tx, id, text)?; + } tx.commit()?; - self.apply_rules_to_item(id)?; + if settings.automatic_filing { + self.apply_rules_to_item(id)?; + } Ok(id) } @@ -516,6 +642,7 @@ impl Database { } pub fn reapply_automatic_assignments(&mut self) -> Result<()> { + let automatic_filing = self.document_settings()?.automatic_filing; let tx = self.conn.transaction()?; let items: Vec<(i64, String)> = { let mut stmt = tx.prepare("SELECT id,text FROM items WHERE discarded=0")?; @@ -523,14 +650,24 @@ impl Database { .collect::>()? }; for (id, text) in items { - Self::auto_assign_tx(&tx, id, &text)?; + if automatic_filing { + Self::auto_assign_tx(&tx, id, &text)?; + } else { + tx.execute( + "DELETE FROM item_categories WHERE item_id=?1 AND assignment='automatic'", + [id], + )?; + } } tx.commit()?; - self.apply_rules_to_all()?; + if automatic_filing { + self.apply_rules_to_all()?; + } Ok(()) } pub fn update_item(&mut self, id: i64, changes: &ItemChanges) -> Result<()> { + let automatic_filing = self.document_settings()?.automatic_filing; let tx = self.conn.transaction()?; if let Some(text) = &changes.text { if text.trim().is_empty() { @@ -540,7 +677,9 @@ impl Database { "UPDATE items SET text=?1,updated_at=?2 WHERE id=?3", params![text.trim(), Local::now().to_rfc3339(), id], )?; - Self::auto_assign_tx(&tx, id, text)?; + if automatic_filing { + Self::auto_assign_tx(&tx, id, text)?; + } } if let Some(note) = &changes.note { tx.execute( @@ -591,7 +730,9 @@ impl Database { )?; } tx.commit()?; - self.apply_rules_to_item(id)?; + if automatic_filing { + self.apply_rules_to_item(id)?; + } Ok(()) } @@ -721,7 +862,8 @@ impl Database { } } "when" => { - let value = extract_when(&rule.action_value) + let value = self + .interpret_date(&rule.action_value)? .context("rule action has no recognizable date")?; if item.when_at.as_deref() != Some(value.as_str()) { self.conn.execute( @@ -734,7 +876,8 @@ impl Database { } } "alarm" => { - let value = extract_when(&rule.action_value) + let value = self + .interpret_date(&rule.action_value)? .context("rule action has no recognizable alarm date")?; if item.alarm_at.as_deref() != Some(value.as_str()) { self.conn.execute( @@ -791,6 +934,7 @@ impl Database { } pub fn toggle_done(&mut self, ids: &[i64]) -> Result<()> { + let settings = self.document_settings()?; for id in ids { let Some(item) = self.load_item(*id)? else { 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])?; let created = tx.last_insert_rowid(); - Self::auto_assign_tx(&tx, created, &item.text)?; + if settings.automatic_filing { + 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])?; 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()?; - if let Some(next_id) = next_id { + if let Some(next_id) = next_id + && settings.automatic_filing + { self.apply_rules_to_item(next_id)?; } } @@ -828,11 +977,16 @@ impl Database { } pub fn discard(&self, ids: &[i64], discarded: bool) -> Result<()> { + let permanent = discarded && self.document_settings()?.trash_policy == "immediate"; for id in ids { - self.conn.execute( - "UPDATE items SET discarded=?1,updated_at=?2 WHERE id=?3", - params![discarded as i64, Local::now().to_rfc3339(), id], - )?; + if permanent { + self.conn.execute("DELETE FROM items WHERE id=?1", [id])?; + } else { + self.conn.execute( + "UPDATE items SET discarded=?1,updated_at=?2 WHERE id=?3", + params![discarded as i64, Local::now().to_rfc3339(), id], + )?; + } } Ok(()) } @@ -987,6 +1141,19 @@ impl Database { Ok(()) } + pub fn empty_trash(&self) -> Result { + 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 { if path .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> { if spec.trim().is_empty() { return Ok(default_columns()); @@ -1852,6 +2050,70 @@ mod tests { 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] fn ical_roundtrip_preserves_planner_fields() { let d = tempdir().unwrap(); diff --git a/src/main.rs b/src/main.rs index fdb435b..72d5274 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ mod db; mod filter; mod model; mod parser; +mod preferences; mod ui; use std::{io::stdout, path::PathBuf, time::Duration}; @@ -80,6 +81,7 @@ fn run_tui(mut app: App) -> Result<()> { terminal.clear()?; let result = (|| -> Result<()> { loop { + app.tick()?; terminal.draw(|f| ui::draw(f, &mut app))?; if app.should_quit { break; @@ -93,7 +95,7 @@ fn run_tui(mut app: App) -> Result<()> { } } } - app.db.checkpoint()?; + app.shutdown()?; Ok(()) })(); terminal.show_cursor()?; diff --git a/src/model.rs b/src/model.rs index 1064774..fb0d8ac 100644 --- a/src/model.rs +++ b/src/model.rs @@ -108,3 +108,38 @@ pub struct ItemChanges { pub numeric_value: Option>, pub recurrence: Option, } + +#[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, + } + } +} diff --git a/src/parser.rs b/src/parser.rs index 4a7e1b8..677c46f 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -4,14 +4,46 @@ use chrono::{ }; use regex::Regex; -/// Extracts the first recognizable date/time phrase and returns local RFC 3339. -/// Deliberately favors useful, predictable planner phrases over pretending to be -/// a complete natural-language parser. -pub fn extract_when(text: &str) -> Option { - extract_when_from(text, Local::now().naive_local()) +#[cfg(test)] +pub fn extract_when_from(text: &str, now: NaiveDateTime) -> Option { + extract_when_from_configured( + text, now, "ymd", "monday", "09:00", "09:00", "13:00", "18:00", + ) } -pub fn extract_when_from(text: &str, now: NaiveDateTime) -> Option { +#[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 { + 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 { let lower = text.to_lowercase(); let mut date = None; @@ -20,6 +52,24 @@ pub fn extract_when_from(text: &str, now: NaiveDateTime) -> Option { 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::().ok()?, + c[2].parse::().ok()?, + c[3].parse::().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() { let in_days = Regex::new(r"\bin\s+(\d+)\s+days?\b").unwrap(); if let Some(c) = in_days.captures(&lower) { @@ -33,6 +83,20 @@ pub fn extract_when_from(text: &str, now: NaiveDateTime) -> Option { } } + 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() { let weekdays = [ ("monday", Weekday::Mon), @@ -76,20 +140,43 @@ pub fn extract_when_from(text: &str, now: NaiveDateTime) -> Option { NaiveTime::from_hms_opt(hour, minute, 0)? } else if let Some(c) = time24_re.captures(&lower) { NaiveTime::from_hms_opt(c[1].parse().ok()?, c[2].parse().ok()?, 0)? + } else 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 { - NaiveTime::from_hms_opt(9, 0, 0)? + parse_clock(default_time)? }; let local = Local.from_local_datetime(&date.and_time(time)).single()?; Some(local.to_rfc3339()) } -pub fn pretty_when(value: Option<&str>) -> String { +fn parse_clock(value: &str) -> Option { + 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 { return String::new(); }; 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()) } @@ -176,4 +263,36 @@ mod tests { .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")); + } } diff --git a/src/preferences.rs b/src/preferences.rs new file mode 100644 index 0000000..40678c0 --- /dev/null +++ b/src/preferences.rs @@ -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 { + 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 { + 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()); + } +} diff --git a/src/ui.rs b/src/ui.rs index fc823f9..be3e91a 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -13,15 +13,46 @@ use crate::{ app::{App, FormKind, Mode}, filter, model::{Item, ViewColumn}, - parser::pretty_when, + parser::format_when, }; -const BLUE: Color = Color::Rgb(0, 0, 128); -const CYAN: Color = Color::Cyan; -const CANVAS: Color = Color::Rgb(192, 192, 192); -const RED: Color = Color::Rgb(128, 0, 0); +#[derive(Clone, Copy)] +struct Palette { + primary: Color, + 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) { + let colors = palette(&app.preferences.theme); frame.render_widget( Block::default().style(Style::default().bg(Color::Black)), frame.area(), @@ -30,7 +61,7 @@ pub fn draw(frame: &mut Frame<'_>, app: &mut App) { if height < 10 || frame.area().width < 40 { frame.render_widget( Paragraph::new("Rogue Agenda needs at least 40×10") - .style(Style::default().fg(Color::Yellow).bg(BLUE)) + .style(Style::default().fg(colors.accent).bg(colors.primary)) .alignment(Alignment::Center), frame.area(), ); @@ -39,7 +70,11 @@ pub fn draw(frame: &mut Frame<'_>, app: &mut App) { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Length(2), + Constraint::Length(if app.preferences.show_command_bar { + 2 + } else { + 0 + }), Constraint::Length(1), Constraint::Min(3), Constraint::Length(1), @@ -50,16 +85,21 @@ pub fn draw(frame: &mut Frame<'_>, app: &mut App) { draw_view_tabs(frame, app, chunks[1]); draw_items(frame, app, chunks[2]); draw_status(frame, app, chunks[3]); - draw_commands(frame, app, chunks[4]); + if app.preferences.show_command_bar { + draw_commands(frame, app, chunks[4]); + } else { + app.command_regions.clear(); + } draw_overlay(frame, app); } fn draw_header(frame: &mut Frame<'_>, app: &App, area: Rect) { + let colors = palette(&app.preferences.theme); let alarms = app.db.due_alarm_count().unwrap_or(0); + let now = Local::now().to_rfc3339(); let right = format!( - "{} {}{}", - Local::now().format("%Y-%m-%d"), - Local::now().format("%H:%M"), + "{}{}", + formatted_when(app, Some(&now)), if alarms > 0 { format!(" ALARM:{alarms}") } else { @@ -67,7 +107,15 @@ fn draw_header(frame: &mut Frame<'_>, app: &App, area: Rect) { } ); 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 line1 = format!("{left1}{}{right}", " ".repeat(pad)); 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( Style::default() .fg(Color::White) - .bg(BLUE) + .bg(colors.primary) .add_modifier(Modifier::BOLD), ), 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) { + let colors = palette(&app.preferences.theme); app.view_regions.clear(); 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() { let w = (v.name.chars().count() + 2).min(20) as u16; 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); let st = if i == app.view_index { Style::default() - .fg(BLUE) + .fg(colors.primary) .bg(Color::White) .add_modifier(Modifier::BOLD) } 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); 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) { - 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(); if app.items.is_empty() { frame.render_widget( @@ -124,7 +180,7 @@ fn draw_items(frame: &mut Frame<'_>, app: &mut App, area: Rect) { } else { " 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, ); 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( format!("── {heading} "), Style::default() - .fg(BLUE) - .bg(CANVAS) + .fg(colors.primary) + .bg(colors.canvas) .add_modifier(Modifier::BOLD), ))), DisplayLine::Header => lines.push(cells_line( columns.iter().map(|c| c.heading.clone()).collect(), &widths, Style::default() - .fg(BLUE) - .bg(CANVAS) + .fg(colors.primary) + .bg(colors.canvas) .add_modifier(Modifier::BOLD), )), DisplayLine::Item(index) => { @@ -221,16 +277,16 @@ fn draw_items(frame: &mut Frame<'_>, app: &mut App, area: Rect) { let style = if *index == app.selected { Style::default() .fg(Color::White) - .bg(RED) + .bg(colors.selection) .add_modifier(Modifier::BOLD) } else { Style::default() .fg(if item.done_at.is_some() { Color::DarkGray } else { - Color::Black + colors.foreground }) - .bg(CANVAS) + .bg(colors.canvas) }; let cells = columns.iter().map(|c| column_value(c, item, app)).collect(); 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) => { let cells = columns .iter() - .map(|c| aggregate_value(c, indices, &app.items)) + .map(|c| aggregate_value(c, indices, &app.items, app)) .collect(); lines.push(cells_line( cells, &widths, Style::default() - .fg(BLUE) - .bg(CANVAS) + .fg(colors.primary) + .bg(colors.canvas) .add_modifier(Modifier::BOLD), )); } } } frame.render_widget( - Paragraph::new(lines).style(Style::default().bg(CANVAS)), + Paragraph::new(lines).style(Style::default().bg(colors.canvas)), 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 { - "•" + &app.preferences.item_marker }; format!("{marker} {}", item.text.replace('\n', " ")) } "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(), - "note" => item.note.replace('\n', " "), + "note" => item.note.replace( + '\n', + if app.preferences.show_carriage_returns { + "↵ " + } else { + " " + }, + ), "value" => item .numeric_value - .map(|v| format!("{v:.2}")) + .map(|v| format_number(v, app)) .unwrap_or_default(), - "done" => pretty_when(item.done_at.as_deref()), - "alarm" => pretty_when(item.alarm_at.as_deref()), + "done" => formatted_when(app, item.done_at.as_deref()), + "alarm" => formatted_when(app, item.alarm_at.as_deref()), "recurrence" => item.recurrence.clone(), - "created" => pretty_when(Some(&item.created_at)), - "updated" => pretty_when(Some(&item.updated_at)), + "created" => formatted_when(app, Some(&item.created_at)), + "updated" => formatted_when(app, Some(&item.updated_at)), _ => String::new(), } } -fn aggregate_value(column: &ViewColumn, indices: &[usize], items: &[Item]) -> String { +fn aggregate_value(column: &ViewColumn, indices: &[usize], items: &[Item], app: &App) -> String { if column.aggregate == "none" { 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), _ => 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) { + let colors = palette(&app.preferences.theme); let mut lines: Vec = vec![]; let mut dates: HashMap = HashMap::new(); for i in &app.items { - let date = pretty_when(i.when_at.as_deref()) - .chars() - .take(10) - .collect::(); + let formatted = formatted_when(app, i.when_at.as_deref()); + let (date, time) = formatted.rsplit_once(' ').unwrap_or((&formatted, "")); + let date = date.to_owned(); if !dates.contains_key(&date) { lines.push(Line::from(Span::styled( format!("── {date} ──"), Style::default() - .fg(BLUE) - .bg(CANVAS) + .fg(colors.primary) + .bg(colors.canvas) .add_modifier(Modifier::BOLD), ))); dates.insert(date.clone(), true); } let selected = app.items.get(app.selected).map(|s| s.id) == Some(i.id); let style = if selected { - Style::default().fg(Color::White).bg(RED) + Style::default().fg(Color::White).bg(colors.selection) } else { - Style::default().fg(Color::Black).bg(CANVAS) + Style::default().fg(colors.foreground).bg(colors.canvas) }; let y = area.y + lines.len() as u16; app.item_rows .push((Rect::new(area.x, y, area.width, 1), i.id)); lines.push(Line::from(Span::styled( - format!( - " {} {}", - pretty_when(i.when_at.as_deref()) - .chars() - .skip(11) - .collect::(), - i.text - ), + format!(" {} {}", time, i.text), style, ))); } frame.render_widget( - Paragraph::new(lines).style(Style::default().bg(CANVAS)), + Paragraph::new(lines).style(Style::default().bg(colors.canvas)), 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::(); + format!( + "{}{}{}{}", + if negative { "-" } else { "" }, + integer, + app.preferences.decimal_separator, + fraction + ) +} + fn draw_status(frame: &mut Frame<'_>, app: &App, area: Rect) { + let colors = palette(&app.preferences.theme); let totals = app .items .iter() @@ -431,9 +517,9 @@ fn draw_status(frame: &mut Frame<'_>, app: &App, area: Rect) { String::new() } else { format!( - " Σ {:.2} avg {:.2}", - totals.iter().sum::(), - totals.iter().sum::() / totals.len() as f64 + " Σ {} avg {}", + format_number(totals.iter().sum::(), app), + format_number(totals.iter().sum::() / totals.len() as f64, app) ) }; let right = format!("{} item(s){}", app.items.len(), aggregate); @@ -443,12 +529,13 @@ fn draw_status(frame: &mut Frame<'_>, app: &App, area: Rect) { as usize; frame.render_widget( 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, ); } fn draw_commands(frame: &mut Frame<'_>, app: &mut App, area: Rect) { + let colors = palette(&app.preferences.theme); let labels = [ "Help", "Edit", "Choices", "Done", "Note", "Props", "Mark", "Vw Mgr", "Cat Mgr", "Menu", ]; @@ -465,20 +552,23 @@ fn draw_commands(frame: &mut Frame<'_>, app: &mut App, area: Rect) { Line::from(labels[i]), ]) .alignment(Alignment::Center) - .style(Style::default().fg(Color::White).bg(BLUE)), + .style(Style::default().fg(Color::White).bg(colors.primary)), *r, ); } } fn draw_overlay(frame: &mut Frame<'_>, app: &App) { + let colors = palette(&app.preferences.theme); match &app.mode { Mode::Normal => {} Mode::Help => { let text = Text::from(vec![ Line::from(Span::styled( "ROGUE AGENDA HELP", - Style::default().fg(CYAN).add_modifier(Modifier::BOLD), + Style::default() + .fg(colors.accent) + .add_modifier(Modifier::BOLD), )), Line::from(""), Line::from( @@ -499,6 +589,7 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &App) { center(frame.area(), 74, 15), "Help", Paragraph::new(text).wrap(Wrap { trim: false }), + colors, ); } Mode::Input(input) => { @@ -522,7 +613,13 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &App) { Style::default().fg(Color::DarkGray), ))) } 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( frame, @@ -532,6 +629,7 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &App) { vertical_scroll.min(u16::MAX as usize) as u16, horizontal_scroll.min(u16::MAX as usize) as u16, )), + colors, ); frame.set_cursor_position(( inner.x + cursor_column.saturating_sub(horizontal_scroll) as u16, @@ -553,14 +651,18 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &App) { [ Line::from(Span::styled( *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( format!("> {}", p.values[i]), Style::default().fg(Color::White).bg(if i == p.field { - RED + colors.selection } else { - BLUE + colors.primary }), )), ] @@ -575,6 +677,7 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &App) { center(frame.area(), 78, 18), "Item Properties", Paragraph::new(lines), + colors, ); } Mode::Form(form) => { @@ -606,35 +709,79 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &App) { ], "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 filter_source = match form.kind { - FormKind::Category(_) => form.values.get(5), - FormKind::View(_) => form.values.get(3), - } - .map(String::as_str) - .unwrap_or(""); - let preview = match app.db.count_matching_filter(filter_source) { - Ok(n) => format!("Preview: {n} existing item(s) match"), - Err(e) => format!("Preview error: {e}"), + let preview = match form.kind { + FormKind::Category(_) | FormKind::View(_) => { + let filter_source = match form.kind { + FormKind::Category(_) => form.values.get(5), + FormKind::View(_) => form.values.get(3), + FormKind::Preferences | FormKind::DocumentSettings => None, + } + .map(String::as_str) + .unwrap_or(""); + match app.db.count_matching_filter(filter_source) { + Ok(n) => format!("Preview: {n} existing item(s) match"), + Err(e) => format!("Preview error: {e}"), + } + } + FormKind::Preferences => format!("File: {}", app.preferences_path.display()), + FormKind::DocumentSettings => { + "Document preferences are saved transactionally".into() + } }; let lines = names .iter() .enumerate() - .flat_map(|(i, name)| { - [ - Line::from(Span::styled( - *name, - Style::default().fg(if i == form.field { Color::Yellow } else { CYAN }), - )), - Line::from(Span::styled( - format!("> {}", form.values[i]), - Style::default().fg(Color::White).bg(if i == form.field { - RED + .map(|(i, name)| { + Line::from(Span::styled( + format!("{name:<50} {}", form.values[i]), + Style::default() + .fg(if i == form.field { + Color::White } else { - BLUE + colors.accent + }) + .bg(if i == form.field { + colors.selection + } else { + colors.primary }), - )), - ] + )) }) .chain([ Line::from(""), @@ -645,9 +792,10 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &App) { .collect::>(); popup( frame, - center(frame.area(), 96, 24), + center(frame.area(), 110, (names.len() as u16 + 6).clamp(12, 22)), title, Paragraph::new(lines), + colors, ); } Mode::Dependencies { @@ -678,12 +826,12 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &App) { .block( Block::bordered() .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( Style::default() .fg(Color::White) - .bg(RED) + .bg(colors.selection) .add_modifier(Modifier::BOLD), ), area, @@ -720,12 +868,12 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &App) { .title( " 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( Style::default() .fg(Color::White) - .bg(RED) + .bg(colors.selection) .add_modifier(Modifier::BOLD), ), area, @@ -735,24 +883,43 @@ fn draw_overlay(frame: &mut Frame<'_>, app: &App) { Mode::Menu => { popup( frame, - center(frame.area(), 46, 10), + center(frame.area(), 58, 14), "Rogue Agenda Menu", Paragraph::new(vec![ Line::from(" n New item"), Line::from(" s Save / checkpoint"), Line::from(" h Help"), Line::from(" r Prerequisites"), + Line::from(" p Preferences (user TOML)"), + Line::from(" d Document settings (.agnd)"), + Line::from(" b Back up document now"), + Line::from(" t Empty Trash permanently"), Line::from(" q Quit"), Line::from(""), Line::from(" Esc closes the menu"), ]) .style(Style::default().fg(Color::White)), + colors, + ); + } + Mode::Confirm { prompt, .. } => { + popup( + frame, + center(frame.area(), 66, 7), + "Confirm", + Paragraph::new(vec![ + Line::from(prompt.as_str()), + Line::from(""), + Line::from("Enter/Y confirms · N/Esc cancels"), + ]), + colors, ); } } } fn draw_category_list(frame: &mut Frame<'_>, app: &App, title: &str, assign: bool) { + let colors = palette(&app.preferences.theme); let assigned = app .selected_item() .and_then(|i| app.db.item_category_ids(i.id).ok()) @@ -785,18 +952,21 @@ fn draw_category_list(frame: &mut Frame<'_>, app: &App, title: &str, assign: boo .and_then(|p| names.get(&p)) .map(|p| format!(" ({p})")) .unwrap_or_default(); - let rule = app - .db - .category_rules(c.id) - .ok() - .and_then(|rules| rules.into_iter().next()) - .map(|r| { - format!( - " rule: {} → {}:{}", - r.condition_expr, r.action_kind, r.action_value - ) - }) - .unwrap_or_default(); + let rule = if app.preferences.show_rule_info { + app.db + .category_rules(c.id) + .ok() + .and_then(|rules| rules.into_iter().next()) + .map(|r| { + format!( + " rule: {} → {}:{}", + r.condition_expr, r.action_kind, r.action_value + ) + }) + .unwrap_or_default() + } else { + String::new() + }; ListItem::new(format!( "{marker} {indent}{}{} {}{}", c.name, @@ -818,12 +988,12 @@ fn draw_category_list(frame: &mut Frame<'_>, app: &App, title: &str, assign: boo .block( Block::bordered() .title(format!(" {title} ")) - .style(Style::default().fg(Color::White).bg(BLUE)), + .style(Style::default().fg(Color::White).bg(colors.primary)), ) .highlight_style( Style::default() .fg(Color::White) - .bg(RED) + .bg(colors.selection) .add_modifier(Modifier::BOLD), ), 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( Block::default() .borders(Borders::ALL) .title(format!(" {title} ")) - .border_style(Style::default().fg(CYAN)) - .style(Style::default().bg(BLUE)), + .border_style(Style::default().fg(colors.accent)) + .style(Style::default().bg(colors.primary)), area, ); frame.render_widget( - widget.style(Style::default().fg(Color::White).bg(BLUE)), + widget.style(Style::default().fg(Color::White).bg(colors.primary)), area.inner(Margin { horizontal: 1, vertical: 1, @@ -863,7 +1033,7 @@ fn center(area: Rect, width: u16, height: u16) -> Rect { #[cfg(test)] mod tests { 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 ratatui::{ Terminal, @@ -876,7 +1046,13 @@ mod tests { let p = d.path().join("ui.agnd"); let mut db = Database::open(&p).unwrap(); db.add_item("Call Grace tomorrow").unwrap(); - let mut app = App::new(db, p).unwrap(); + let mut app = App::new_with_preferences( + db, + p, + AppPreferences::default(), + d.path().join("preferences.toml"), + ) + .unwrap(); let backend = TestBackend::new(100, 30); let mut term = Terminal::new(backend).unwrap(); term.draw(|f| draw(f, &mut app)).unwrap(); @@ -920,7 +1096,13 @@ mod tests { "Urgent work|priority=1", ) .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.refresh().unwrap(); let backend = TestBackend::new(90, 24); @@ -945,7 +1127,13 @@ mod tests { let p = d.path().join("compact.agnd"); let mut db = Database::open(&p).unwrap(); db.add_item("Compact item tomorrow").unwrap(); - let mut app = App::new(db, p).unwrap(); + let mut app = App::new_with_preferences( + db, + p, + AppPreferences::default(), + d.path().join("preferences.toml"), + ) + .unwrap(); let backend = TestBackend::new(50, 15); let mut term = Terminal::new(backend).unwrap(); term.draw(|f| draw(f, &mut app)).unwrap(); @@ -966,7 +1154,13 @@ mod tests { let p = d.path().join("notes.agnd"); let mut db = Database::open(&p).unwrap(); db.add_item("Write release notes").unwrap(); - let mut app = App::new(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)) .unwrap(); for c in "first line".chars() { @@ -1006,4 +1200,105 @@ mod tests { .unwrap(); assert_eq!(app.items[0].note, "first line\nsecond line"); } + + #[test] + fn application_preferences_change_the_workspace_and_open_from_the_menu() { + let directory = tempdir().unwrap(); + let path = directory.path().join("preferences-ui.agnd"); + let mut db = Database::open(&path).unwrap(); + let item = db.add_item("Configured display item").unwrap(); + db.update_item( + item, + &ItemChanges { + when_at: Some(Some("2026-08-17T15:30:00+00:00".into())), + numeric_value: Some(Some(1234.5)), + ..Default::default() + }, + ) + .unwrap(); + let preferences = AppPreferences { + theme: "amber".into(), + show_command_bar: false, + item_marker: ">".into(), + date_format: "us".into(), + clock_24h: false, + decimal_separator: ",".into(), + thousands_separator: ".".into(), + ..Default::default() + }; + let mut app = App::new_with_preferences( + db, + path, + preferences, + directory.path().join("preferences.toml"), + ) + .unwrap(); + let backend = TestBackend::new(100, 30); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|frame| draw(frame, &mut app)).unwrap(); + let screen = terminal + .backend() + .buffer() + .content + .iter() + .map(|cell| cell.symbol()) + .collect::(); + assert!(screen.contains("> Configured display item")); + assert!(screen.contains("08/17/2026 3:30pm")); + assert!(screen.contains("1.234,50")); + assert!(!screen.contains("Cat Mgr")); + + app.handle_key(KeyEvent::new(KeyCode::F(10), KeyModifiers::NONE)) + .unwrap(); + app.handle_key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE)) + .unwrap(); + assert!(matches!( + app.mode, + Mode::Form(crate::app::FormState { + kind: FormKind::Preferences, + .. + }) + )); + } + + #[test] + fn 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()); + } }