preferences added

This commit is contained in:
Hermes Agent
2026-08-16 20:08:27 +00:00
parent b099157a17
commit f53ed36f31
11 changed files with 1425 additions and 173 deletions

View File

@@ -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<i64>),
View(Option<i64>),
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<i64>),
}
pub struct App {
pub db: Database,
pub path: PathBuf,
pub preferences: AppPreferences,
pub preferences_path: PathBuf,
pub document_settings: DocumentSettings,
pub views: Vec<ViewDef>,
pub view_index: usize,
pub items: Vec<Item>,
@@ -85,15 +107,41 @@ pub struct App {
pub item_rows: Vec<(Rect, i64)>,
pub command_regions: Vec<Rect>,
pub view_regions: Vec<Rect>,
last_autosave: Instant,
current_day: NaiveDate,
}
impl App {
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 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<i64>, 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<Category>) {
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<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();
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 {