aven 0.1.9

Local-first task manager CLI and sync server
Documentation
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};

use crate::tui::theme::{self, BG, BG_PANEL, BORDER, FG, FG_DIM, FG_MUTED, YELLOW};

#[derive(Clone, Copy, PartialEq, Eq)]
pub(super) enum FooterMode {
    List,
    Columns,
    Detail,
    DetailSelection,
    StatusChoice,
    PriorityChoice,
}

pub(super) fn footer_bar(
    mode: FooterMode,
    width: u16,
    marked_task_count: usize,
) -> Paragraph<'static> {
    let mut spans = marked_task_indicator(marked_task_count);
    let indicator_width = spans
        .iter()
        .map(|span| span.content.chars().count() as u16)
        .sum::<u16>();
    for (keys, label) in footer_hints(mode, width.saturating_sub(indicator_width)) {
        spans.extend(key(keys));
        spans.push(cmd(mode, label));
    }
    let hints = Line::from(spans);
    Paragraph::new(hints)
        .block(
            Block::new()
                .borders(Borders::TOP)
                .border_style(Style::new().fg(BORDER)),
        )
        .style(Style::new().fg(FG).bg(BG))
}

fn marked_task_indicator(count: usize) -> Vec<Span<'static>> {
    if count == 0 {
        return Vec::new();
    }
    let mut spans = vec![Span::styled(
        format!("{count} marked  "),
        Style::new().fg(YELLOW).add_modifier(Modifier::BOLD),
    )];
    spans.extend(key("t C"));
    spans.push(Span::styled(" clear all  ", Style::new().fg(FG_DIM)));
    spans
}

fn footer_hints(mode: FooterMode, width: u16) -> &'static [(&'static str, &'static str)] {
    match mode {
        FooterMode::List if width >= 148 => &[
            ("j/k", "move"),
            ("Enter", "detail"),
            ("a", "add"),
            ("n", "note"),
            ("s", "status"),
            ("p", "projects"),
            ("d", "done"),
            ("x", "cancel"),
            ("Space", "mark"),
            ("e l", "labels"),
            ("g", "scope"),
            ("v", "views"),
            ("f", "filter"),
            ("o", "order"),
            ("?", "more"),
            ("q", "quit"),
        ],
        FooterMode::List if width >= 96 => &[
            ("j/k", "move"),
            ("Enter", "detail"),
            ("a", "add"),
            ("s", "status"),
            ("p", "projects"),
            ("g/v/f/o", "menus"),
            ("?", "more"),
            ("q", "quit"),
        ],
        FooterMode::List => &[
            ("j/k", "move"),
            ("Enter", "detail"),
            ("a", "add"),
            ("?", "more"),
            ("q", "quit"),
        ],
        FooterMode::Columns if width >= 120 => &[
            ("h/j/k/l", "select"),
            ("</>", "move"),
            ("m", "lane"),
            ("Space", "mark"),
            ("u", "undo"),
            ("Enter", "detail"),
            ("?", "more"),
            ("q", "quit"),
        ],
        FooterMode::Columns if width >= 80 => &[
            ("h/j/k/l", "select"),
            ("</>", "move"),
            ("m", "lane"),
            ("Space", "mark"),
            ("?", "more"),
        ],
        FooterMode::Columns => &[
            ("h/l", "select"),
            ("</>", "move"),
            ("m", "lane"),
            ("?", "more"),
        ],
        FooterMode::DetailSelection if width >= 72 => &[
            ("y", "copy selection"),
            ("Esc", "clear selection"),
            ("j/k Pg", "scroll"),
        ],
        FooterMode::DetailSelection => &[("y", "copy"), ("Esc", "clear")],
        FooterMode::Detail if width >= 128 => &[
            ("j/k Pg", "scroll"),
            ("[/]", "task"),
            ("e", "edit"),
            ("s", "status"),
            ("e p", "priority"),
            ("n", "note"),
            ("t d", "done"),
            ("t y/Y", "copy"),
            ("?", "more"),
            ("Esc", "back"),
        ],
        FooterMode::Detail if width >= 72 => &[
            ("j/k Pg", "scroll"),
            ("[/]", "task"),
            ("e", "edit"),
            ("s/e p", "status/priority"),
            ("n", "note"),
            ("?", "more"),
            ("Esc", "back"),
        ],
        FooterMode::Detail => &[
            ("j/k", "scroll"),
            ("[/]", "task"),
            ("e", "edit"),
            ("?", "more"),
            ("Esc", "back"),
        ],
        FooterMode::StatusChoice => &[
            ("i", "inbox"),
            ("b", "backlog"),
            ("t", "todo"),
            ("a", "active"),
            ("d", "done"),
            ("x", "canceled"),
            ("Esc", "cancel"),
        ],
        FooterMode::PriorityChoice => &[
            ("n", "none"),
            ("l", "low"),
            ("m", "medium"),
            ("h", "high"),
            ("u", "urgent"),
            ("Esc", "cancel"),
        ],
    }
}

