chloe_todo_tui 0.1.0

A terminal-based todo application with TUI
Documentation
use std::{
    io::{self, Stdout},
    time::Duration,
};

use anyhow::{Context, Result};
use crossterm::{
    event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers},
    execute,
    terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{Terminal, backend::CrosstermBackend};
use tokio::{sync::mpsc, task, time};

use crate::{
    app::{App, FormField, InputMode, StatusKind},
    database::repository::{NewTodoDraft, TodoRepository},
    ui,
};

pub type CrosstermTerminal = Terminal<CrosstermBackend<Stdout>>;

pub fn init_terminal() -> Result<CrosstermTerminal> {
    enable_raw_mode().context("failed to enable raw mode")?;
    let mut stdout = io::stdout();
    execute!(stdout, EnterAlternateScreen).context("failed to enter alternate screen")?;
    let backend = CrosstermBackend::new(stdout);
    Terminal::new(backend).context("failed to create terminal")
}

pub fn restore_terminal(terminal: &mut CrosstermTerminal) -> Result<()> {
    disable_raw_mode().context("failed to disable raw mode")?;
    execute!(terminal.backend_mut(), LeaveAlternateScreen)
        .context("failed to leave alternate screen")?;
    terminal.show_cursor().context("failed to show cursor")
}

