tododo 0.1.2

A minimal terminal todo manager built with Rust and Ratatui
Documentation
use ratatui::{
    layout::{Alignment, Constraint, Direction, Layout, Rect},
    style::{Color, Style},
    text::{Line, Span},
    widgets::{Block, Borders, Paragraph, Table},
};

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

const FOOTER_TOGGLE: &str = "[Space] Toggle";
const FOOTER_EDIT: &str = "[E] Edit";
const FOOTER_BACK: &str = "[Enter/Esc] Back";
const FOOTER_GAP: u16 = 3;

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

    if let Some(selected_id) = &app.selected_id {
        if let Some(todo) = app.todos.iter().find(|t| &t.id == selected_id) {
            render_detail_header(frame, header, todo.title.as_str(), todo.status);
            render_detail_body(frame, body, app, todo.status);
        }
    } else {
        frame.render_widget(
            Paragraph::new("No todo selected").alignment(Alignment::Center),
            body,
        );
    }

    frame.render_widget(
        Paragraph::new(format!(
            "{} | {} | {}",
            FOOTER_TOGGLE, FOOTER_EDIT, FOOTER_BACK
        ))
        .alignment(Alignment::Left),
        footer,
    );
}

pub fn footer_click_action(
    area: Rect,
    row: u16,
    col: u16,
    selected_id: Option<&str>,
) -> Option<AppAction> {
    let [_, _, footer] = detail_regions(area);
    if footer.height == 0 || row != footer.y {
        return None;
    }

    let toggle_w = FOOTER_TOGGLE.chars().count() as u16;
    let edit_w = FOOTER_EDIT.chars().count() as u16;
    let back_w = FOOTER_BACK.chars().count() as u16;
    let total = toggle_w + FOOTER_GAP + edit_w + FOOTER_GAP + back_w;
    if footer.width < total {
        return None;
    }

    let start = footer.x;
    let toggle_start = start;
    let toggle_end = toggle_start + toggle_w;
    let edit_start = toggle_end + FOOTER_GAP;
    let edit_end = edit_start + edit_w;
    let back_start = edit_end + FOOTER_GAP;
    let back_end = back_start + back_w;

    if col >= toggle_start && col < toggle_end {
        selected_id.map(|id| AppAction::ToggleTodo(id.to_string()))
    } else if col >= edit_start && col < edit_end {
        selected_id.map(|id| AppAction::EditTodo(id.to_string()))
    } else if col >= back_start && col < back_end {
        Some(AppAction::CloseDetail)
    } else {
        None
    }
}

fn detail_regions(area: Rect) -> [Rect; 3] {
    let chunks = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Length(3),
            Constraint::Fill(1),
            Constraint::Length(1),
        ])
        .split(area);
    [chunks[0], chunks[1], chunks[2]]
}

fn render_detail_header(frame: &mut ratatui::Frame, area: Rect, title: &str, status: TodoStatus) {
    let is_completed = matches!(status, TodoStatus::Completed);
    let title_style = if is_completed {
        Style::new().fg(Color::DarkGray)
    } else {
        Style::new()
    };

    let status_icon = match status {
        TodoStatus::Pending => Span::raw(""),
        TodoStatus::Completed => Span::raw(""),
    };
    let status_style = if is_completed {
        Style::new().fg(Color::DarkGray)
    } else {
        Style::new().fg(Color::Green)
    };

    let header_text = Line::from(vec![
        status_icon.style(status_style),
        Span::raw(title).style(title_style),
    ]);

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

    let paragraph = Paragraph::new(header_text)
        .block(block)
        .alignment(Alignment::Left);
    frame.render_widget(paragraph, area);
}

fn render_detail_body(frame: &mut ratatui::Frame, area: Rect, app: &App, status: TodoStatus) {
    if let Some(selected_id) = &app.selected_id {
        if let Some(todo) = app.todos.iter().find(|t| &t.id == selected_id) {
            let is_completed = matches!(status, TodoStatus::Completed);
            let base_style = if is_completed {
                Style::new().fg(Color::DarkGray)
            } else {
                Style::new().fg(Color::White)
            };

            let priority_str = {
                let p = todo.priority.to_char();
                if p.is_empty() {
                    "".to_string()
                } else {
                    p.to_string()
                }
            };

            let status_str = match status {
                TodoStatus::Pending => "Pending",
                TodoStatus::Completed => "Completed",
            };

            let created_time = format_time(&todo.created_at);
            let mut rows: Vec<Vec<String>> = vec![
                vec!["Status".to_string(), status_str.to_string()],
                vec!["Priority".to_string(), priority_str],
                vec!["Created".to_string(), created_time],
            ];

            if is_completed {
                if let Some(completed_at) = &todo.completed_at {
                    rows.push(vec!["Completed".to_string(), format_time(completed_at)]);
                }
            }

            let table_rows: Vec<_> = rows
                .iter()
                .map(|row| {
                    ratatui::widgets::Row::new(vec![
                        ratatui::widgets::Cell::from(row[0].as_str()).style(base_style),
                        ratatui::widgets::Cell::from(row[1].as_str()).style(base_style),
                    ])
                })
                .collect();

            let table = Table::new(table_rows, &[Constraint::Length(12), Constraint::Fill(1)])
                .block(
                    Block::default()
                        .borders(Borders::ALL)
                        .border_style(Style::new().fg(Color::White))
                        .title_alignment(Alignment::Left)
                        .title(" Info "),
                )
                .column_spacing(1);

            let body_chunks = Layout::default()
                .direction(Direction::Vertical)
                .constraints([
                    Constraint::Length(7),
                    Constraint::Length(1),
                    Constraint::Fill(1),
                ])
                .split(area);

            let info_area = body_chunks[0];
            let note_area = body_chunks[2];

            frame.render_widget(table, info_area);

            if !todo.note.is_empty() {
                let note_paragraph = Paragraph::new(todo.note.as_str())
                    .block(
                        Block::default()
                            .title(" Note ")
                            .title_alignment(Alignment::Left)
                            .borders(Borders::ALL)
                            .border_style(Style::new().fg(Color::White)),
                    )
                    .style(base_style);
                frame.render_widget(note_paragraph, note_area);
            } else {
                let note_paragraph = Paragraph::new("(empty)")
                    .block(
                        Block::default()
                            .title(" Note ")
                            .title_alignment(Alignment::Left)
                            .borders(Borders::ALL)
                            .border_style(Style::new().fg(Color::DarkGray))
                            .title_style(Style::new().fg(Color::DarkGray)),
                    )
                    .style(Style::new().fg(Color::DarkGray));
                frame.render_widget(note_paragraph, note_area);
            }
        }
    }
}