use crate::core::model::{Category, ColorTag, State};
use crate::core::ops;
use crate::core::store::Store;
use crate::persist::atomic::FileStore;
use crate::tui::{keymap, ui};
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{backend::CrosstermBackend, Terminal};
use std::io;
use std::path::PathBuf;
#[derive(Debug, Clone)]
pub enum RowRef {
Header(Category),
Item { category: Category, id: u64, depth: usize },
}
#[derive(Debug, PartialEq, Eq)]
pub enum InputMode {
Normal,
Popup(PopupState),
}
#[derive(Debug, PartialEq, Eq)]
pub enum PopupState {
AddTodo { category: Category, input: String, notes: String, editing_notes: bool },
EditTodo { id: u64, input: String, notes: String, editing_notes: bool },
AddSub { todo_id: u64, input: String, notes: String, editing_notes: bool },
EditSub { todo_id: u64, sub_id: u64, input: String, notes: String, editing_notes: bool },
Move { id: u64, current_selection: usize }, DeleteConfirm { id: u64, is_sub: bool }, Message { text: String },
}
pub struct App {
pub state: State,
pub store: FileStore,
pub rows: Vec<RowRef>,
pub cursor: usize,
pub scroll: usize,
pub mode: InputMode,
pub show_notes: bool,
pub exit: bool,
}
impl App {
pub fn new(path: PathBuf) -> anyhow::Result<Self> {
let store = FileStore::new(path);
let state = store.load()?;
let mut app = Self {
state,
store,
rows: Vec::new(),
cursor: 0,
scroll: 0,
mode: InputMode::Normal,
show_notes: false,
exit: false,
};
app.rebuild_rows();
Ok(app)
}
pub fn rebuild_rows(&mut self) {
self.rows.clear();
for cat in State::all_categories() {
self.rows.push(RowRef::Header(cat));
let todos: Vec<_> = self.state.get_category(cat).iter().map(|t| (t.id, t.subtasks.clone())).collect();
for (id, subtasks) in todos {
self.rows.push(RowRef::Item { category: cat, id, depth: 0 });
self.add_subs_recursive(&subtasks, cat, 1);
}
}
if self.cursor >= self.rows.len() && !self.rows.is_empty() {
self.cursor = self.rows.len() - 1;
}
}
fn add_subs_recursive(&mut self, subs: &[crate::core::model::SubTask], cat: Category, depth: usize) {
for sub in subs {
self.rows.push(RowRef::Item { category: cat, id: sub.id, depth });
if !sub.subtasks.is_empty() {
self.add_subs_recursive(&sub.subtasks, cat, depth + 1);
}
}
}
fn ensure_visible(&mut self, height: usize) {
if self.cursor < self.scroll {
self.scroll = self.cursor;
} else if self.cursor >= self.scroll + height {
self.scroll = self.cursor.saturating_sub(height).saturating_add(1);
}
}
pub fn run(&mut self) -> anyhow::Result<()> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let res = self.run_loop(&mut terminal);
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
if let Err(err) = res {
println!("{:?}", err);
}
Ok(())
}
fn run_loop(&mut self, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> anyhow::Result<()> {
while !self.exit {
terminal.draw(|f| {
ui::draw(f, self);
})?;
if event::poll(std::time::Duration::from_millis(100))? {
match event::read()? {
Event::Key(key) => self.handle_key(key)?,
Event::Mouse(mouse) => self.handle_mouse(mouse)?,
_ => {}
}
}
}
Ok(())
}
fn handle_key(&mut self, key: event::KeyEvent) -> anyhow::Result<()> {
match &mut self.mode {
InputMode::Normal => self.handle_normal(key),
InputMode::Popup(_) => self.handle_popup(key),
}
}
fn handle_mouse(&mut self, mouse: event::MouseEvent) -> anyhow::Result<()> {
match mouse.kind {
event::MouseEventKind::ScrollDown => {
match &mut self.mode {
InputMode::Normal => self.perform_action(keymap::Action::Down)?,
InputMode::Popup(state) => match state {
PopupState::Move { current_selection, .. } => {
*current_selection = (*current_selection + 1).min(3);
}
PopupState::AddTodo { editing_notes, .. } |
PopupState::EditTodo { editing_notes, .. } |
PopupState::AddSub { editing_notes, .. } |
PopupState::EditSub { editing_notes, .. } => {
*editing_notes = true;
}
_ => {}
}
}
}
event::MouseEventKind::ScrollUp => {
match &mut self.mode {
InputMode::Normal => self.perform_action(keymap::Action::Up)?,
InputMode::Popup(state) => match state {
PopupState::Move { current_selection, .. } => {
*current_selection = current_selection.saturating_sub(1);
}
PopupState::AddTodo { editing_notes, .. } |
PopupState::EditTodo { editing_notes, .. } |
PopupState::AddSub { editing_notes, .. } |
PopupState::EditSub { editing_notes, .. } => {
*editing_notes = false;
}
_ => {}
}
}
}
_ => {}
}
Ok(())
}
fn handle_normal(&mut self, key: event::KeyEvent) -> anyhow::Result<()> {
let action = keymap::map_key(key);
self.perform_action(action)
}
fn perform_action(&mut self, action: keymap::Action) -> anyhow::Result<()> {
match action {
keymap::Action::Quit => self.exit = true,
keymap::Action::Down => {
if !self.rows.is_empty() {
self.cursor = (self.cursor + 1).min(self.rows.len() - 1);
let term_height = crossterm::terminal::size()?.1 as usize;
let list_height = term_height.saturating_sub(2);
if self.cursor >= self.scroll + list_height {
self.scroll = self.cursor + 1 - list_height;
}
}
}
keymap::Action::Up => {
self.cursor = self.cursor.saturating_sub(1);
if self.cursor < self.scroll {
self.scroll = self.cursor;
}
}
keymap::Action::Top => {
self.cursor = 0;
self.scroll = 0;
}
keymap::Action::Bottom => {
if !self.rows.is_empty() {
self.cursor = self.rows.len() - 1;
self.scroll = self.rows.len().saturating_sub(1);
}
}
keymap::Action::ReorderUp => {
if let Some(RowRef::Item { id, .. }) = self.rows.get(self.cursor) {
ops::reorder_item(&self.store, *id, true)?;
self.reload()?;
}
}
keymap::Action::ReorderDown => {
if let Some(RowRef::Item { id, .. }) = self.rows.get(self.cursor) {
ops::reorder_item(&self.store, *id, false)?;
self.reload()?;
}
}
keymap::Action::Add => {
let cat = self.get_current_category().unwrap_or(Category::Short);
self.mode = InputMode::Popup(PopupState::AddTodo {
category: cat,
input: String::new(),
notes: String::new(),
editing_notes: false
});
}
keymap::Action::Edit => {
if let Some(RowRef::Item { id, depth, .. }) = self.rows.get(self.cursor) {
if *depth == 0 {
if let Some(todo) = self.find_todo(*id) {
self.mode = InputMode::Popup(PopupState::EditTodo {
id: *id,
input: todo.title.clone(),
notes: todo.notes.clone().unwrap_or_default(),
editing_notes: false
});
}
} else {
if let Some(sub) = self.find_sub_by_id(*id) {
self.mode = InputMode::Popup(PopupState::EditSub {
todo_id: 0, sub_id: *id,
input: sub.title.clone(),
notes: sub.notes.clone().unwrap_or_default(),
editing_notes: false
});
}
}
}
}
keymap::Action::MarkDone => {
if let Some(RowRef::Item { id, depth, .. }) = self.rows.get(self.cursor) {
if *depth == 0 {
ops::toggle_done(&self.store, *id)?;
} else {
if let Some(sub) = self.find_sub_by_id(*id) {
ops::edit_subtask(&self.store, 0, *id, None, Some(!sub.done), None)?;
}
}
self.reload()?;
}
}
keymap::Action::CompleteTask => {
if let Some(RowRef::Item { id, depth, .. }) = self.rows.get(self.cursor) {
if *depth == 0 {
ops::move_todo(&self.store, *id, Category::Completed)?;
self.reload()?;
} else {
self.mode = InputMode::Popup(PopupState::Message {
text: "The 'C' command only works for top-level tasks. \nSubtasks should be marked done with 'space' or 'x'.".to_string()
});
}
}
}
keymap::Action::ToggleNotes => {
self.show_notes = !self.show_notes;
}
keymap::Action::UndoDone => {
if let Some(RowRef::Item { id, category, .. }) = self.rows.get(self.cursor) {
if *category == Category::Completed {
ops::move_todo(&self.store, *id, Category::Short)?;
self.reload()?;
}
}
}
keymap::Action::Delete => {
if let Some(RowRef::Item { id, depth, .. }) = self.rows.get(self.cursor) {
self.mode = InputMode::Popup(PopupState::DeleteConfirm { id: *id, is_sub: *depth > 0 });
}
}
keymap::Action::Color => {
}
keymap::Action::SelectColor(idx) => {
let tag = match idx {
1 => ColorTag::Red,
2 => ColorTag::Yellow,
3 => ColorTag::Green,
4 => ColorTag::Blue,
5 => ColorTag::Magenta,
6 => ColorTag::Cyan,
7 => ColorTag::Gray,
_ => ColorTag::None,
};
if let Some(RowRef::Item { id, .. }) = self.rows.get(self.cursor) {
ops::set_color(&self.store, *id, tag)?;
self.reload()?;
}
}
keymap::Action::Move => {
if let Some(RowRef::Item { id, depth, .. }) = self.rows.get(self.cursor) {
if *depth == 0 {
self.mode = InputMode::Popup(PopupState::Move { id: *id, current_selection: 0 });
}
}
}
keymap::Action::NewSubtask => {
if let Some(RowRef::Item { id, .. }) = self.rows.get(self.cursor) {
self.mode = InputMode::Popup(PopupState::AddSub {
todo_id: *id,
input: String::new(),
notes: String::new(),
editing_notes: false
});
}
}
keymap::Action::ToggleSubtask => {
if let Some(RowRef::Item { id, depth, .. }) = self.rows.get(self.cursor) {
if *depth > 0 {
if let Some(sub) = self.find_sub_by_id(*id) {
ops::edit_subtask(&self.store, 0, *id, None, Some(!sub.done), None)?;
self.reload()?;
}
}
}
}
_ => {}
}
Ok(())
}
fn handle_popup(&mut self, key: event::KeyEvent) -> anyhow::Result<()> {
match key.code {
event::KeyCode::Esc => {
self.mode = InputMode::Normal;
}
event::KeyCode::Enter => {
match &mut self.mode {
InputMode::Popup(state) => match state {
PopupState::AddTodo { category, input, notes, .. } => {
ops::add_todo(&self.store, *category, input.clone(), if notes.is_empty() { None } else { Some(notes.clone()) })?;
},
PopupState::EditTodo { id, input, notes, .. } => {
ops::edit_todo(&self.store, *id, Some(input.clone()), Some(notes.clone()))?;
},
PopupState::AddSub { todo_id, input, notes, .. } => {
ops::add_subtask(&self.store, *todo_id, input.clone(), if notes.is_empty() { None } else { Some(notes.clone()) })?;
},
PopupState::EditSub { todo_id, sub_id, input, notes, .. } => {
ops::edit_subtask(&self.store, *todo_id, *sub_id, Some(input.clone()), None, Some(notes.clone()))?;
},
PopupState::DeleteConfirm { id, is_sub } => {
if *is_sub {
ops::remove_subtask(&self.store, 0, *id)?;
} else {
ops::remove_todo(&self.store, *id)?;
}
},
PopupState::Move { id, current_selection } => {
let cats = State::all_categories();
if let Some(cat) = cats.get(*current_selection) {
ops::move_todo(&self.store, *id, *cat)?;
}
},
PopupState::Message { .. } => {}
},
_ => {}
}
self.reload()?;
self.mode = InputMode::Normal;
}
event::KeyCode::Up | event::KeyCode::Char('k') => {
match &mut self.mode {
InputMode::Popup(PopupState::Move { current_selection, .. }) => {
*current_selection = current_selection.saturating_sub(1);
}
InputMode::Popup(PopupState::AddTodo { editing_notes, .. }) |
InputMode::Popup(PopupState::EditTodo { editing_notes, .. }) |
InputMode::Popup(PopupState::AddSub { editing_notes, .. }) |
InputMode::Popup(PopupState::EditSub { editing_notes, .. }) => {
*editing_notes = false; }
_ => {}
}
}
event::KeyCode::Down | event::KeyCode::Char('j') => {
match &mut self.mode {
InputMode::Popup(PopupState::Move { current_selection, .. }) => {
*current_selection = (*current_selection + 1).min(3);
}
InputMode::Popup(PopupState::AddTodo { editing_notes, .. }) |
InputMode::Popup(PopupState::EditTodo { editing_notes, .. }) |
InputMode::Popup(PopupState::AddSub { editing_notes, .. }) |
InputMode::Popup(PopupState::EditSub { editing_notes, .. }) => {
*editing_notes = true; }
_ => {}
}
}
event::KeyCode::Char(c) => {
match &mut self.mode {
InputMode::Popup(state) => match state {
PopupState::AddTodo { input, notes, editing_notes, .. } |
PopupState::EditTodo { input, notes, editing_notes, .. } |
PopupState::AddSub { input, notes, editing_notes, .. } |
PopupState::EditSub { input, notes, editing_notes, .. } => {
if *editing_notes {
notes.push(c);
} else {
input.push(c);
}
},
_ => {}
},
_ => {}
}
}
event::KeyCode::Backspace => {
match &mut self.mode {
InputMode::Popup(state) => match state {
PopupState::AddTodo { input, notes, editing_notes, .. } |
PopupState::EditTodo { input, notes, editing_notes, .. } |
PopupState::AddSub { input, notes, editing_notes, .. } |
PopupState::EditSub { input, notes, editing_notes, .. } => {
if *editing_notes {
notes.pop();
} else {
input.pop();
}
},
_ => {}
},
_ => {}
}
}
event::KeyCode::Tab => {
match &mut self.mode {
InputMode::Popup(state) => match state {
PopupState::AddTodo { editing_notes, .. } |
PopupState::EditTodo { editing_notes, .. } |
PopupState::AddSub { editing_notes, .. } |
PopupState::EditSub { editing_notes, .. } => {
*editing_notes = !*editing_notes;
},
_ => {}
},
_ => {}
}
}
_ => {}
}
Ok(())
}
fn reload(&mut self) -> anyhow::Result<()> {
self.state = self.store.load()?;
self.rebuild_rows();
Ok(())
}
fn get_current_category(&self) -> Option<Category> {
if let Some(row) = self.rows.get(self.cursor) {
match row {
RowRef::Header(cat) => Some(*cat),
RowRef::Item { category, .. } => Some(*category),
}
} else {
None
}
}
pub fn get_current_item_notes(&self) -> Option<String> {
if let Some(RowRef::Item { id, depth, .. }) = self.rows.get(self.cursor) {
if *depth == 0 {
self.find_todo(*id).and_then(|t| t.notes.clone())
} else {
self.find_sub_by_id(*id).and_then(|s| s.notes.clone())
}
} else {
None
}
}
fn find_todo(&self, id: u64) -> Option<&crate::core::model::Todo> {
for cat in State::all_categories() {
if let Some(t) = self.state.get_category(cat).iter().find(|t| t.id == id) {
return Some(t);
}
}
None
}
fn find_sub_by_id(&self, sub_id: u64) -> Option<&crate::core::model::SubTask> {
for cat in State::all_categories() {
for todo in self.state.get_category(cat) {
if let Some(sub) = self.find_sub_recursive(&todo.subtasks, sub_id) {
return Some(sub);
}
}
}
None
}
fn find_sub_recursive<'a>(&self, subs: &'a [crate::core::model::SubTask], id: u64) -> Option<&'a crate::core::model::SubTask> {
for sub in subs {
if sub.id == id { return Some(sub); }
if let Some(found) = self.find_sub_recursive(&sub.subtasks, id) {
return Some(found);
}
}
None
}
}