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

82
src/app/editor.rs Normal file
View File

@@ -0,0 +1,82 @@
use super::InputState;
pub(super) fn insert_at_cursor(input: &mut InputState, character: char) {
input.value.insert(input.cursor, character);
input.cursor += character.len_utf8();
}
pub(super) fn insert_str_at_cursor(input: &mut InputState, value: &str) {
input.value.insert_str(input.cursor, value);
input.cursor += value.len();
}
pub(super) 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;
}
}
pub(super) 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);
}
}
pub(super) fn previous_boundary(value: &str, cursor: usize) -> usize {
value[..cursor]
.char_indices()
.next_back()
.map_or(0, |(index, _)| index)
}
pub(super) fn next_boundary(value: &str, cursor: usize) -> usize {
value[cursor..]
.chars()
.next()
.map_or(cursor, |character| cursor + character.len_utf8())
}
pub(super) fn line_start(value: &str, cursor: usize) -> usize {
value[..cursor].rfind('\n').map_or(0, |index| index + 1)
}
pub(super) 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)
}
pub(super) 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)
};
}

141
src/app/forms.rs Normal file
View File

@@ -0,0 +1,141 @@
use anyhow::{Result, bail};
use super::{ChoiceOption, FormKind};
const YES_NO_CHOICES: &[(&str, &str)] = &[("Yes", "yes"), ("No", "no")];
pub(super) fn yes_no(value: bool) -> String {
if value { "yes" } else { "no" }.into()
}
pub(super) fn parse_yes(value: &str) -> bool {
matches!(
value.trim().to_lowercase().as_str(),
"y" | "yes" | "true" | "1" | "on"
)
}
pub(super) 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}"),
}
}
pub(super) fn display_space(value: &str) -> String {
if value == " " { "space" } else { value }.into()
}
pub(super) 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(),
)
}
pub(super) 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("")
}