pub async fn run_app(
    terminal: &mut CrosstermTerminal,
    mut app: App,
    repo: TodoRepository,
) -> Result<()> {
    let mut receiver = spawn_event_reader()?;
    let mut ticker = time::interval(Duration::from_millis(200));
    let size = terminal.size().context("failed to get terminal size")?;
    app.set_terminal_size((size.width, size.height));
    while !app.should_quit {
        terminal
            .draw(|frame: &mut ratatui::Frame<'_>| ui::draw(frame, &app))
            .context("failed to draw UI")?;

        tokio::select! {
            maybe_event = receiver.recv() => {
                if let Some(event) = maybe_event {
                    handle_event(event, &mut app, &repo).await?;
                } else {
                    break;
                }
            }
            _ = ticker.tick() => {}
        }
    }

    Ok(())
}

fn spawn_event_reader() -> Result<mpsc::UnboundedReceiver<Event>> {
    let (tx, rx) = mpsc::unbounded_channel();
    task::spawn(async move {
        loop {
            if tx.is_closed() {
                break;
            }

            let event = task::spawn_blocking(|| {
                if crossterm::event::poll(Duration::from_millis(100))? {
                    crossterm::event::read().map(Some)
                } else {
                    Ok(None)
                }
            })
            .await;

            match event {
                Ok(Ok(Some(evt))) => {
                    if tx.send(evt).is_err() {
                        break;
                    }
                }
                Ok(Ok(None)) => continue,
                Ok(Err(_)) | Err(_) => break,
            }
        }
    });
    Ok(rx)
}

async fn handle_event(event: Event, app: &mut App, repo: &TodoRepository) -> Result<()> {
    match event {
        Event::Key(key) if key.kind == KeyEventKind::Release => Ok(()),
        Event::Key(key) => {
            if let Some(command) = match app.mode {
                InputMode::Normal => handle_normal_key(key, app),
                InputMode::Adding => handle_add_key(key, app),
            } {
                execute_command(command, app, repo).await?;
            }
            Ok(())
        }
        Event::Resize(width, height) => {
            // write to log file
            use std::io::Write;
            let open_or_create = std::fs::OpenOptions::new()
                .create(true)
                .append(true)
                .open("resize.log");
            let mut file = open_or_create.unwrap();
            write!(file, "Terminal resized to {}x{}\n", width, height).unwrap();
            app.set_terminal_size((width, height));
            Ok(())
        }
        _ => Ok(()),
    }
}

fn handle_normal_key(key: KeyEvent, app: &mut App) -> Option<Command> {
    if key.kind == KeyEventKind::Release {
        return None;
    }

    match key.code {
        KeyCode::Char(c) if c == ' ' => app.selected_todo().map(|todo| Command::Toggle {
            id: todo.id,
            target: !todo.completed,
        }),
        KeyCode::Char(c) => match c.to_ascii_lowercase() {
            'q' => {
                app.should_quit = true;
                None
            }
            'j' => {
                app.next();
                None
            }
            'k' => {
                app.previous();
                None
            }
            'a' => {
                app.enter_add_mode();
                None
            }
            'd' => app.selected_todo_id().map(Command::Delete),
            'r' => Some(Command::Refresh),
            _ => None,
        },
        KeyCode::Down => {
            app.next();
            None
        }
        KeyCode::Up => {
            app.previous();
            None
        }
        KeyCode::Tab => {
            app.cycle_filter();
            None
        }
        KeyCode::Enter => app.selected_todo().map(|todo| Command::Toggle {
            id: todo.id,
            target: !todo.completed,
        }),
        _ => None,
    }
}

fn handle_add_key(key: KeyEvent, app: &mut App) -> Option<Command> {
    if key.kind == KeyEventKind::Release {
        return None;
    }

    match key.code {
        KeyCode::Esc => {
            app.reset_form();
            app.exit_add_mode();
            app.set_status(StatusKind::Info, "Cancelled new todo");
            None
        }
        KeyCode::Enter => {
            if let Some(draft) = app.consume_form() {
                Some(Command::Add(draft))
            } else {
                app.set_status(StatusKind::Error, "Title cannot be empty");
                None
            }
        }
        KeyCode::Backspace => {
            app.erase_char();
            None
        }
        KeyCode::Tab => {
            app.toggle_field();
            None
        }
        KeyCode::Up => {
            app.increase_priority();
            None
        }
        KeyCode::Down => {
            app.decrease_priority();
            None
        }

        KeyCode::Char(ch) => {
            if key.modifiers.contains(KeyModifiers::CONTROL)
                || key.modifiers.contains(KeyModifiers::ALT)
            {
                return None;
            }
            if !ch.is_control() && app.active_field != FormField::Priority {
                app.push_char(ch);
            } else if app.active_field == FormField::Priority {
                match ch {
                    '1' => app.set_priority_low(),
                    '2' => app.set_priority_medium(),
                    '3' => app.set_priority_high(),
                    _ => {}
                }
            }
            None
        }
        _ => None,
    }
}

#[derive(Debug)]
enum Command {
    Refresh,
    Toggle { id: i32, target: bool },
    Delete(i32),
    Add(NewTodoDraft),
}

async fn execute_command(command: Command, app: &mut App, repo: &TodoRepository) -> Result<()> {
    app.set_loading(true);
    let outcome = match command {
        Command::Refresh => {
            refresh(app, repo).await?;
            app.set_status(StatusKind::Info, "Todo list refreshed");
            Ok(())
        }
        Command::Toggle { id, target } => {
            repo.set_completed(id, target)
                .await
                .with_context(|| format!("failed to update todo #{id}"))?;
            refresh(app, repo).await?;
            app.set_status(
                StatusKind::Success,
                if target {
                    "Marked as complete"
                } else {
                    "Marked as active"
                },
            );
            Ok(())
        }
        Command::Delete(id) => {
            repo.delete(id)
                .await
                .with_context(|| format!("failed to delete todo #{id}"))?;
            refresh(app, repo).await?;
            app.set_status(StatusKind::Success, format!("Deleted todo #{id}"));
            Ok(())
        }
        Command::Add(draft) => {
            app.exit_add_mode();
            match repo.insert(&draft).await {
                Ok(inserted) => {
                    refresh(app, repo).await?;
                    app.reset_form();
                    app.set_status(
                        StatusKind::Success,
                        format!("Added '{}'", inserted.title.trim()),
                    );
                    Ok(())
                }
                Err(err) => {
                    app.mode = InputMode::Adding;
                    app.active_field = FormField::Title;
                    app.form = draft;
                    Err(err)
                }
            }
        }
    };
    app.set_loading(false);

    if let Err(err) = outcome {
        app.set_status(StatusKind::Error, format!("{}", err));
    }

    Ok(())
}

async fn refresh(app: &mut App, repo: &TodoRepository) -> Result<()> {
    let todos = repo.list_all().await?;
    app.set_todos(todos);
    Ok(())
}