todomd 0.2.0

A simple markdown-based todo list CLI and TUI
Documentation
use crate::core::model::{Category, ColorTag};
use crate::tui::app::{App, InputMode, PopupState, RowRef};
use chrono::Local;
use ratatui::{
    layout::{Constraint, Direction, Layout, Rect},
    style::{Color, Modifier, Style},
    text::{Line, Span},
    widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap},
    Frame,
};

pub fn draw(f: &mut Frame, app: &mut App) {
    let chunks = Layout::default()
        .direction(Direction::Vertical)
        .constraints([Constraint::Min(0), Constraint::Length(3)])
        .split(f.size());

    if app.show_notes {
        let horizontal = Layout::default()
            .direction(Direction::Horizontal)
            .constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
            .split(chunks[0]);
        draw_document(f, app, horizontal[0]);
        draw_notes(f, app, horizontal[1]);
    } else {
        draw_document(f, app, chunks[0]);
    }
    draw_help(f, chunks[1]);

    if let InputMode::Popup(ref popup) = app.mode {
        draw_popup(f, popup);
    }
}

fn draw_popup(f: &mut Frame, popup: &PopupState) {
    let area = centered_rect(60, 40, f.size());
    f.render_widget(Clear, area);

    let block = Block::default()
        .borders(Borders::ALL)
        .title(match popup {
            PopupState::AddTodo { .. } => " Add Todo ",
            PopupState::EditTodo { .. } => " Edit Todo ",
            PopupState::AddSub { .. } => " Add Subtask ",
            PopupState::EditSub { .. } => " Edit Subtask ",
            PopupState::DeleteConfirm { .. } => " Delete Confirm ",
            PopupState::Move { .. } => " Move Todo ",
            PopupState::Message { .. } => " Warning ",
        })
        .border_style(Style::default().fg(Color::Yellow));
    
    match popup {
        PopupState::AddTodo { input, notes, editing_notes, .. } |
        PopupState::EditTodo { input, notes, editing_notes, .. } => {
            let chunks = Layout::default()
                .direction(Direction::Vertical)
                .margin(1)
                .constraints([Constraint::Length(3), Constraint::Min(0)])
                .split(area);

            let title_block = Block::default()
                .borders(Borders::ALL)
                .title(" Title ")
                .border_style(Style::default().fg(if !*editing_notes { Color::Yellow } else { Color::White }));
            let p_title = Paragraph::new(input.as_str()).block(title_block);
            f.render_widget(p_title, chunks[0]);

            let notes_block = Block::default()
                .borders(Borders::ALL)
                .title(" Notes ")
                .border_style(Style::default().fg(if *editing_notes { Color::Yellow } else { Color::White }));
            let p_notes = Paragraph::new(notes.as_str()).block(notes_block).wrap(Wrap { trim: true });
            f.render_widget(p_notes, chunks[1]);
            
            f.render_widget(block, area);
        }
        PopupState::AddSub { input, .. } | PopupState::EditSub { input, .. } => {
            let chunks = Layout::default()
                .direction(Direction::Vertical)
                .margin(1)
                .constraints([Constraint::Length(3), Constraint::Min(0)])
                .split(area);
            let sub_block = Block::default().borders(Borders::ALL).title(" Subtask Title ");
            let p = Paragraph::new(input.as_str()).block(sub_block);
            f.render_widget(p, chunks[0]);
            f.render_widget(block, area);
        }
        PopupState::DeleteConfirm { is_sub, .. } => {
            let text = if *is_sub { "Delete this subtask?\n(Enter to confirm, Esc to cancel)" } else { "Delete this todo and all its subtasks?\n(Enter to confirm, Esc to cancel)" };
            let p = Paragraph::new(text)
                .block(block)
                .alignment(ratatui::layout::Alignment::Center);
            f.render_widget(p, area);
        }
        PopupState::Move { current_selection, .. } => {
            let cats = ["Short", "Medium", "Long", "Completed"];
            let items: Vec<ListItem> = cats.iter().enumerate().map(|(i, &c)| {
                let style = if i == *current_selection {
                    Style::default().fg(Color::Black).bg(Color::Yellow)
                } else {
                    Style::default()
                };
                ListItem::new(c).style(style)
            }).collect();
            let list = List::new(items).block(block);
            f.render_widget(list, area);
        }
        PopupState::Message { text } => {
            let p = Paragraph::new(text.as_str())
                .block(block)
                .alignment(ratatui::layout::Alignment::Center);
            f.render_widget(p, area);
        }
    }
}

