tododo 0.1.2

A minimal terminal todo manager built with Rust and Ratatui
Documentation
use chrono::Local;
use ratatui::{
    layout::{Alignment, Constraint, Margin, Rect},
    style::{Color, Style},
    widgets::{Block, Borders, Cell, Paragraph, Row, Table},
};

use crate::actions::AppAction;
use crate::app::App;
use crate::domain::TodoStatus;
use crate::screens::layout::vstack;

const STATUS_COL_WIDTH: u16 = 2;
const PRIORITY_COL_WIDTH: u16 = 1;
const TIME_COL_WIDTH: u16 = 16;

fn format_item_time(
    todo: &crate::domain::todo::Todo,
    sort_mode: crate::domain::SortMode,
) -> String {
    let use_created_at = matches!(sort_mode, crate::domain::SortMode::CreatedAt);

    if !use_created_at && matches!(todo.status, TodoStatus::Completed) {
        if let Some(completed_at) = &todo.completed_at {
            let local = completed_at.with_timezone(&Local);
            local.format("%Y-%m-%d %H:%M").to_string()
        } else {
            String::new()
        }
    } else {
        let local = todo.created_at.with_timezone(&Local);
        local.format("%Y-%m-%d %H:%M").to_string()
    }
}

fn priority_color(priority: i32) -> Color {
    match priority {
        1 => Color::Magenta,
        2 => Color::Yellow,
        3 => Color::Cyan,
        4 => Color::Green,
        _ => Color::DarkGray,
    }
}

pub fn render_list(app: &mut App, frame: &mut ratatui::Frame) {
    let [header, body, footer] = vstack(frame.area());

    render_header(app, frame, header);
    render_todo_table(app, frame, body);
    render_footer(app, frame, footer);
}

fn render_header(app: &App, frame: &mut ratatui::Frame, area: Rect) {
    let remaining = app
        .todos
        .iter()
        .filter(|t| matches!(t.status, TodoStatus::Pending))
        .count();

    let sort_label = app.sort_mode.label();
    let header_text = if let Some(err) = &app.error_message {
        format!(
            "Remaining: {}   Sort: {}   Error: {}",
            remaining, sort_label, err
        )
    } else {
        format!("Remaining: {}   Sort: {}", remaining, sort_label)
    };

    let header = Paragraph::new(header_text).block(
        Block::default()
            .title(" Todos ")
            .title_alignment(Alignment::Left)
            .borders(Borders::ALL)
            .border_style(Style::new().fg(Color::White)),
    );

    frame.render_widget(header, area);
}

fn render_todo_table(app: &mut App, frame: &mut ratatui::Frame, area: Rect) {
    if app.todos.is_empty() {
        let empty = Paragraph::new("No todos")
            .block(
                Block::default()
                    .borders(Borders::ALL)
                    .border_style(Style::new().fg(Color::White)),
            )
            .alignment(Alignment::Center);
        frame.render_widget(empty, area);
        return;
    }

    let selected_idx = app.list_state.selected();
    let sort_mode = app.sort_mode;

    let rows: Vec<Row> = app
        .todos
        .iter()
        .enumerate()
        .map(|(idx, todo)| {
            let is_selected = selected_idx == Some(idx);
            let is_completed = matches!(todo.status, TodoStatus::Completed);

            let status_char = if is_completed { "" } else { "" };
            let status_style = Style::new().fg(Color::DarkGray);

            let priority_str = todo.priority.to_char();
            let has_priority = !priority_str.is_empty();

            let time_str = format_item_time(todo, sort_mode);
            let time_style = Style::new().fg(Color::DarkGray);

            let selection_indicator = if is_selected { "" } else { " " };
            let priority_display = if has_priority {
                priority_str.to_string()
            } else {
                " ".to_string()
            };
            let p_color = if has_priority {
                priority_color(todo.priority.into())
            } else {
                Color::DarkGray
            };

            let title_style = if is_completed {
                Style::new().fg(Color::DarkGray)
            } else {
                Style::new()
            };

            Row::new(vec![
                Cell::from(format!("{}{}", status_char, selection_indicator)).style(status_style),
                Cell::from(priority_display).style(Style::new().fg(p_color)),
                Cell::from(todo.title.clone()).style(title_style),
                Cell::from(time_str.clone()).style(time_style),
            ])
        })
        .collect();

    let table = Table::new(
        rows,
        &[
            Constraint::Length(STATUS_COL_WIDTH),
            Constraint::Length(PRIORITY_COL_WIDTH),
            Constraint::Min(10),
            Constraint::Length(TIME_COL_WIDTH),
        ],
    )
    .row_highlight_style(Style::new().reversed())
    .highlight_symbol("")
    .block(
        Block::default()
            .borders(Borders::ALL)
            .border_style(Style::new().fg(Color::White)),
    );

    frame.render_stateful_widget(table, area, &mut app.list_state);
}

fn render_footer(_app: &App, frame: &mut ratatui::Frame, area: Rect) {
    frame.render_widget(
        ratatui::widgets::Paragraph::new(
            "[Mouse] Click/Scroll | [J/K] Select | [Enter] Open | [Space] Toggle | [E] Edit | [P] Priority | [S] Sort | [A] New | [D] Delete | [Q/Ctrl+C] Quit",
        )
        .alignment(Alignment::Left),
        area,
    );
}

pub fn mouse_action(app: &App, area: Rect, row: u16, col: u16) -> Option<AppAction> {
    let [_, table_area, _] = vstack(area);
    click_action(app, table_area, row, col)
}

fn click_action(app: &App, table_area: Rect, row: u16, col: u16) -> Option<AppAction> {
    if app.todos.is_empty() || table_area.width < 3 || table_area.height < 3 {
        return None;
    }

    let inner = table_area.inner(Margin {
        vertical: 1,
        horizontal: 1,
    });

    if row < inner.y
        || row >= inner.y.saturating_add(inner.height)
        || col < inner.x
        || col >= inner.x.saturating_add(inner.width)
    {
        return None;
    }

    let visible_row = (row - inner.y) as usize;
    let item_index = app.list_state.offset().saturating_add(visible_row);
    if item_index >= app.todos.len() {
        return None;
    }

    let was_selected = app.list_state.selected() == Some(item_index);
    let todo_id = app.todos[item_index].id.clone();

    let rel_col = col - inner.x;
    if rel_col < STATUS_COL_WIDTH {
        return Some(AppAction::ToggleTodo(todo_id));
    }

    if rel_col < STATUS_COL_WIDTH + PRIORITY_COL_WIDTH {
        let next_priority = app.todos[item_index].priority.next();
        return Some(AppAction::SetPriority(
            todo_id,
            next_priority.to_char().to_string(),
        ));
    }

    let title_start = STATUS_COL_WIDTH + PRIORITY_COL_WIDTH;
    let title_end = inner.width.saturating_sub(TIME_COL_WIDTH);
    if rel_col >= title_start && rel_col < title_end && was_selected {
        return Some(AppAction::OpenDetail(todo_id));
    }

    Some(AppAction::SelectTodo(item_index))
}