bea-rs 0.8.1

A file-based task tracker CLI and MCP server for AI agent workflows
Documentation
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, StatefulWidget};

use bears::task::{Task, TaskType};

use super::super::app::Filter;
use super::super::style::{self, Theme};

pub(in crate::tui) struct TaskListWidget<'a> {
    tasks: &'a [Task],
    filter: &'a Filter,
    border_style: Style,
    theme: &'a Theme,
}

impl<'a> TaskListWidget<'a> {
    pub fn new(
        tasks: &'a [Task],
        filter: &'a Filter,
        border_style: Style,
        theme: &'a Theme,
    ) -> Self {
        Self {
            tasks,
            filter,
            border_style,
            theme,
        }
    }
}

impl StatefulWidget for TaskListWidget<'_> {
    type State = ListState;

    fn render(self, area: Rect, buf: &mut Buffer, state: &mut ListState) {
        let items: Vec<ListItem> = self
            .tasks
            .iter()
            .map(|task| {
                let pri_color = self.theme.priority_color(task.priority);
                let indicator = style::status_indicator(&task.status);
                let st_color = self.theme.status_color(&task.status);
                let type_prefix = if task.task_type == TaskType::Epic {
                    ""
                } else {
                    ""
                };

                let line = Line::from(vec![
                    Span::styled(
                        format!("{} ", task.priority),
                        Style::default().fg(pri_color),
                    ),
                    Span::styled(format!("{indicator} "), Style::default().fg(st_color)),
                    Span::styled(
                        format!("[{}] ", task.id),
                        Style::default().fg(self.theme.id_color),
                    ),
                    Span::raw(format!("{type_prefix}{}", task.title)),
                ]);

                ListItem::new(line)
            })
            .collect();

        let title = {
            let mut parts = vec![" Tasks".to_string()];
            if !self.filter.query.is_empty() {
                parts.push(format!(" [{}]", self.filter.query));
            }
            parts.push(format!(" ({})", self.filter.list_mode.label()));
            parts.push(format!(" ({}) ", self.tasks.len()));
            parts.join("")
        };

        let list = List::new(items)
            .block(
                Block::default()
                    .title(title)
                    .borders(Borders::ALL)
                    .border_style(self.border_style),
            )
            .highlight_style(self.theme.highlight_style())
            .highlight_symbol(style::HIGHLIGHT_SYMBOL);

        StatefulWidget::render(list, area, buf, state);
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use bears::task::{Priority, Task};
    use ratatui::buffer::Buffer;

    fn sample_tasks() -> Vec<Task> {
        vec![
            Task::new("aaa".into(), "Task A".into(), Priority::P1),
            Task::new("bbb".into(), "Task B".into(), Priority::P2),
        ]
    }

    #[test]
    fn renders_task_titles() {
        let tasks = sample_tasks();
        let filter = Filter::default();
        let theme = Theme::default();
        let widget = TaskListWidget::new(&tasks, &filter, Style::default(), &theme);

        let area = Rect::new(0, 0, 40, 10);
        let mut buf = Buffer::empty(area);
        let mut state = ListState::default();
        StatefulWidget::render(widget, area, &mut buf, &mut state);

        let text: String = buf.content().iter().map(|c| c.symbol()).collect::<String>();
        assert!(text.contains("Task A"));
        assert!(text.contains("Task B"));
    }

    #[test]
    fn filter_title_shows_query() {
        let tasks = sample_tasks();
        let filter = Filter {
            query: "test".into(),
            ..Filter::default()
        };
        let theme = Theme::default();
        let widget = TaskListWidget::new(&tasks, &filter, Style::default(), &theme);

        let area = Rect::new(0, 0, 40, 10);
        let mut buf = Buffer::empty(area);
        let mut state = ListState::default();
        StatefulWidget::render(widget, area, &mut buf, &mut state);

        let text: String = buf.content().iter().map(|c| c.symbol()).collect::<String>();
        assert!(text.contains("[test]"));
    }

    #[test]
    fn shows_epic_marker() {
        let mut task = Task::new("eee".into(), "My Epic".into(), Priority::P0);
        task.task_type = TaskType::Epic;
        let tasks = vec![task];
        let filter = Filter::default();
        let theme = Theme::default();
        let widget = TaskListWidget::new(&tasks, &filter, Style::default(), &theme);

        let area = Rect::new(0, 0, 50, 5);
        let mut buf = Buffer::empty(area);
        let mut state = ListState::default();
        StatefulWidget::render(widget, area, &mut buf, &mut state);

        let text: String = buf.content().iter().map(|c| c.symbol()).collect::<String>();
        assert!(text.contains(""));
        assert!(text.contains("My Epic"));
    }
}