preferences added
This commit is contained in:
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 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 {
|
||||
|
||||
Reference in New Issue
Block a user