modularize app and database internals

- Extract editor, form, export, and schema modules
- Add database workflow integration tests
This commit is contained in:
Chili Palmer
2026-08-19 22:20:15 +02:00
parent c8ad487ca1
commit b5b8ae0d55
11 changed files with 778 additions and 659 deletions

View File

@@ -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::*;