chloe_todo_tui 0.1.0

A terminal-based todo application with TUI
Documentation
use ratatui::{
    Frame,
    layout::{Alignment, Constraint, Direction, Layout, Rect},
    style::{Color, Modifier, Style},
    text::{Line, Span, Text},
    widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Tabs, Wrap},
};
use textwrap::wrap;

use crate::app::{App, Filter, FormField, InputMode, StatusKind};
use crate::database::repository::Priority;

pub fn draw(frame: &mut Frame<'_>, app: &App) {
    let layout = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Length(3),
            Constraint::Min(8),
            Constraint::Length(7),
            Constraint::Length(3),
        ])
        .split(frame.area());

    render_filters(frame, app, layout[0]);
    render_todo_list(frame, app, layout[1]);

    if app.mode == InputMode::Adding {
        render_form(frame, app, layout[2]);
    } else {
        render_help(frame, layout[2]);
    }

    render_status(frame, app, layout[3]);
}

fn render_filters(frame: &mut Frame<'_>, app: &App, area: Rect) {
    let filters = [Filter::All, Filter::Active, Filter::Completed];
    let titles: Vec<Span> = filters
        .iter()
        .map(|filter| {
            let mut style = Style::default();
            match filter {
                Filter::All => style = style.fg(Color::White),
                Filter::Active => style = style.fg(Color::Green),
                Filter::Completed => style = style.fg(Color::Blue),
            }
            if *filter == app.filter {
                style = style.fg(Color::LightYellow).add_modifier(Modifier::BOLD);
            }
            let total = app
                .todos
                .iter()
                .filter(|f| match filter {
                    Filter::All => true,
                    Filter::Active => !f.completed,
                    Filter::Completed => f.completed,
                })
                .count();
            Span::styled(format!("{} ({})", filter.label(), total), style)
        })
        .collect();

    let selected = filters.iter().position(|f| *f == app.filter).unwrap_or(0);

    let tabs = Tabs::new(titles)
        .block(
            Block::default()
                .borders(Borders::ALL)
                .title(format!("Todos ({})", app.todos.len())),
        )
        .select(selected)
        .highlight_style(Style::default().fg(Color::Yellow));

    frame.render_widget(tabs, area);
}

fn render_todo_list(frame: &mut Frame<'_>, app: &App, area: Rect) {
    let todos = app.filtered_todos();
    let available_width = area.width.saturating_sub(6).max(10) as usize;

    if todos.is_empty() {
        let text = if app.mode == InputMode::Adding {
            "Start typing a new todo..."
        } else {
            "No todos match this filter. Press 'a' to add one."
        };
        frame.render_widget(
            Paragraph::new(text)
                .block(Block::default().borders(Borders::ALL).title("Todos"))
                .alignment(Alignment::Center),
            area,
        );
        return;
    }

    let items: Vec<ListItem> = todos
        .iter()
        .map(|todo| {
            let status_icon = if todo.completed { "" } else { "" };
            let priority_style = priority_style(todo.priority.as_str());
            let primary = Line::from(vec![Span::styled(
                format!("{status_icon} Title:  {}", todo.title.trim()),
                Style::default().add_modifier(if todo.completed {
                    Modifier::DIM
                } else {
                    Modifier::BOLD
                }),
            )]);
            let priority = Line::from(vec![
                Span::styled("Priority: ", Style::default()),
                Span::styled(format!("[{}]", todo.priority), priority_style),
            ]);
            // check if primary and description fit in one line

            let description = todo
                .description
                .as_deref()
                .filter(|desc| !desc.trim().is_empty())
                .unwrap_or("No description provided");

            let timestamp = format!(
                "Created: {}  Updated: {}",
                todo.created_at.format("%Y-%m-%d %H:%M"),
                todo.updated_at.format("%Y-%m-%d %H:%M"),
            );
            let description_lines: Vec<Line> = wrap(description, available_width)
                .into_iter()
                .map(|segment| Line::from(segment.into_owned()))
                .collect();
            let timestamp_line = Line::from(Span::styled(
                timestamp,
                Style::default().fg(Color::DarkGray),
            ));
            // if primary.width() + 2 + description_line.width() + 2 + timestamp_line.width()
            //     <= area.width as usize
            // {
            //     let mut combined_spans = vec![];
            //     combined_spans.extend(primary.spans);
            //     combined_spans.push(Span::raw(" | "));
            //     combined_spans.extend(description_line.spans);
            //     combined_spans.push(Span::raw(" | "));
            //     combined_spans.extend(timestamp_line.spans);

            //     return ListItem::new(vec![Line::from(combined_spans)]).style(if todo.completed {
            //         Style::default().fg(Color::Gray)
            //     } else {
            //         Style::default()
            //     });
            // }

            let mut lines = vec![primary, priority];
            lines.extend(description_lines);
            lines.push(timestamp_line);

            ListItem::new(lines).style(if todo.completed {
                Style::default().fg(Color::Gray)
            } else {
                Style::default()
            })
        })
        .collect();

    let mut state = ListState::default();
    state.select(Some(app.selected.min(items.len() - 1)));

    let list = List::new(items)
        .block(Block::default().borders(Borders::ALL).title("Todos"))
        .highlight_style(Style::default().fg(Color::LightYellow))
        .highlight_symbol("» ");

    frame.render_stateful_widget(list, area, &mut state);
}