fn key(label: &str) -> Vec<Span<'static>> {
    let key_style = Style::new()
        .fg(FG_MUTED)
        .bg(BG_PANEL)
        .add_modifier(Modifier::BOLD);
    let separator_style = Style::new().fg(FG_DIM).bg(BG_PANEL);
    let edge_style = Style::new().fg(BG_PANEL).bg(BG);
    let mut spans = vec![Span::styled("".to_string(), edge_style)];
    for (index, part) in label.split('/').enumerate() {
        if index > 0 {
            spans.push(Span::styled("/".to_string(), separator_style));
        }
        spans.push(Span::styled(part.to_string(), key_style));
    }
    spans.push(Span::styled("".to_string(), edge_style));
    spans
}

fn cmd(mode: FooterMode, label: &str) -> Span<'static> {
    let style = match mode {
        FooterMode::StatusChoice => theme::status_style(label),
        FooterMode::PriorityChoice => theme::priority_style(label),
        FooterMode::List
        | FooterMode::Columns
        | FooterMode::Detail
        | FooterMode::DetailSelection => Style::new().fg(FG_DIM),
    };
    Span::styled(format!(" {label}  "), style)
}

#[cfg(test)]
mod tests {
    use super::*;
    use ratatui::Terminal;
    use ratatui::backend::TestBackend;

    fn buffer_text(backend: &TestBackend) -> String {
        backend
            .buffer()
            .content
            .iter()
            .map(|cell| cell.symbol())
            .collect()
    }

    #[test]
    fn detail_footer_lists_scroll_and_task_navigation_keys() {
        let backend = TestBackend::new(128, 3);
        let mut terminal = Terminal::new(backend).unwrap();
        terminal
            .draw(|frame| frame.render_widget(footer_bar(FooterMode::Detail, 128, 0), frame.area()))
            .unwrap();
        let rendered = buffer_text(terminal.backend());

        assert!(rendered.contains("j/k Pg"));
        assert!(rendered.contains("[/]"));
        assert!(rendered.contains("task"));
        assert!(rendered.contains("s"));
        assert!(rendered.contains("e p"));
        assert!(rendered.contains("more"));
    }

    #[test]
    fn footer_shows_marked_task_scope() {
        let backend = TestBackend::new(100, 3);
        let mut terminal = Terminal::new(backend).unwrap();
        terminal
            .draw(|frame| frame.render_widget(footer_bar(FooterMode::List, 100, 3), frame.area()))
            .unwrap();
        let rendered = buffer_text(terminal.backend());

        assert!(rendered.contains("● 3 marked"));
        assert!(rendered.contains("t C"));
        assert!(rendered.contains("clear all"));
    }

    #[test]
    fn footer_hides_marked_task_scope_when_marks_are_empty() {
        let backend = TestBackend::new(100, 3);
        let mut terminal = Terminal::new(backend).unwrap();
        terminal
            .draw(|frame| frame.render_widget(footer_bar(FooterMode::List, 100, 0), frame.area()))
            .unwrap();
        let rendered = buffer_text(terminal.backend());

        assert!(!rendered.contains("marked"));
    }

    #[test]
    fn detail_selection_footer_advertises_copy_and_clear() {
        let hints = footer_hints(FooterMode::DetailSelection, 80);

        assert_eq!(
            hints,
            &[
                ("y", "copy selection"),
                ("Esc", "clear selection"),
                ("j/k Pg", "scroll"),
            ]
        );
    }

    #[test]
    fn list_footer_expands_intent_labels_when_wide() {
        let hints = footer_hints(FooterMode::List, 148);

        assert!(hints.contains(&("a", "add")));
        assert!(hints.contains(&("s", "status")));
        assert!(hints.contains(&("p", "projects")));
        assert!(hints.contains(&("d", "done")));
        assert!(hints.contains(&("x", "cancel")));
        assert!(hints.contains(&("Space", "mark")));
        assert!(hints.contains(&("e l", "labels")));
        assert!(hints.contains(&("g", "scope")));
        assert!(hints.contains(&("v", "views")));
        assert!(hints.contains(&("?", "more")));
        assert!(!hints.contains(&("p", "priority")));
        assert!(!hints.contains(&("g", "views")));
        assert!(!hints.iter().any(|(_, label)| *label == "prefixes"));
    }

    #[test]
    fn columns_footer_prioritizes_selection_and_move_keys() {
        let hints = footer_hints(FooterMode::Columns, 120);

        assert!(hints.contains(&("h/j/k/l", "select")));
        assert!(hints.contains(&("</>", "move")));
        assert!(hints.contains(&("m", "lane")));
        assert!(hints.contains(&("Space", "mark")));
        assert!(hints.contains(&("u", "undo")));
    }

    #[test]
    fn footer_collapses_to_core_hints_when_narrow() {
        let hints = footer_hints(FooterMode::List, 60);

        assert_eq!(
            hints,
            &[
                ("j/k", "move"),
                ("Enter", "detail"),
                ("a", "add"),
                ("?", "more"),
                ("q", "quit"),
            ]
        );
    }
}