fn draw_document(f: &mut Frame, app: &mut App, area: Rect) {
    let block = Block::default().borders(Borders::ALL).title(" todomd ");
    let inner_area = block.inner(area);
    f.render_widget(block, area);

    // Calculate visible rows
    let height = inner_area.height as usize;
    // Ensure visibility (redundant if app handles it, but safe)
    if app.cursor < app.scroll {
        app.scroll = app.cursor;
    } else if app.cursor >= app.scroll + height {
        app.scroll = app.cursor.saturating_sub(height).saturating_add(1);
    }

    let start = app.scroll;
    let end = (start + height).min(app.rows.len());

    let mut items = Vec::new();
    let now = Local::now().timestamp();

    for (i, row) in app.rows.iter().enumerate().skip(start).take(end - start) {
        let is_selected = i == app.cursor;
        let content = match row {
            RowRef::Header(cat) => {
                let color = match cat {
                    Category::Short => Color::Green,
                    Category::Medium => Color::Yellow,
                    Category::Long => Color::Magenta,
                    Category::Completed => Color::DarkGray,
                };
                let style = if is_selected {
                    Style::default().fg(Color::Black).bg(color).add_modifier(Modifier::BOLD)
                } else {
                    Style::default().fg(color).add_modifier(Modifier::BOLD)
                };
                Line::from(vec![Span::styled(cat.as_str().to_uppercase(), style)])
            }
            RowRef::Item { category, id, depth } => {
                // Find item
                // If depth==0, it's a Todo. If >0, SubTask.
                if *depth == 0 {
                    if let Some(todo) = find_todo_in_state(app, *category, *id) {
                        let check = if *category == Category::Completed { "[x]" } else { "[ ]" };
                        let color = get_color(todo.color, *category);
                        let title_style = if is_selected {
                            Style::default().fg(Color::Black).bg(color)
                        } else {
                            Style::default().fg(color)
                        };
                        
                        let age_c = format_duration(now - todo.created_at);
                        let age_u = format_duration(now - todo.updated_at);
                        let sub_info = if !todo.subtasks.is_empty() {
                            let (done, total) = count_subs_recursive(&todo.subtasks);
                            format!(" subs {}/{}", done, total)
                        } else {
                            String::new()
                        };

                        let text = format!(" {} ({:3}) {}  u:{} c:{}{}", check, todo.id, todo.title, age_u, age_c, sub_info);
                        Line::from(vec![Span::styled(text, title_style)])
                    } else {
                        Line::from("Error: Todo not found")
                    }
                } else {
                     // Subtask
                     if let Some(sub) = find_sub_in_cat(app, *category, *id) {
                         let check = if sub.done { "[x]" } else { "[ ]" };
                         // We need a base color. Subtask inherits from parent or its own.
                         let color = if matches!(sub.color, ColorTag::None) { 
                             get_color(ColorTag::None, *category) 
                         } else { 
                             get_color(sub.color, *category) 
                         };
                         
                        let title_style = if is_selected {
                            Style::default().fg(Color::Black).bg(color)
                        } else {
                            Style::default().fg(color)
                        };

                        let age_u = format_duration(now - sub.updated_at);
                        let indent = "  ".repeat(*depth);
                        
                        let text = format!("{} {} ({}) {}  u:{}", indent, check, sub.id, sub.title, age_u);
                        Line::from(vec![Span::styled(text, title_style)])
                     } else {
                         Line::from("Error: Subtask not found")
                     }
                }
            }
        };
        items.push(ListItem::new(content));
    }

    let list = List::new(items);
    f.render_widget(list, inner_area);
}

fn count_subs_recursive(subs: &[crate::core::model::SubTask]) -> (usize, usize) {
    let mut done = 0;
    let mut total = 0;
    for sub in subs {
        total += 1;
        if sub.done { done += 1; }
        let (d, t) = count_subs_recursive(&sub.subtasks);
        done += d;
        total += t;
    }
    (done, total)
}

fn find_sub_in_cat(app: &App, cat: Category, id: u64) -> Option<&crate::core::model::SubTask> {
    for todo in app.state.get_category(cat) {
        if let Some(sub) = find_sub_recursive_static(&todo.subtasks, id) {
            return Some(sub);
        }
    }
    None
}

fn find_sub_recursive_static(subs: &[crate::core::model::SubTask], id: u64) -> Option<&crate::core::model::SubTask> {
    for sub in subs {
        if sub.id == id { return Some(sub); }
        if let Some(found) = find_sub_recursive_static(&sub.subtasks, id) {
            return Some(found);
        }
    }
    None
}

fn draw_help(f: &mut Frame, area: Rect) {
    let block = Block::default().borders(Borders::TOP);
    let text = "j/k move | Shift+j/k reorder | enter edit | a add | s add-sub | d del | space/x done | n notes | C move-completed | u undo | q quit";
    let p = Paragraph::new(text).block(block).style(Style::default().fg(Color::DarkGray));
    f.render_widget(p, area);
}

fn draw_notes(f: &mut Frame, app: &mut App, area: Rect) {
    let block = Block::default()
        .borders(Borders::ALL)
        .title(" Notes ")
        .border_style(Style::default().fg(Color::Cyan));
    
    let notes = app.get_current_item_notes().unwrap_or_else(|| "No notes.".to_string());
    
    let p = Paragraph::new(notes)
        .block(block)
        .wrap(Wrap { trim: true });
    
    f.render_widget(p, area);
}

fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
    let popup_layout = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Percentage((100 - percent_y) / 2),
            Constraint::Percentage(percent_y),
            Constraint::Percentage((100 - percent_y) / 2),
        ])
        .split(r);

    Layout::default()
        .direction(Direction::Horizontal)
        .constraints([
            Constraint::Percentage((100 - percent_x) / 2),
            Constraint::Percentage(percent_x),
            Constraint::Percentage((100 - percent_x) / 2),
        ])
        .split(popup_layout[1])[1]
}

fn get_color(tag: ColorTag, cat: Category) -> Color {
    match tag {
        ColorTag::Red => Color::Red,
        ColorTag::Yellow => Color::Yellow,
        ColorTag::Green => Color::Green,
        ColorTag::Blue => Color::Blue,
        ColorTag::Magenta => Color::Magenta,
        ColorTag::Cyan => Color::Cyan,
        ColorTag::Gray => Color::DarkGray,
        ColorTag::None => match cat {
            Category::Short => Color::Green,
            Category::Medium => Color::Yellow,
            Category::Long => Color::Blue, 
            Category::Completed => Color::DarkGray,
        },
    }
}

fn format_duration(seconds: i64) -> String {
    let seconds = seconds.max(0); 
    let days = seconds / 86400;
    if days > 0 {
        format!("{}d", days)
    } else {
        "0d".to_string()
    }
}

fn find_todo_in_state(app: &App, cat: Category, id: u64) -> Option<&crate::core::model::Todo> {
    app.state.get_category(cat).iter().find(|t| t.id == id)
}