fn render_form(frame: &mut Frame<'_>, app: &App, area: Rect) {
    let highlight = Style::default()
        .fg(Color::Yellow)
        .add_modifier(Modifier::BOLD);

    let title_label = if app.active_field == FormField::Title {
        Span::styled(format!("Title: {}", app.form.title), highlight)
    } else {
        Span::raw(format!("Title: {}", app.form.title))
    };

    let description_label = if app.active_field == FormField::Description {
        Span::styled(format!("Description: {}", app.form.description), highlight)
    } else {
        Span::raw(format!("Description: {}", app.form.description))
    };

    let priority_span = priority_label(app.form.priority);

    let instructions = Text::from(vec![
        Line::from(title_label),
        Line::from(description_label),
        Line::from(vec![Span::raw("Priority: "), priority_span]),
        Line::from("Enter to save • Esc to cancel • Tab switches field • ↑/↓ adjust priority"),
    ]);

    let paragraph = Paragraph::new(instructions)
        .wrap(Wrap { trim: false })
        .block(Block::default().borders(Borders::ALL).title("Add Todo"));

    frame.render_widget(paragraph, area);
}

fn render_help(frame: &mut Frame<'_>, area: Rect) {
    let help_text = Text::from(vec![
        Line::from("Navigation: ↑/↓ or j/k"),
        Line::from("Filter: Tab"),
        Line::from("Toggle Complete: Space"),
        Line::from("Add Todo: a"),
        Line::from("Delete Todo: d"),
        Line::from("Refresh: r"),
        Line::from("Quit: q"),
    ]);

    let paragraph = Paragraph::new(help_text)
        .block(Block::default().borders(Borders::ALL).title("Help"))
        .wrap(Wrap { trim: true });

    frame.render_widget(paragraph, area);
}

fn render_status(frame: &mut Frame<'_>, app: &App, area: Rect) {
    let (text, style) = if let Some(status) = &app.status {
        let style = match status.kind {
            StatusKind::Info => Style::default().fg(Color::Cyan),
            StatusKind::Success => Style::default().fg(Color::Green),
            StatusKind::Error => Style::default().fg(Color::Red),
        };
        (status.message.clone(), style)
    } else if app.is_loading {
        ("Loading...".to_string(), Style::default().fg(Color::Yellow))
    } else {
        (
            "Press 'a' to add, Space to toggle completion, 'q' to quit.".to_string(),
            Style::default(),
        )
    };

    let paragraph = Paragraph::new(Line::from(Span::styled(text, style)))
        .block(Block::default().borders(Borders::ALL).title("Status"));

    frame.render_widget(paragraph, area);
}

fn priority_label(priority: Priority) -> Span<'static> {
    let style = priority_style(priority.as_str());
    Span::styled(priority.label(), style)
}

fn priority_style(value: &str) -> Style {
    match value {
        "high" => Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
        "low" => Style::default().fg(Color::Green),
        _ => Style::default().fg(Color::Yellow),
    }
}