todomd 0.2.2

A simple markdown-based todo list CLI and TUI
Documentation
use crate::core::model::{Category, ColorTag};
use crate::tui::app::{App, FocusedField, 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, created_at, updated_at, focus, date_unit, .. } |
        PopupState::EditTodo { input, notes, created_at, updated_at, focus, date_unit, .. } |
        PopupState::AddSub { input, notes, created_at, updated_at, focus, date_unit, .. } |
        PopupState::EditSub { input, notes, created_at, updated_at, focus, date_unit, .. } => {
            let area = centered_rect(80, 50, f.size());
            f.render_widget(Clear, area);
            
            // Outer block for title
            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 ",
                    _ => " Edit ",
                })
                .border_style(Style::default().fg(Color::Yellow));
            f.render_widget(block, area);

            let inner_area = area.inner(&ratatui::layout::Margin { horizontal: 1, vertical: 1 });

            let outer_chunks = Layout::default()
                .direction(Direction::Horizontal)
                .constraints([Constraint::Percentage(70), Constraint::Min(25)])
                .split(inner_area);
            
            // Left: Title & Notes
            let left_chunks = Layout::default()
                .direction(Direction::Vertical)
                .constraints([Constraint::Length(3), Constraint::Min(0)])
                .split(outer_chunks[0]);

            let title_label = match popup {
                PopupState::AddSub { .. } | PopupState::EditSub { .. } => " Subtask Title ",
                _ => " Title ",
            };

            let title_style = if matches!(focus, FocusedField::Title) { Style::default().fg(Color::Yellow) } else { Style::default().fg(Color::White) };
            let title_block = Block::default().borders(Borders::ALL).title(title_label).border_style(title_style);
            f.render_widget(Paragraph::new(input.as_str()).block(title_block), left_chunks[0]);

            let notes_style = if matches!(focus, FocusedField::Notes) { Style::default().fg(Color::Yellow) } else { Style::default().fg(Color::White) };
            let notes_block = Block::default().borders(Borders::ALL).title(" Notes ").border_style(notes_style);
            f.render_widget(Paragraph::new(notes.as_str()).block(notes_block).wrap(Wrap { trim: true }), left_chunks[1]);

            // Right: Created & Updated
            let right_chunks = Layout::default()
                .direction(Direction::Vertical)
                .constraints([Constraint::Length(4), Constraint::Length(4), Constraint::Min(0)])
                .split(outer_chunks[1]);

            let c_style = if matches!(focus, FocusedField::Created) { Style::default().fg(Color::Yellow) } else { Style::default().fg(Color::White) };
            let c_block = Block::default().borders(Borders::ALL).title(" Created At ").border_style(c_style);
            let c_para = render_datetime(*created_at, matches!(focus, FocusedField::Created), *date_unit).block(c_block);
            f.render_widget(c_para, right_chunks[0]);

            let u_style = if matches!(focus, FocusedField::Updated) { Style::default().fg(Color::Yellow) } else { Style::default().fg(Color::White) };
            let u_block = Block::default().borders(Borders::ALL).title(" Updated At ").border_style(u_style);
            let u_para = render_datetime(*updated_at, matches!(focus, FocusedField::Updated), *date_unit).block(u_block);
            f.render_widget(u_para, right_chunks[1]);
        }

        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();


    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 time_c = format_timestamp(todo.created_at);
                        let time_u = format_timestamp(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}) {:<40}  u: {}  c: {}{}", check, todo.id, todo.title, time_u, time_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 time_u = format_timestamp(sub.updated_at);
                        let indent = "  ".repeat(*depth);
                        let title_pad = 40_usize.saturating_sub(indent.len());
                        let text = format!("{} {} ({:3}) {:<width$}  u: {}", indent, check, sub.id, sub.title, time_u, width = title_pad);
                        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_timestamp(ts: i64) -> String {
    use chrono::{DateTime, TimeZone};
    let dt: DateTime<Local> = Local.timestamp_opt(ts, 0).unwrap();
    dt.format("%Y/%m/%d %H:%M:%S").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)
}

fn render_datetime(ts: i64, focused: bool, unit: usize) -> Paragraph<'static> {
    use chrono::{Datelike, DateTime, TimeZone, Timelike};
    let dt: DateTime<Local> = Local.timestamp_opt(ts, 0).unwrap();
    
    let parts = [
        format!("{:04}", dt.year()),
        format!("{:02}", dt.month()),
        format!("{:02}", dt.day()),
        format!("{:02}", dt.hour()),
        format!("{:02}", dt.minute()),
        format!("{:02}", dt.second()),
    ];
    
    let mut spans = Vec::new();
    for (i, p) in parts.iter().enumerate() {
        let style = if focused && i == unit {
            Style::default().fg(Color::Black).bg(Color::Yellow)
        } else {
            Style::default()
        };
        spans.push(Span::styled(p.clone(), style));
        if i == 0 || i == 1 { spans.push(Span::raw("/")); }
        else if i == 2 { spans.push(Span::raw(" ")); }
        else if i == 3 || i == 4 { spans.push(Span::raw(":")); }
    }
    
    Paragraph::new(Line::from(spans))
}