working prototype
This commit is contained in:
812
src/app.rs
Normal file
812
src/app.rs
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user