modularize app and database internals
- Extract editor, form, export, and schema modules - Add database workflow integration tests
This commit is contained in:
284
src/app.rs
284
src/app.rs
@@ -22,6 +22,16 @@ use crate::{
|
||||
preferences::AppPreferences,
|
||||
};
|
||||
|
||||
mod editor;
|
||||
mod forms;
|
||||
|
||||
use editor::{
|
||||
delete_at_cursor, delete_before_cursor, insert_at_cursor, insert_str_at_cursor, line_end,
|
||||
line_start, move_cursor_vertically, next_boundary, previous_boundary,
|
||||
};
|
||||
pub(crate) use forms::form_choices;
|
||||
use forms::{decode_space, display_space, form_prompt, parse_yes, parse_yes_no, yes_no};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum InputKind {
|
||||
NewItem,
|
||||
@@ -132,32 +142,32 @@ pub enum ConfirmAction {
|
||||
}
|
||||
|
||||
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>,
|
||||
pub selected: usize,
|
||||
pub scroll: usize,
|
||||
pub search: String,
|
||||
pub marked: HashSet<i64>,
|
||||
pub categories: Vec<Category>,
|
||||
pub category_selected: usize,
|
||||
pub view_selected: usize,
|
||||
pub mode: Mode,
|
||||
pub status: String,
|
||||
pub should_quit: bool,
|
||||
pub item_rows: Vec<(Rect, i64)>,
|
||||
pub command_regions: Vec<Rect>,
|
||||
pub view_regions: Vec<Rect>,
|
||||
pub choice_regions: Vec<Rect>,
|
||||
pub macros: Vec<MacroDef>,
|
||||
pub macro_selected: usize,
|
||||
pub recording: Option<RecordingState>,
|
||||
pub macro_capture_key: bool,
|
||||
pub(crate) db: Database,
|
||||
pub(crate) path: PathBuf,
|
||||
pub(crate) preferences: AppPreferences,
|
||||
pub(crate) preferences_path: PathBuf,
|
||||
pub(crate) document_settings: DocumentSettings,
|
||||
pub(crate) views: Vec<ViewDef>,
|
||||
pub(crate) view_index: usize,
|
||||
pub(crate) items: Vec<Item>,
|
||||
pub(crate) selected: usize,
|
||||
pub(crate) scroll: usize,
|
||||
pub(crate) search: String,
|
||||
pub(crate) marked: HashSet<i64>,
|
||||
pub(crate) categories: Vec<Category>,
|
||||
pub(crate) category_selected: usize,
|
||||
pub(crate) view_selected: usize,
|
||||
pub(crate) mode: Mode,
|
||||
pub(crate) status: String,
|
||||
pub(crate) should_quit: bool,
|
||||
pub(crate) item_rows: Vec<(Rect, i64)>,
|
||||
pub(crate) command_regions: Vec<Rect>,
|
||||
pub(crate) view_regions: Vec<Rect>,
|
||||
pub(crate) choice_regions: Vec<Rect>,
|
||||
pub(crate) macros: Vec<MacroDef>,
|
||||
pub(crate) macro_selected: usize,
|
||||
pub(crate) recording: Option<RecordingState>,
|
||||
pub(crate) macro_capture_key: bool,
|
||||
macro_runtime: Option<MacroRuntime>,
|
||||
macro_globals: std::collections::HashMap<String, String>,
|
||||
macro_return_mode: Option<Mode>,
|
||||
@@ -248,6 +258,10 @@ impl App {
|
||||
self.items.get(self.selected)
|
||||
}
|
||||
|
||||
pub fn should_quit(&self) -> bool {
|
||||
self.should_quit
|
||||
}
|
||||
|
||||
pub fn refresh(&mut self) -> Result<()> {
|
||||
let keep = self.selected_item().map(|i| i.id);
|
||||
self.views = self.db.views()?;
|
||||
@@ -1858,143 +1872,6 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn yes_no(value: bool) -> String {
|
||||
if value { "yes" } else { "no" }.into()
|
||||
}
|
||||
fn parse_yes(value: &str) -> bool {
|
||||
matches!(
|
||||
value.trim().to_lowercase().as_str(),
|
||||
"y" | "yes" | "true" | "1" | "on"
|
||||
)
|
||||
}
|
||||
|
||||
fn 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 display_space(value: &str) -> String {
|
||||
if value == " " { "space" } else { value }.into()
|
||||
}
|
||||
|
||||
fn decode_space(value: &str) -> String {
|
||||
if value.eq_ignore_ascii_case("space") {
|
||||
" ".into()
|
||||
} else {
|
||||
value.into()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn form_choices(kind: &FormKind, field: usize) -> Option<Vec<ChoiceOption>> {
|
||||
let entries: &[(&str, &str)] = match kind {
|
||||
FormKind::Preferences => match field {
|
||||
0 => &[
|
||||
("Classic — Lotus-inspired blue", "classic"),
|
||||
("Mono — grayscale", "mono"),
|
||||
("Amber — warm dark", "amber"),
|
||||
("Greenscreen — muted phosphor green", "greenscreen"),
|
||||
("Nord — arctic blue dark", "nord"),
|
||||
("Catppuccin Mocha — cozy pastel dark", "catppuccin-mocha"),
|
||||
],
|
||||
1..=3 | 6 | 8 => YES_NO_CHOICES,
|
||||
7 => &[
|
||||
("ISO — 2026-08-16", "iso"),
|
||||
("US — 08/16/2026", "us"),
|
||||
("European — 16/08/2026", "european"),
|
||||
("Long — 16 Aug 2026", "long"),
|
||||
],
|
||||
_ => return None,
|
||||
},
|
||||
FormKind::DocumentSettings => match field {
|
||||
1 | 4 => YES_NO_CHOICES,
|
||||
2 => &[
|
||||
("On demand", "on-demand"),
|
||||
("On close", "on-close"),
|
||||
("End of day", "end-of-day"),
|
||||
("Immediately", "immediate"),
|
||||
],
|
||||
3 => &[
|
||||
("Keep completed items", "keep"),
|
||||
("Move them to Trash", "trash"),
|
||||
],
|
||||
5 => &[
|
||||
("Year / month / day", "ymd"),
|
||||
("Month / day / year", "mdy"),
|
||||
("Day / month / year", "dmy"),
|
||||
],
|
||||
6 => &[("Monday", "monday"), ("Sunday", "sunday")],
|
||||
_ => return None,
|
||||
},
|
||||
FormKind::Category(_) | FormKind::View(_) => return None,
|
||||
};
|
||||
Some(
|
||||
entries
|
||||
.iter()
|
||||
.map(|(label, value)| ChoiceOption {
|
||||
label: (*label).into(),
|
||||
value: (*value).into(),
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn form_prompt(kind: &FormKind, field: usize) -> &'static str {
|
||||
let prompts: &[&str] = match kind {
|
||||
FormKind::Category(_) => &[
|
||||
"Name",
|
||||
"Parent category",
|
||||
"Kind",
|
||||
"Match phrases",
|
||||
"Mutually exclusive",
|
||||
"Rule condition",
|
||||
"Rule action",
|
||||
],
|
||||
FormKind::View(_) => &[
|
||||
"Name",
|
||||
"Kind",
|
||||
"Filter value",
|
||||
"Boolean filter",
|
||||
"Sort key",
|
||||
"Show done",
|
||||
"Columns",
|
||||
"Sections",
|
||||
],
|
||||
FormKind::Preferences => &[
|
||||
"Theme",
|
||||
"Show command bar",
|
||||
"Show rule info",
|
||||
"Show return markers",
|
||||
"Item marker",
|
||||
"Autosave minutes",
|
||||
"Confirm destructive actions",
|
||||
"Date format",
|
||||
"24-hour clock",
|
||||
"Decimal separator",
|
||||
"Thousands separator",
|
||||
],
|
||||
FormKind::DocumentSettings => &[
|
||||
"Description",
|
||||
"Backup on open",
|
||||
"Trash",
|
||||
"Completed items",
|
||||
"Automatic filing",
|
||||
"Numeric date order",
|
||||
"Week starts",
|
||||
"Default time",
|
||||
"Morning time",
|
||||
"Afternoon time",
|
||||
"Evening time",
|
||||
"Note tab width",
|
||||
],
|
||||
};
|
||||
prompts.get(field).copied().unwrap_or("")
|
||||
}
|
||||
|
||||
const YES_NO_CHOICES: &[(&str, &str)] = &[("Yes", "yes"), ("No", "no")];
|
||||
|
||||
fn normalize_date(db: &Database, value: &str) -> Result<Option<String>> {
|
||||
let s = value.trim();
|
||||
if s.is_empty() {
|
||||
@@ -2027,87 +1904,6 @@ fn is_legacy_key_capture_shortcut(key: KeyEvent) -> bool {
|
||||
key.code == KeyCode::Char('=') && key.modifiers.contains(KeyModifiers::ALT)
|
||||
}
|
||||
|
||||
fn insert_at_cursor(input: &mut InputState, c: char) {
|
||||
input.value.insert(input.cursor, c);
|
||||
input.cursor += c.len_utf8();
|
||||
}
|
||||
|
||||
fn insert_str_at_cursor(input: &mut InputState, value: &str) {
|
||||
input.value.insert_str(input.cursor, value);
|
||||
input.cursor += value.len();
|
||||
}
|
||||
|
||||
fn delete_before_cursor(input: &mut InputState) {
|
||||
let previous = previous_boundary(&input.value, input.cursor);
|
||||
if previous != input.cursor {
|
||||
input.value.drain(previous..input.cursor);
|
||||
input.cursor = previous;
|
||||
}
|
||||
}
|
||||
|
||||
fn delete_at_cursor(input: &mut InputState) {
|
||||
let next = next_boundary(&input.value, input.cursor);
|
||||
if next != input.cursor {
|
||||
input.value.drain(input.cursor..next);
|
||||
}
|
||||
}
|
||||
|
||||
fn previous_boundary(value: &str, cursor: usize) -> usize {
|
||||
value[..cursor]
|
||||
.char_indices()
|
||||
.next_back()
|
||||
.map_or(0, |(index, _)| index)
|
||||
}
|
||||
|
||||
fn next_boundary(value: &str, cursor: usize) -> usize {
|
||||
value[cursor..]
|
||||
.chars()
|
||||
.next()
|
||||
.map_or(cursor, |c| cursor + c.len_utf8())
|
||||
}
|
||||
|
||||
fn line_start(value: &str, cursor: usize) -> usize {
|
||||
value[..cursor].rfind('\n').map_or(0, |index| index + 1)
|
||||
}
|
||||
|
||||
fn line_end(value: &str, cursor: usize) -> usize {
|
||||
value[cursor..]
|
||||
.find('\n')
|
||||
.map_or(value.len(), |index| cursor + index)
|
||||
}
|
||||
|
||||
fn byte_at_character(value: &str, start: usize, end: usize, column: usize) -> usize {
|
||||
value[start..end]
|
||||
.char_indices()
|
||||
.nth(column)
|
||||
.map_or(end, |(offset, _)| start + offset)
|
||||
}
|
||||
|
||||
fn move_cursor_vertically(input: &mut InputState, delta: isize) {
|
||||
let start = line_start(&input.value, input.cursor);
|
||||
let end = line_end(&input.value, input.cursor);
|
||||
let column = input.value[start..input.cursor].chars().count();
|
||||
input.cursor = if delta < 0 {
|
||||
if start == 0 {
|
||||
input.cursor
|
||||
} else {
|
||||
let target_end = start - 1;
|
||||
let target_start = input.value[..target_end]
|
||||
.rfind('\n')
|
||||
.map_or(0, |index| index + 1);
|
||||
byte_at_character(&input.value, target_start, target_end, column)
|
||||
}
|
||||
} else if end == input.value.len() {
|
||||
input.cursor
|
||||
} else {
|
||||
let target_start = end + 1;
|
||||
let target_end = input.value[target_start..]
|
||||
.find('\n')
|
||||
.map_or(input.value.len(), |index| target_start + index);
|
||||
byte_at_character(&input.value, target_start, target_end, column)
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
Reference in New Issue
Block a user