working prototype

This commit is contained in:
Hermes Agent
2026-08-16 19:23:59 +00:00
commit ba530a7271
13 changed files with 6787 additions and 0 deletions

812
src/app.rs Normal file
View File

@@ -0,0 +1,812 @@
use std::{collections::HashSet, path::PathBuf};
use anyhow::Result;
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,
};
#[derive(Debug, Clone)]
pub enum InputKind {
NewItem,
EditItem(i64),
Note(i64),
Search,
}
#[derive(Debug, Clone)]
pub struct InputState {
pub kind: InputKind,
pub title: String,
pub value: String,
pub multiline: bool,
}
#[derive(Debug, Clone)]
pub struct PropsState {
pub item_id: i64,
pub values: [String; 5],
pub field: usize,
}
#[derive(Debug, Clone)]
pub enum FormKind {
Category(Option<i64>),
View(Option<i64>),
}
#[derive(Debug, Clone)]
pub struct FormState {
pub kind: FormKind,
pub values: Vec<String>,
pub field: usize,
}
#[derive(Debug, Clone)]
pub enum Mode {
Normal,
Help,
Input(InputState),
Properties(PropsState),
Form(FormState),
Dependencies {
item_id: i64,
choices: Vec<Item>,
selected: usize,
},
Categories,
Views,
CategoryManager,
Menu,
}
pub struct App {
pub db: Database,
pub path: PathBuf,
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>,
}
impl App {
pub fn new(db: Database, path: PathBuf) -> Result<Self> {
let views = db.views()?;
let categories = db.categories()?;
let mut app = Self {
db,
path,
views,
view_index: 0,
items: vec![],
selected: 0,
scroll: 0,
search: String::new(),
marked: HashSet::new(),
categories,
category_selected: 0,
view_selected: 0,
mode: Mode::Normal,
status: "Ready — capture first, organize later".into(),
should_quit: false,
item_rows: vec![],
command_regions: vec![],
view_regions: vec![],
};
app.refresh()?;
Ok(app)
}
pub fn current_view(&self) -> &ViewDef {
&self.views[self.view_index]
}
pub fn selected_item(&self) -> Option<&Item> {
self.items.get(self.selected)
}
pub fn refresh(&mut self) -> Result<()> {
let keep = self.selected_item().map(|i| i.id);
self.views = self.db.views()?;
self.categories = self.db.categories()?;
self.view_index = self.view_index.min(self.views.len().saturating_sub(1));
self.items = self.db.items(&self.views[self.view_index], &self.search)?;
self.selected = keep
.and_then(|id| self.items.iter().position(|i| i.id == id))
.unwrap_or(self.selected.min(self.items.len().saturating_sub(1)));
if self.selected < self.scroll {
self.scroll = self.selected;
}
Ok(())
}
fn chosen_ids(&self) -> Vec<i64> {
if self.marked.is_empty() {
self.selected_item().map(|i| vec![i.id]).unwrap_or_default()
} else {
self.marked.iter().copied().collect()
}
}
pub fn handle_key(&mut self, key: KeyEvent) -> Result<()> {
match self.mode.clone() {
Mode::Normal => self.handle_normal(key),
Mode::Help => {
self.mode = Mode::Normal;
Ok(())
}
Mode::Input(_) => self.handle_input(key),
Mode::Properties(_) => self.handle_properties(key),
Mode::Form(_) => self.handle_form(key),
Mode::Dependencies { .. } => self.handle_dependencies(key),
Mode::Categories => self.handle_categories(key),
Mode::Views => self.handle_views(key),
Mode::CategoryManager => self.handle_category_manager(key),
Mode::Menu => self.handle_menu(key),
}
}
fn handle_normal(&mut self, key: KeyEvent) -> Result<()> {
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('s') {
self.db.checkpoint()?;
self.status = "Saved and checkpointed".into();
return Ok(());
}
match key.code {
KeyCode::Char('q') => self.should_quit = true,
KeyCode::Up | KeyCode::Char('k') => self.move_selection(-1),
KeyCode::Down | KeyCode::Char('j') => self.move_selection(1),
KeyCode::PageUp => self.move_selection(-10),
KeyCode::PageDown => self.move_selection(10),
KeyCode::Home => self.selected = 0,
KeyCode::End => self.selected = self.items.len().saturating_sub(1),
KeyCode::Insert | KeyCode::Char('n') => {
self.open_input(InputKind::NewItem, "New item", String::new(), false)
}
KeyCode::F(2) | KeyCode::Char('e') | KeyCode::Enter => self.edit_selected(),
KeyCode::F(3) | KeyCode::Char('c') => {
if self.selected_item().is_some() {
self.category_selected = 0;
self.mode = Mode::Categories;
}
}
KeyCode::F(4) | KeyCode::Char('d') => self.toggle_done()?,
KeyCode::F(5) => self.edit_note(),
KeyCode::F(6) | KeyCode::Char('p') => self.edit_properties(),
KeyCode::F(7) | KeyCode::Char(' ') => self.toggle_mark(),
KeyCode::F(8) | KeyCode::Char('v') => {
self.view_selected = self.view_index;
self.mode = Mode::Views;
}
KeyCode::F(9) => {
self.category_selected = 0;
self.mode = Mode::CategoryManager;
}
KeyCode::F(10) | KeyCode::Char('m') => self.mode = Mode::Menu,
KeyCode::F(1) | KeyCode::Char('?') => self.mode = Mode::Help,
KeyCode::Char('r') => self.open_dependencies()?,
KeyCode::Char('/') => self.open_input(
InputKind::Search,
"Search current view",
self.search.clone(),
false,
),
KeyCode::Char(']') => self.switch_view(1)?,
KeyCode::Char('[') => self.switch_view(-1)?,
KeyCode::Delete => self.discard_or_restore()?,
KeyCode::Esc if !self.search.is_empty() => {
self.search.clear();
self.refresh()?;
self.status = "Search cleared".into();
}
_ => {}
}
Ok(())
}
fn move_selection(&mut self, delta: isize) {
if self.items.is_empty() {
return;
}
self.selected =
(self.selected as isize + delta).clamp(0, self.items.len() as isize - 1) as usize;
}
fn switch_view(&mut self, delta: isize) -> Result<()> {
let len = self.views.len() as isize;
self.view_index = (self.view_index as isize + delta).rem_euclid(len) as usize;
self.selected = 0;
self.scroll = 0;
self.search.clear();
self.refresh()?;
self.status = format!("View: {}", self.current_view().name);
Ok(())
}
fn open_input(&mut self, kind: InputKind, title: &str, value: String, multiline: bool) {
self.mode = Mode::Input(InputState {
kind,
title: title.into(),
value,
multiline,
});
}
fn edit_selected(&mut self) {
if let Some(i) = self.selected_item() {
self.open_input(
InputKind::EditItem(i.id),
"Edit item",
i.text.clone(),
false,
);
}
}
fn edit_note(&mut self) {
if let Some(i) = self.selected_item() {
self.open_input(
InputKind::Note(i.id),
"Item note (Ctrl-S saves; Enter adds a line)",
i.note.clone(),
true,
);
}
}
fn edit_properties(&mut self) {
if let Some(i) = self.selected_item() {
self.mode = Mode::Properties(PropsState {
item_id: i.id,
values: [
i.priority.to_string(),
i.when_at.clone().unwrap_or_default(),
i.alarm_at.clone().unwrap_or_default(),
i.numeric_value.map(|v| v.to_string()).unwrap_or_default(),
i.recurrence.clone(),
],
field: 0,
});
}
}
fn toggle_mark(&mut self) {
if let Some(id) = self.selected_item().map(|i| i.id) {
if !self.marked.remove(&id) {
self.marked.insert(id);
}
self.status = format!("{} item(s) marked", self.marked.len());
}
}
fn toggle_done(&mut self) -> Result<()> {
let ids = self.chosen_ids();
if !ids.is_empty() {
self.db.toggle_done(&ids)?;
self.marked.clear();
self.refresh()?;
self.status = format!("Updated {} item(s)", ids.len());
}
Ok(())
}
fn discard_or_restore(&mut self) -> Result<()> {
let ids = self.chosen_ids();
if ids.is_empty() {
return Ok(());
}
let restore = self.current_view().kind == "trash";
self.db.discard(&ids, !restore)?;
self.marked.clear();
self.refresh()?;
self.status = if restore {
format!("Restored {} item(s)", ids.len())
} else {
format!("Moved {} item(s) to Trash", ids.len())
};
Ok(())
}
fn handle_input(&mut self, key: KeyEvent) -> Result<()> {
let Mode::Input(mut input) = self.mode.clone() else {
return Ok(());
};
if key.code == KeyCode::Esc {
self.mode = Mode::Normal;
self.status = "Canceled".into();
return Ok(());
}
if input.multiline
&& key.modifiers.contains(KeyModifiers::CONTROL)
&& key.code == KeyCode::Char('s')
{
return self.accept_input(input);
}
match key.code {
KeyCode::Enter if input.multiline => input.value.push('\n'),
KeyCode::Enter => return self.accept_input(input),
KeyCode::Backspace => {
input.value.pop();
}
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
input.value.push(c)
}
_ => {}
}
self.mode = Mode::Input(input);
Ok(())
}
fn accept_input(&mut self, input: InputState) -> Result<()> {
match input.kind {
InputKind::NewItem => {
self.db.add_item(&input.value)?;
self.status = "Item captured and automatically filed".into();
}
InputKind::EditItem(id) => {
self.db.update_item(
id,
&ItemChanges {
text: Some(input.value),
..Default::default()
},
)?;
self.status = "Item updated and filing re-evaluated".into();
}
InputKind::Note(id) => {
self.db.update_item(
id,
&ItemChanges {
note: Some(input.value),
..Default::default()
},
)?;
self.status = "Note saved".into();
}
InputKind::Search => {
self.search = input.value;
self.status = if self.search.is_empty() {
"Search cleared".into()
} else {
format!("Filtering for “{}", self.search)
};
}
}
self.mode = Mode::Normal;
self.refresh()?;
Ok(())
}
fn handle_properties(&mut self, key: KeyEvent) -> Result<()> {
let Mode::Properties(mut props) = self.mode.clone() else {
return Ok(());
};
match key.code {
KeyCode::Esc => {
self.mode = Mode::Normal;
return Ok(());
}
KeyCode::Tab | KeyCode::Down => props.field = (props.field + 1) % 5,
KeyCode::BackTab | KeyCode::Up => props.field = (props.field + 4) % 5,
KeyCode::Backspace => {
props.values[props.field].pop();
}
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 numeric_value = if props.values[3].trim().is_empty() {
None
} else {
props.values[3].parse().ok()
};
self.db.update_item(
props.item_id,
&ItemChanges {
priority: Some(priority),
when_at: Some(when_at),
alarm_at: Some(alarm_at),
numeric_value: Some(numeric_value),
recurrence: Some(props.values[4].clone()),
..Default::default()
},
)?;
self.mode = Mode::Normal;
self.refresh()?;
self.status = "Properties saved".into();
return Ok(());
}
KeyCode::Char(c) => props.values[props.field].push(c),
_ => {}
}
self.mode = Mode::Properties(props);
Ok(())
}
fn handle_categories(&mut self, key: KeyEvent) -> Result<()> {
match key.code {
KeyCode::Esc | KeyCode::Enter => self.mode = Mode::Normal,
KeyCode::Up | KeyCode::Char('k') => {
self.category_selected = self.category_selected.saturating_sub(1)
}
KeyCode::Down | KeyCode::Char('j') => {
self.category_selected =
(self.category_selected + 1).min(self.categories.len().saturating_sub(1))
}
KeyCode::Char(' ') => {
if let (Some(item), Some(cat)) = (
self.selected_item(),
self.categories.get(self.category_selected),
) {
self.db.toggle_assignment(item.id, cat.id)?;
self.refresh()?;
self.status = "Category assignment changed".into();
}
}
_ => {}
}
Ok(())
}
fn handle_views(&mut self, key: KeyEvent) -> Result<()> {
match key.code {
KeyCode::Esc => self.mode = Mode::Normal,
KeyCode::Up if key.modifiers.contains(KeyModifiers::CONTROL) => {
if let Some(view) = self.views.get(self.view_selected) {
let id = view.id;
self.db.move_view(id, -1)?;
self.refresh()?;
self.view_selected = self.views.iter().position(|v| v.id == id).unwrap_or(0);
self.status = "View moved up".into();
}
}
KeyCode::Down if key.modifiers.contains(KeyModifiers::CONTROL) => {
if let Some(view) = self.views.get(self.view_selected) {
let id = view.id;
self.db.move_view(id, 1)?;
self.refresh()?;
self.view_selected = self.views.iter().position(|v| v.id == id).unwrap_or(0);
self.status = "View moved down".into();
}
}
KeyCode::Up | KeyCode::Char('k') => {
self.view_selected = self.view_selected.saturating_sub(1)
}
KeyCode::Down | KeyCode::Char('j') => {
self.view_selected =
(self.view_selected + 1).min(self.views.len().saturating_sub(1))
}
KeyCode::Enter => {
self.view_index = self.view_selected;
self.selected = 0;
self.scroll = 0;
self.search.clear();
self.mode = Mode::Normal;
self.refresh()?;
self.status = format!("View: {}", self.current_view().name);
}
KeyCode::Char('n') | KeyCode::Insert => self.open_view_form(None),
KeyCode::Char('e') | KeyCode::F(2) => {
if let Some(view) = self.views.get(self.view_selected).cloned() {
self.open_view_form(Some(view));
}
}
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();
}
}
_ => {}
}
Ok(())
}
fn handle_category_manager(&mut self, key: KeyEvent) -> Result<()> {
match key.code {
KeyCode::Esc => self.mode = Mode::Normal,
KeyCode::Up | KeyCode::Char('k') => {
self.category_selected = self.category_selected.saturating_sub(1)
}
KeyCode::Down | KeyCode::Char('j') => {
self.category_selected =
(self.category_selected + 1).min(self.categories.len().saturating_sub(1))
}
KeyCode::Char('n') | KeyCode::Insert => self.open_category_form(None),
KeyCode::Char('e') | KeyCode::Enter | KeyCode::F(2) => {
if let Some(category) = self.categories.get(self.category_selected).cloned() {
self.open_category_form(Some(category));
}
}
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();
}
}
_ => {}
}
Ok(())
}
fn handle_menu(&mut self, key: KeyEvent) -> Result<()> {
self.mode = Mode::Normal;
match key.code {
KeyCode::Char('n') => {
self.open_input(InputKind::NewItem, "New item", String::new(), false)
}
KeyCode::Char('s') => {
self.db.checkpoint()?;
self.status = "Saved and checkpointed".into();
}
KeyCode::Char('h') => self.mode = Mode::Help,
KeyCode::Char('r') => self.open_dependencies()?,
KeyCode::Char('q') => self.should_quit = true,
_ => {}
}
Ok(())
}
fn open_category_form(&mut self, category: Option<Category>) {
let id = category.as_ref().map(|c| c.id);
let rule =
id.and_then(|category_id| self.db.category_rules(category_id).ok()?.into_iter().next());
let condition = rule
.as_ref()
.map(|r| r.condition_expr.clone())
.unwrap_or_default();
let action = rule
.map(|r| format!("{}:{}", r.action_kind, r.action_value))
.unwrap_or_default();
let values = if let Some(c) = category {
let parent = c
.parent_id
.and_then(|id| self.categories.iter().find(|p| p.id == id))
.map(|p| p.name.clone())
.unwrap_or_default();
vec![
c.name,
parent,
c.kind,
c.match_text,
yes_no(c.exclusive),
condition,
action,
]
} else {
vec![
String::new(),
String::new(),
"standard".into(),
String::new(),
"no".into(),
String::new(),
String::new(),
]
};
self.mode = Mode::Form(FormState {
kind: FormKind::Category(id),
values,
field: 0,
});
}
fn open_view_form(&mut self, view: Option<ViewDef>) {
let id = view.as_ref().map(|v| v.id);
let values = if let Some(v) = view {
let columns = v.columns_spec();
let sections = v.sections_spec();
vec![
v.name,
v.kind,
v.filter_value,
v.filter_expr,
v.sort_key,
yes_no(v.show_done),
columns,
sections,
]
} else {
vec![
String::new(),
"list".into(),
String::new(),
String::new(),
"manual".into(),
"yes".into(),
"item:48:Items:none,categories:27:Categories:none,when:20:When:none,priority:5:P:none".into(),
String::new(),
]
};
self.mode = Mode::Form(FormState {
kind: FormKind::View(id),
values,
field: 0,
});
}
fn handle_form(&mut self, key: KeyEvent) -> Result<()> {
let Mode::Form(mut form) = self.mode.clone() else {
return Ok(());
};
match key.code {
KeyCode::Esc => {
self.mode = match form.kind {
FormKind::Category(_) => Mode::CategoryManager,
FormKind::View(_) => Mode::Views,
};
return Ok(());
}
KeyCode::Tab | KeyCode::Down => form.field = (form.field + 1) % form.values.len(),
KeyCode::BackTab | KeyCode::Up => {
form.field = (form.field + form.values.len() - 1) % form.values.len()
}
KeyCode::Backspace => {
form.values[form.field].pop();
}
KeyCode::Enter => {
match form.kind {
FormKind::Category(id) => {
let parent_id = if form.values[1].trim().is_empty() {
None
} else {
self.categories
.iter()
.find(|c| c.name.eq_ignore_ascii_case(form.values[1].trim()))
.map(|c| c.id)
};
let category_id = self.db.save_category(
id,
&form.values[0],
parent_id,
form.values[2].trim(),
&form.values[3],
parse_yes(&form.values[4]),
)?;
self.db
.save_primary_rule(category_id, &form.values[5], &form.values[6])?;
self.db.reapply_automatic_assignments()?;
self.refresh()?;
self.mode = Mode::CategoryManager;
self.status = "Category definition saved".into();
}
FormKind::View(id) => {
let saved = self.db.save_view_design(
id,
&form.values[0],
form.values[1].trim(),
&form.values[2],
form.values[4].trim(),
parse_yes(&form.values[5]),
&form.values[3],
&form.values[6],
&form.values[7],
)?;
self.refresh()?;
self.view_selected =
self.views.iter().position(|v| v.id == saved).unwrap_or(0);
self.mode = Mode::Views;
self.status = "Live view saved".into();
}
}
return Ok(());
}
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
form.values[form.field].push(c)
}
_ => {}
}
self.mode = Mode::Form(form);
Ok(())
}
fn open_dependencies(&mut self) -> Result<()> {
let Some(item_id) = self.selected_item().map(|i| i.id) else {
return Ok(());
};
let Some(all) = self.views.iter().find(|v| v.kind == "list") else {
return Ok(());
};
let choices = self
.db
.items(all, "")?
.into_iter()
.filter(|i| i.id != item_id)
.collect();
self.mode = Mode::Dependencies {
item_id,
choices,
selected: 0,
};
Ok(())
}
fn handle_dependencies(&mut self, key: KeyEvent) -> Result<()> {
let Mode::Dependencies {
item_id,
choices,
mut selected,
} = self.mode.clone()
else {
return Ok(());
};
match key.code {
KeyCode::Esc | KeyCode::Enter => {
self.mode = Mode::Normal;
return Ok(());
}
KeyCode::Up | KeyCode::Char('k') => selected = selected.saturating_sub(1),
KeyCode::Down | KeyCode::Char('j') => {
selected = (selected + 1).min(choices.len().saturating_sub(1))
}
KeyCode::Char(' ') => {
if let Some(choice) = choices.get(selected) {
self.db.toggle_dependency(item_id, choice.id)?;
self.status = "Prerequisite changed".into();
}
}
_ => {}
}
self.mode = Mode::Dependencies {
item_id,
choices,
selected,
};
Ok(())
}
pub fn handle_mouse(&mut self, event: MouseEvent) -> Result<()> {
if !matches!(event.kind, MouseEventKind::Down(MouseButton::Left)) {
return Ok(());
}
let (x, y) = (event.column, event.row);
if let Some((_, id)) = self.item_rows.iter().find(|(r, _)| inside(*r, x, y)) {
if let Some(pos) = self.items.iter().position(|i| i.id == *id) {
self.selected = pos;
}
return Ok(());
}
if let Some(i) = self.command_regions.iter().position(|r| inside(*r, x, y)) {
return self.handle_key(KeyEvent::new(KeyCode::F((i + 1) as u8), KeyModifiers::NONE));
}
if let Some(i) = self.view_regions.iter().position(|r| inside(*r, x, y)) {
self.view_index = i;
self.selected = 0;
self.scroll = 0;
self.search.clear();
self.refresh()?;
}
Ok(())
}
}
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 normalize_date(value: &str) -> Option<String> {
let s = value.trim();
if s.is_empty() {
None
} else if chrono::DateTime::parse_from_rfc3339(s).is_ok() {
Some(s.into())
} else {
extract_when(&format!("on {s}"))
}
}
fn inside(r: Rect, x: u16, y: u16) -> bool {
x >= r.x && x < r.x + r.width && y >= r.y && y < r.y + r.height
}