argv-todo 0.1.0

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

use super::{
    input::draw_editor,
    text::truncate,
    theme::{ACCENT, MUTED, TEXT},
};
use unicode_width::UnicodeWidthStr;

pub(super) fn draw_header(frame: &mut Frame, app: &App, area: Rect) {
    let brand = Line::from(vec![
        Span::styled(
            "argv-todo",
            Style::default().fg(TEXT).add_modifier(Modifier::BOLD),
        ),
        Span::styled(
            concat!(" v", env!("CARGO_PKG_VERSION")),
            Style::default().fg(Color::Red),
        ),
    ]);
    let brand_width = (brand.width() + 2) as u16;
    let [brand_area, summary] =
        Layout::horizontal([Constraint::Length(brand_width), Constraint::Min(0)]).areas(area);
    frame.render_widget(Paragraph::new(brand), brand_area);
    let done = app.todos.iter().filter(|todo| todo.done).count();
    let text = if app.todos.is_empty() {
        "0 tasks".into()
    } else {
        format!("{done}/{} done", app.todos.len())
    };
    frame.render_widget(
        Paragraph::new(text)
            .alignment(Alignment::Right)
            .style(Style::default().fg(MUTED)),
        summary,
    );
}

pub(super) fn draw_input(frame: &mut Frame, app: &App, area: Rect) {
    if area.is_empty() {
        return;
    }
    if app.vim.input().is_none() {
        if !app.query.is_empty() {
            frame.render_widget(
                Paragraph::new(Line::from(vec![
                    Span::styled("/ ", Style::default().fg(ACCENT)),
                    Span::raw(truncate(
                        &app.query,
                        usize::from(area.width.saturating_sub(14)),
                    )),
                    Span::styled("  · Esc clear", Style::default().fg(MUTED)),
                ])),
                area,
            );
        }
        return;
    }
    if app.vim.input() != Some(InputTarget::Search) {
        return;
    }
    draw_editor(frame, &app.editor, "/ ", Style::default(), area, !app.help);
}

pub(super) fn draw_footer(frame: &mut Frame, app: &App, area: Rect) {
    let editing = app.vim.input().is_some();
    let pending = app.vim.pending_label();
    let label = format!("{}  {pending}", app.vim.mode().label().to_lowercase());
    let label = label.trim_end();
    let mode_width = (label.width() + 2).min(usize::from(area.width) / 2) as u16;
    let [mode, keys] =
        Layout::horizontal([Constraint::Length(mode_width), Constraint::Min(0)]).areas(area);
    frame.render_widget(
        Paragraph::new(label).style(Style::default().fg(if editing || !pending.is_empty() {
            ACCENT
        } else {
            MUTED
        })),
        mode,
    );
    if !app.show_hints() {
        return;
    }
    let hint = footer_hint(app, usize::from(keys.width));
    frame.render_widget(
        Paragraph::new(hint)
            .alignment(Alignment::Right)
            .style(Style::default().fg(MUTED)),
        keys,
    );
}

fn footer_hint(app: &App, width: usize) -> &'static str {
    let candidates: &[&str] = if app.vim.input().is_some() && app.vim.mode().visual() {
        &[
            "d delete · c change · y yank · Esc normal",
            "d/c/y · Esc normal",
            "d/c/y · Esc",
        ]
    } else if app.vim.input().is_some() && app.vim.mode() == VimMode::Normal {
        if app.vim.input() == Some(InputTarget::Search) {
            &[
                "i/a insert · v select · Enter apply · Esc cancel",
                "i/a · v · Enter apply · Esc",
                "i · v · Enter · Esc",
            ]
        } else {
            &[
                "i/a insert · v select · Enter save · Esc cancel",
                "i/a · v · Enter save · Esc cancel",
                "i · v · Enter · Esc",
            ]
        }
    } else if app.vim.input().is_none() && app.vim.pending_label().ends_with('p') {
        &[
            "h high · m mid · l low",
            "h high m mid l low",
            "h/m/l priority",
            "h/m/l",
        ]
    } else if app.vim.input() == Some(InputTarget::Search) {
        &[
            "Enter apply · Esc normal · ←/→ move",
            "Enter apply · Esc normal",
            "Enter apply · Esc",
            "Enter · Esc",
        ]
    } else if app.vim.input() == Some(InputTarget::Task) {
        &[
            "Enter save · Esc normal · Ctrl-p priority",
            "Enter save · Esc normal",
            "Enter save · Esc",
            "Enter · Esc",
        ]
    } else if app.vim.input().is_some() {
        &[
            "Enter save · Esc normal · ←/→ move",
            "Enter save · Esc normal",
            "Enter save · Esc",
            "Enter · Esc",
        ]
    } else if !app.query.is_empty() {
        &[
            "j/k move · i child · Esc clear · ? help · q quit",
            "Esc clear · ? help · q quit",
            "Esc clear · ? · q",
        ]
    } else if app.task_view() != TaskView::Normal {
        &[
            "Tab pane · j/k move · h/l tree · Enter fold · i child · Esc config · ? help · q quit",
            "Tab pane · i child · Esc config · ? help · q quit",
            "Tab pane · Esc config · ? · q",
            "Tab pane · ? · q",
            "Tab · ? · q",
        ]
    } else {
        &[
            "j/k move · h/l tree · i/a child · o/O sibling · Enter fold · x done · t priority · Esc config · ? help · q quit",
            "i child · o/O sibling · Esc config · ? help · q quit",
            "i child · Esc config · ? help · q quit",
            "i · Esc config · ? · q",
            "i · ? · q",
        ]
    };
    candidates
        .iter()
        .copied()
        .find(|hint| hint.width() <= width)
        .unwrap_or("")
}