use crate::formatters::style_tokens;
use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph, Widget},
};
const SPINNER_FRAMES: &[char] = &['→', '↘', '↓', '↙', '←', '↖', '↑', '↗'];
pub fn todo_panel_height(item_count: usize, expanded: bool) -> u16 {
if item_count == 0 {
return 0;
}
if !expanded {
return 3;
}
(item_count as u16 + 2).min(12)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TodoDisplayStatus {
Pending,
InProgress,
Completed,
}
#[derive(Debug, Clone)]
pub struct TodoDisplayItem {
pub id: usize,
pub title: String,
pub status: TodoDisplayStatus,
pub active_form: Option<String>,
}
pub struct TodoPanelWidget<'a> {
items: &'a [TodoDisplayItem],
plan_name: Option<&'a str>,
expanded: bool,
spinner_tick: usize,
}
impl<'a> TodoPanelWidget<'a> {
pub fn new(items: &'a [TodoDisplayItem]) -> Self {
Self {
items,
plan_name: None,
expanded: true,
spinner_tick: 0,
}
}
pub fn with_plan_name(mut self, name: &'a str) -> Self {
self.plan_name = Some(name);
self
}
pub fn with_expanded(mut self, expanded: bool) -> Self {
self.expanded = expanded;
self
}
pub fn with_spinner_tick(mut self, tick: usize) -> Self {
self.spinner_tick = tick;
self
}
pub fn required_height(&self) -> u16 {
if !self.items.is_empty()
&& self
.items
.iter()
.all(|i| i.status == TodoDisplayStatus::Completed)
{
return 0;
}
if !self.expanded {
return 3;
}
(self.items.len() as u16 + 2).min(12)
}
fn counts(&self) -> (usize, usize, usize) {
let mut done = 0usize;
let mut in_progress = 0usize;
for item in self.items {
match item.status {
TodoDisplayStatus::Completed => done += 1,
TodoDisplayStatus::InProgress => in_progress += 1,
TodoDisplayStatus::Pending => {}
}
}
(done, in_progress, self.items.len())
}
fn build_lines(&self, _done: usize, _in_progress: usize, _total: usize) -> Vec<Line<'a>> {
let mut lines = Vec::new();
for item in self.items {
let (symbol, style) = match item.status {
TodoDisplayStatus::Completed => (
" \u{2714} ".to_string(), Style::default().fg(style_tokens::GOLD),
),
TodoDisplayStatus::InProgress => {
let spinner = SPINNER_FRAMES[self.spinner_tick % SPINNER_FRAMES.len()];
(
format!(" {spinner} "),
Style::default()
.fg(style_tokens::PRIMARY)
.add_modifier(Modifier::BOLD),
)
}
TodoDisplayStatus::Pending => (
" \u{25CB} ".to_string(), Style::default().fg(style_tokens::GREY),
),
};
let display_title = item.title.clone();
lines.push(Line::from(vec![
Span::styled(symbol, style),
Span::styled(display_title, style),
]));
}
lines
}
fn build_collapsed_line(&self, done: usize, total: usize) -> Line<'a> {
if done == total && total > 0 {
return Line::from(vec![
Span::styled(
" \u{2714} ".to_string(),
Style::default()
.fg(style_tokens::SUCCESS)
.add_modifier(Modifier::BOLD),
),
Span::styled(
"All tasks complete".to_string(),
Style::default().fg(style_tokens::SUCCESS),
),
Span::styled(
format!(" ({done}/{total})"),
Style::default().fg(style_tokens::GREY),
),
]);
}
let spinner = SPINNER_FRAMES[self.spinner_tick % SPINNER_FRAMES.len()];
let active_text = self
.items
.iter()
.find(|i| i.status == TodoDisplayStatus::InProgress)
.and_then(|i| {
i.active_form
.as_deref()
.filter(|s| !s.is_empty())
.or(Some(i.title.as_str()))
})
.unwrap_or("Working...");
Line::from(vec![
Span::styled(
format!(" {spinner} "),
Style::default()
.fg(style_tokens::PRIMARY)
.add_modifier(Modifier::BOLD),
),
Span::styled(
active_text.to_string(),
Style::default().fg(style_tokens::PRIMARY),
),
Span::styled(
format!(" ({done}/{total})"),
Style::default().fg(style_tokens::GREY),
),
])
}
}
impl Widget for TodoPanelWidget<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let (done, in_progress, total) = self.counts();
let title_text = if self.expanded {
if let Some(name) = self.plan_name {
format!("TODOS: {name} ({done}/{total})")
} else {
format!("TODOS ({done}/{total})")
}
} else {
format!("TODOS ({done}/{total})")
};
let title = Line::from(vec![
Span::raw(" "),
Span::styled(
title_text,
Style::default()
.fg(style_tokens::GREEN_LIGHT)
.add_modifier(Modifier::BOLD),
),
Span::styled(
" (Ctrl+T to toggle) ",
Style::default().fg(style_tokens::GREY),
),
]);
let border_color = if done == total && total > 0 {
style_tokens::SUCCESS
} else {
style_tokens::GREY
};
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color));
if self.expanded {
let lines = self.build_lines(done, in_progress, total);
let paragraph = Paragraph::new(lines).block(block);
paragraph.render(area, buf);
} else {
let line = self.build_collapsed_line(done, total);
let paragraph = Paragraph::new(vec![line]).block(block);
paragraph.render(area, buf);
}
}
}
#[cfg(test)]
#[path = "todo_panel_tests.rs"]
mod tests;