use crate::{
db::DB,
project::{Project, Task},
ui::{editor::Editor, logo::Logo, projects_pane::ProjectsPane, tasks_pane::TasksPane},
};
use ratatui::{
DefaultTerminal, Frame,
buffer::Buffer,
crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind},
layout::{Constraint, Direction, Layout, Rect},
widgets::{ListState, Widget},
};
use std::{io, path::PathBuf, vec};
use tui_textarea::{CursorMove, Input, TextArea};
#[derive(Debug)]
pub struct App {
mode: Mode,
prev_mode: Mode,
db: DB,
projects: Vec<Project>,
project_list_state: ListState,
task_list_state: ListState,
text_area: TextArea<'static>,
}
impl App {
pub fn new(db_path: PathBuf) -> rusqlite::Result<Self> {
let db = DB::new(db_path)?;
db.init()?;
let mut projects = db.get_all_projects()?;
for project in &mut projects {
let tasks = db.get_all_tasks_in_project(project.id)?;
project.populate_tasks(tasks);
}
Ok(Self {
mode: Mode::InProjectsPane,
prev_mode: Mode::InProjectsPane,
db,
projects,
project_list_state: ListState::default(),
task_list_state: ListState::default(),
text_area: TextArea::default(),
})
}
pub fn run(&mut self, terminal: &mut DefaultTerminal) -> AppResult<()> {
while self.mode != Mode::Terminated {
terminal.draw(|frame| self.draw(frame))?;
self.handle_events()?;
}
Ok(())
}
fn draw(&mut self, frame: &mut Frame) {
frame.render_widget(&mut *self, frame.area());
}
fn handle_events(&mut self) -> AppResult<()> {
match event::read()? {
Event::Key(key_event) if key_event.kind == KeyEventKind::Press => {
self.handle_key_event(key_event)?
}
_ => {}
};
Ok(())
}
fn handle_key_event(&mut self, key_event: KeyEvent) -> AppResult<()> {
match key_event.code {
KeyCode::Esc => {
if matches!(self.mode, Mode::Editing { .. }) {
self.exit_editor();
} else {
self.mode = Mode::Terminated;
}
}
KeyCode::Enter => self.save_editor_entry()?,
_ if matches!(self.mode, Mode::Editing { .. }) => {
self.enter_char(Input::from(key_event));
}
KeyCode::Char('h') => self.switch_to_projects_pane(),
KeyCode::Char('l') => self.switch_to_tasks_pane(),
KeyCode::Char('j') | KeyCode::Down => self.select_next_item(),
KeyCode::Char('k') | KeyCode::Up => self.select_previous_item(),
KeyCode::Char('g') => self.select_first_item(),
KeyCode::Char('G') => self.select_last_item(),
KeyCode::Char('d') => self.remove_selected_item()?,
KeyCode::Char('a') => self.to_editor_insert(),
KeyCode::Char('r') => self.to_editor_rename(),
KeyCode::Char(' ') => self.toggle_task_status()?,
_ => {}
};
Ok(())
}
}
impl App {
fn switch_to_projects_pane(&mut self) {
if self.mode == Mode::InTasksPane {
self.prev_mode = Mode::InProjectsPane;
self.mode = Mode::InProjectsPane;
self.task_list_state.select(None);
}
}
fn switch_to_tasks_pane(&mut self) {
if self.mode == Mode::InProjectsPane {
self.prev_mode = Mode::InTasksPane;
self.mode = Mode::InTasksPane;
}
}
fn to_editor_insert(&mut self) {
match self.mode {
Mode::InProjectsPane => {
self.mode = Mode::Editing {
curr: None,
mode: EditMode::Insert,
}
}
Mode::InTasksPane => {
if let Some(_) = self.project_list_state.selected() {
self.mode = Mode::Editing {
curr: None,
mode: EditMode::Insert,
}
}
}
_ => {}
}
}
fn to_editor_rename(&mut self) {
match self.mode {
Mode::InProjectsPane => {
if let Some(index) = self.project_list_state.selected()
&& let Some(project) = self.projects.get(index)
{
self.mode = Mode::Editing {
curr: None,
mode: EditMode::Rename,
};
self.populate_editor(project.title.clone().as_str());
}
}
Mode::InTasksPane => {
if let Some(project_index) = self.project_list_state.selected()
&& let Some(project) = self.projects.get(project_index)
&& let Some(task_index) = self.task_list_state.selected()
{
self.mode = Mode::Editing {
curr: None,
mode: EditMode::Rename,
};
if let Some(task_title) = project.get_task_title(task_index) {
self.populate_editor(task_title.as_str());
}
}
}
_ => {}
}
}
}
impl App {
fn enter_char(&mut self, curr_input: Input) {
if let Mode::Editing { curr, .. } = &mut self.mode {
*curr = Some(curr_input);
}
}
fn reset_editor(&mut self) {
self.text_area.move_cursor(CursorMove::Head);
self.text_area.start_selection();
self.text_area.move_cursor(CursorMove::End);
self.text_area.cut();
}
fn exit_editor(&mut self) {
self.reset_editor();
match self.prev_mode {
Mode::InProjectsPane => self.mode = Mode::InProjectsPane,
Mode::InTasksPane => self.mode = Mode::InTasksPane,
_ => {}
}
}
fn populate_editor(&mut self, line: &str) {
self.text_area.insert_str(line);
self.text_area.move_cursor(CursorMove::End);
}
fn save_editor_entry(&mut self) -> AppResult<()> {
let entry = self.text_area.lines()[0].trim().to_string().clone();
if entry.len() == 0 {
self.reset_editor();
return Ok(());
}
if let Mode::Editing { mode, .. } = &self.mode {
match mode {
EditMode::Insert => match self.prev_mode {
Mode::InProjectsPane => {
self.create_new_project(entry)?;
self.reset_editor();
self.mode = Mode::InProjectsPane;
self.project_list_state.select_last();
}
Mode::InTasksPane => {
self.create_new_task(entry)?;
self.reset_editor();
self.mode = Mode::InTasksPane;
self.task_list_state.select_last();
}
_ => {}
},
EditMode::Rename => match self.prev_mode {
Mode::InProjectsPane => {
self.rename_selected_project(entry)?;
self.reset_editor();
self.mode = Mode::InProjectsPane;
}
Mode::InTasksPane => {
self.rename_selected_task(entry)?;
self.reset_editor();
self.mode = Mode::InTasksPane;
}
_ => {}
},
};
}
Ok(())
}
}
impl App {
fn select_next_item(&mut self) {
match self.mode {
Mode::InProjectsPane => {
if let Some(index) = self.project_list_state.selected() {
if index != self.projects.len() - 1 {
self.project_list_state.select_next();
}
} else {
self.project_list_state.select_next();
}
}
Mode::InTasksPane => self.task_list_state.select_next(),
_ => {}
}
}
fn select_previous_item(&mut self) {
match self.mode {
Mode::InProjectsPane => self.project_list_state.select_previous(),
Mode::InTasksPane => self.task_list_state.select_previous(),
_ => {}
}
}
fn select_first_item(&mut self) {
match self.mode {
Mode::InProjectsPane => self.project_list_state.select_first(),
Mode::InTasksPane => self.task_list_state.select_first(),
_ => {}
}
}
fn select_last_item(&mut self) {
match self.mode {
Mode::InProjectsPane => self.project_list_state.select_last(),
Mode::InTasksPane => self.task_list_state.select_last(),
_ => {}
}
}
fn remove_selected_item(&mut self) -> AppResult<()> {
match self.mode {
Mode::InProjectsPane => self.delete_selected_project()?,
Mode::InTasksPane => self.delete_selected_task()?,
_ => {}
};
Ok(())
}
}
impl App {
fn delete_selected_project(&mut self) -> AppResult<()> {
if let Some(index) = self.project_list_state.selected() {
let project = self.projects.remove(index);
self.db.delete_project_by_id(project.id)?;
}
Ok(())
}
fn delete_selected_task(&mut self) -> AppResult<()> {
if let Some(project_index) = self.project_list_state.selected()
&& let Some(task_index) = self.task_list_state.selected()
&& let Some(project) = self.projects.get_mut(project_index)
{
let task_id = project.remove_task(task_index);
self.db.delete_task_by_id(task_id)?;
}
Ok(())
}
fn create_new_project(&mut self, title: String) -> AppResult<()> {
let id = self.db.insert_new_project(&title)?;
self.projects.push(Project::new(id, title, vec![]));
Ok(())
}
fn create_new_task(&mut self, title: String) -> AppResult<()> {
if let Some(index) = self.project_list_state.selected()
&& let Some(project) = self.projects.get_mut(index)
{
let id = self.db.insert_new_task_in_project(&title, project.id)?;
project.add_task(Task::new(id, title, false));
}
Ok(())
}
fn rename_selected_project(&mut self, new_title: String) -> AppResult<()> {
if let Some(index) = self.project_list_state.selected()
&& let Some(project) = self.projects.get_mut(index)
{
self.db.rename_project_by_id(&new_title, project.id)?;
project.rename(new_title);
}
Ok(())
}
fn rename_selected_task(&mut self, new_title: String) -> AppResult<()> {
if let Some(project_index) = self.project_list_state.selected()
&& let Some(project) = self.projects.get_mut(project_index)
&& let Some(task_index) = self.task_list_state.selected()
&& let Some(id) = project.rename_task(new_title.clone(), task_index)
{
self.db.rename_task_by_id(id, &new_title)?;
}
Ok(())
}
fn toggle_task_status(&mut self) -> AppResult<()> {
if self.mode == Mode::InTasksPane
&& let Some(task_index) = self.task_list_state.selected()
&& let Some(project_index) = self.project_list_state.selected()
&& let Some(project) = self.projects.get_mut(project_index)
&& let Some((task_id, is_done)) = project.toggle_task_done(task_index)
{
self.db.update_task_status(task_id, is_done)?;
}
Ok(())
}
}
impl Widget for &mut App {
fn render(self, area: Rect, buf: &mut Buffer)
where
Self: Sized,
{
let outer_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Min(0),
Constraint::Length(120),
Constraint::Min(0),
])
.split(area);
let inner_area = outer_chunks[1];
let mut constraints = vec![Constraint::Percentage(20), Constraint::Percentage(80)];
if matches!(self.mode, Mode::Editing { .. }) {
constraints.push(Constraint::Length(3));
}
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.split(inner_area);
let main_area = Layout::default()
.direction(Direction::Horizontal)
.constraints(vec![Constraint::Percentage(45), Constraint::Percentage(55)])
.split(chunks[1]);
let editor_area = if matches!(self.mode, Mode::Editing { .. }) {
Some(chunks[2])
} else {
None
};
Logo::render(chunks[0], buf);
ProjectsPane::render(
main_area[0],
buf,
&mut self.project_list_state,
&self.projects,
&self.mode,
);
if let Some(index) = self.project_list_state.selected()
&& let Some(selected_project) = self.projects.get(index)
{
TasksPane::render_with_tasks(
main_area[1],
buf,
&mut self.task_list_state,
&selected_project.tasks,
&self.mode,
);
} else {
TasksPane::render_without_tasks(main_area[1], buf, &self.mode);
}
if let Some(editor_area) = editor_area {
Editor::render(
editor_area,
buf,
&self.mode,
&self.prev_mode,
&mut self.text_area,
);
}
}
}
#[derive(Debug, PartialEq)]
pub enum Mode {
Terminated,
InProjectsPane,
InTasksPane,
Editing { curr: Option<Input>, mode: EditMode },
}
#[derive(Debug, PartialEq)]
pub enum EditMode {
Insert,
Rename,
}
pub type AppResult<T> = Result<T, AppError>;
#[derive(Debug)]
pub enum AppError {
Io(io::Error),
Sql(rusqlite::Error),
}
impl From<io::Error> for AppError {
fn from(value: io::Error) -> Self {
AppError::Io(value)
}
}
impl From<rusqlite::Error> for AppError {
fn from(value: rusqlite::Error) -> Self {
AppError::Sql(value)
}
}