argv-todo 0.1.0

A Vim-style terminal todo manager with nested tasks, priorities, search, and local SQLite storage
use crate::{
    app::{App, TaskPane},
    vim_motion::InputTarget,
};
use ratatui::{
    Frame,
    layout::{Alignment, Rect},
    style::{Modifier, Style},
    text::{Line, Span},
    widgets::{List, ListItem, Paragraph},
};

use super::super::{
    input::editor_spans,
    text::truncate,
    theme::{ACCENT, MUTED, TEXT, priority_style, task_title_style},
};
use super::rows::display_rows;

pub(super) fn draw_list(frame: &mut Frame, app: &mut App, area: Rect, pane: TaskPane) {
    if area.is_empty() {
        return;
    }
    let (visible, draft) = display_rows(app, pane);
    if visible.is_empty() {
        let query = if app.vim.input() == Some(InputTarget::Search) {
            app.editor.text()
        } else {
            &app.query
        };
        let (title, hint) = if !query.is_empty() {
            ("No matching tasks.", "Esc  clear search")
        } else if pane == TaskPane::Completed {
            ("No completed tasks.", "Tab  todo")
        } else if pane == TaskPane::Todo {
            ("No unfinished todos.", "i  add a task · Tab  completed")
        } else {
            ("Nothing on your list.", "i  add your first task")
        };
        let height = 3.min(area.height);
        let empty = Rect::new(
            area.x,
            area.y + area.height.saturating_sub(height) / 2,
            area.width,
            height,
        );
        frame.render_widget(
            Paragraph::new(vec![
                Line::from(Span::styled(title, Style::default().fg(TEXT))),
                Line::from(""),
                Line::from(Span::styled(hint, Style::default().fg(ACCENT))),
            ])
            .alignment(Alignment::Center),
            empty,
        );
        return;
    }
    let child_counts: Vec<_> = visible
        .iter()
        .map(|row| {
            row.index
                .map_or((0, 0), |index| app.child_counts(app.todos[index].id))
        })
        .collect();
    let mut last_sibling = vec![true; visible.len()];
    let mut last_at_depth = Vec::new();
    for (row, task) in visible.iter().enumerate() {
        last_at_depth.truncate(task.depth + 1);
        if let Some(previous) = last_at_depth.get_mut(task.depth) {
            last_sibling[*previous] = false;
            *previous = row;
        } else {
            last_at_depth.push(row);
        }
    }
    let max_depth = (area.width.saturating_sub(18) as usize / 5).max(1);
    let mut ancestors = Vec::new();
    let mut state = *app.pane_list(pane);
    let focused = app.task_pane() == pane;
    if let Some(row) = draft {
        state.select(Some(row));
    } else if let Some(selected) = state.selected() {
        let row = visible
            .iter()
            .enumerate()
            .filter(|(_, row)| !row.ghost)
            .nth(selected)
            .map(|(row, _)| row);
        state.select(row);
    }
    let mut cursor = None;
    let items: Vec<_> = visible
        .iter()
        .enumerate()
        .map(|(row, tree_row)| {
            let todo = tree_row.index.map(|index| &app.todos[index]);
            let selected = focused && state.selected() == Some(row);
            let inline = focused
                && app.vim.input() == Some(InputTarget::Task)
                && todo.is_none_or(|todo| app.editing_id == Some(todo.id));
            let completed = todo.is_some_and(|todo| todo.done);
            let priority = if inline {
                app.input_priority
            } else {
                todo.map_or(app.input_priority, |todo| todo.priority)
            };
            let (done, total) = child_counts[row];
            let root = tree_row.depth == 0;
            let ghost_style = Style::default().fg(MUTED).add_modifier(Modifier::DIM);
            let style = if tree_row.ghost {
                ghost_style
            } else {
                task_title_style(root || total > 0, selected, completed && !inline)
            };
            let marker_style = if tree_row.ghost {
                ghost_style
            } else {
                Style::default().fg(if selected { ACCENT } else { TEXT })
            };
            let children = if tree_row.ghost {
                "  (parent)".into()
            } else if total == 0 {
                String::new()
            } else {
                let folded = todo.is_some_and(|todo| app.is_collapsed(todo.id));
                if folded {
                    format!("  + {done}/{total}")
                } else {
                    format!("  {done}/{total}")
                }
            };
            ancestors.truncate(tree_row.depth);
            let hidden = tree_row.depth.saturating_sub(max_depth);
            let mut guides = if hidden > 0 {
                "… ".into()
            } else {
                String::new()
            };
            for &continues in ancestors.iter().skip(hidden.max(1)) {
                guides.push_str(if continues { "│    " } else { "     " });
            }
            let branch = if root {
                String::new()
            } else {
                format!(
                    "{guides}{}",
                    if last_sibling[row] {
                        "└──  "
                    } else {
                        "├──  "
                    }
                )
            };
            ancestors.push(!last_sibling[row]);
            let prefix_width =
                2 + unicode_width::UnicodeWidthStr::width(branch.as_str()) as u16 + 8;
            let title = if inline {
                let (spans, column) =
                    editor_spans(&app.editor, area.width.saturating_sub(prefix_width), style);
                if prefix_width < area.width {
                    cursor = Some((row, prefix_width + column));
                }
                if app.editor.text().is_empty() {
                    vec![Span::styled("New task…", Style::default().fg(MUTED))]
                } else {
                    spans
                }
            } else {
                vec![Span::styled(
                    truncate(
                        todo.map_or("", |todo| todo.title.as_str()),
                        usize::from(area.width.saturating_sub(prefix_width)).saturating_sub(
                            unicode_width::UnicodeWidthStr::width(children.as_str()),
                        ),
                    ),
                    style,
                )]
            };
            let mut spans = vec![
                Span::styled(branch, marker_style),
                Span::styled(
                    if tree_row.ghost {
                        "·  "
                    } else if completed {
                        "✓  "
                    } else {
                        "□  "
                    },
                    marker_style,
                ),
                Span::styled(
                    format!("{:<4} ", priority.label()),
                    if tree_row.ghost {
                        ghost_style
                    } else {
                        priority_style(priority)
                    },
                ),
            ];
            spans.extend(title);
            spans.push(Span::styled(
                if inline { String::new() } else { children },
                if tree_row.ghost {
                    ghost_style
                } else {
                    Style::default().fg(MUTED)
                },
            ));
            ListItem::new(Line::from(spans))
        })
        .collect();
    let list = List::new(items).highlight_symbol(Span::styled(
        if focused { "› " } else { "· " },
        Style::default().fg(if focused { ACCENT } else { MUTED }),
    ));
    frame.render_stateful_widget(list, area, &mut state);
    *app.pane_list_mut(pane).offset_mut() = state.offset();
    if !app.help
        && let Some((row, column)) = cursor
        && row >= state.offset()
        && row - state.offset() < usize::from(area.height)
    {
        frame.set_cursor_position((area.x + column, area.y + (row - state.offset()) as u16));
    }
}