tododo 0.1.2

A minimal terminal todo manager built with Rust and Ratatui
Documentation
use std::time::Duration;

use ratatui::crossterm::event::{
    DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers, MouseButton,
    MouseEventKind,
};
use ratatui::crossterm::terminal::size;

use crate::app::App;
use crate::app::Screen;
use crate::domain::TodoService;
use crate::error::AppError;
use crate::screens;
use crate::screens::editor::EditorMode;

use super::handler;
use super::keymap;

pub async fn run(
    app: &mut App,
    service: &TodoService,
    exe_dir: &std::path::Path,
) -> Result<(), AppError> {
    let mut terminal = ratatui::init();

    ratatui::crossterm::execute!(std::io::stderr(), EnableMouseCapture)
        .map_err(|e| AppError::Init(format!("failed to enable mouse: {}", e)))?;

    loop {
        terminal
            .draw(|f| match app.screen {
                Screen::List => screens::list::render_list(app, f),
                Screen::Detail => screens::detail::render_detail(app, f),
                Screen::NoteEditor => {
                    if let Some(ref ta_cell) = app.note_editor {
                        screens::editor::render_editor(
                            f,
                            EditorMode::Edit,
                            &ta_cell.borrow(),
                            "[Enter] Newline | [Ctrl+S] Save | [Esc] Cancel",
                        );
                    }
                }
                Screen::CreateTodo => {
                    if let Some(ref ta_cell) = app.create_todo_editor {
                        screens::editor::render_editor(
                            f,
                            EditorMode::Create,
                            &ta_cell.borrow(),
                            "[Enter] Newline | [Ctrl+S] Save | [Esc] Cancel",
                        );
                    }
                }
                Screen::DeleteConfirm => {
                    match app.previous_screen.as_ref() {
                        Some(Screen::Detail) => screens::detail::render_detail(app, f),
                        _ => screens::list::render_list(app, f),
                    }
                    let selected_title = app
                        .selected_id
                        .as_ref()
                        .and_then(|id| app.todos.iter().find(|t| t.id == *id))
                        .map(|t| t.title.as_str());
                    screens::delete_confirm::render_delete_confirm(f, selected_title);
                }
            })
            .map_err(|e| AppError::Init(format!("draw failed: {}", e)))?;

        if app.should_quit {
            break;
        }

        if let Some(event) = read_event().await? {
            match event {
                Event::Key(key) => handle_key_event(app, service, exe_dir, key).await?,
                Event::Mouse(mouse) => handle_mouse_event(app, service, exe_dir, mouse).await?,
                _ => {}
            }
        }
    }

    let _ = ratatui::crossterm::execute!(std::io::stderr(), DisableMouseCapture);
    ratatui::restore();
    Ok(())
}

async fn read_event() -> Result<Option<Event>, AppError> {
    match ratatui::crossterm::event::poll(Duration::from_millis(16)) {
        Ok(true) => {
            let event =
                ratatui::crossterm::event::read().map_err(|e| AppError::Init(e.to_string()))?;
            Ok(Some(event))
        }
        Ok(false) => return Ok(None),
        Err(_) => return Ok(None),
    }
}

async fn handle_key_event(
    app: &mut App,
    service: &TodoService,
    exe_dir: &std::path::Path,
    key: ratatui::crossterm::event::KeyEvent,
) -> Result<(), AppError> {
    if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
        app.should_quit = true;
        return Ok(());
    }

    if let Some(action) = keymap::handle_screen_key(app, &key) {
        handler::execute(app, action, service, exe_dir).await?;
    } else if let Some(action) = keymap::translate_key(app, &key) {
        handler::execute(app, action, service, exe_dir).await?;
    }
    Ok(())
}

async fn handle_mouse_event(
    app: &mut App,
    service: &TodoService,
    exe_dir: &std::path::Path,
    mouse: ratatui::crossterm::event::MouseEvent,
) -> Result<(), AppError> {
    let Some(terminal_area) = terminal_area() else {
        return Ok(());
    };

    match mouse.kind {
        MouseEventKind::Down(MouseButton::Left) => match app.screen {
            Screen::List => {
                if let Some(action) =
                    screens::list::mouse_action(app, terminal_area, mouse.row, mouse.column)
                {
                    handler::execute(app, action, service, exe_dir).await?;
                }
            }
            Screen::DeleteConfirm => {
                if let Some(action) = screens::delete_confirm::click_to_action(
                    mouse.row,
                    mouse.column,
                    terminal_area,
                    app.selected_id.as_deref(),
                ) {
                    handler::execute(app, action, service, exe_dir).await?;
                }
            }
            Screen::Detail => {
                if let Some(action) = screens::detail::footer_click_action(
                    terminal_area,
                    mouse.row,
                    mouse.column,
                    app.selected_id.as_deref(),
                ) {
                    handler::execute(app, action, service, exe_dir).await?;
                }
            }
            _ => {}
        },
        MouseEventKind::ScrollUp => {
            if matches!(app.screen, Screen::List) {
                handler::execute(
                    app,
                    crate::actions::AppAction::SelectPrevious,
                    service,
                    exe_dir,
                )
                .await?;
            }
        }
        MouseEventKind::ScrollDown => {
            if matches!(app.screen, Screen::List) {
                handler::execute(
                    app,
                    crate::actions::AppAction::SelectNext,
                    service,
                    exe_dir,
                )
                .await?;
            }
        }
        _ => {}
    }
    Ok(())
}

fn terminal_area() -> Option<ratatui::layout::Rect> {
    match size() {
        Ok((cols, rows)) => Some(ratatui::layout::Rect::new(0, 0, cols, rows)),
        Err(err) => {
            eprintln!("Warning: failed to read terminal size for mouse event: {}", err);
            None
        }
    }
}