use ratatui::{
Frame,
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span, Text},
widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Tabs, Wrap},
};
use textwrap::wrap;
use crate::app::{App, Filter, FormField, InputMode, StatusKind};
use crate::database::repository::Priority;
pub fn draw(frame: &mut Frame<'_>, app: &App) {
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(8),
Constraint::Length(7),
Constraint::Length(3),
])
.split(frame.area());
render_filters(frame, app, layout[0]);
render_todo_list(frame, app, layout[1]);
if app.mode == InputMode::Adding {
render_form(frame, app, layout[2]);
} else {
render_help(frame, layout[2]);
}
render_status(frame, app, layout[3]);
}
fn render_filters(frame: &mut Frame<'_>, app: &App, area: Rect) {
let filters = [Filter::All, Filter::Active, Filter::Completed];
let titles: Vec<Span> = filters
.iter()
.map(|filter| {
let mut style = Style::default();
match filter {
Filter::All => style = style.fg(Color::White),
Filter::Active => style = style.fg(Color::Green),
Filter::Completed => style = style.fg(Color::Blue),
}
if *filter == app.filter {
style = style.fg(Color::LightYellow).add_modifier(Modifier::BOLD);
}
let total = app
.todos
.iter()
.filter(|f| match filter {
Filter::All => true,
Filter::Active => !f.completed,
Filter::Completed => f.completed,
})
.count();
Span::styled(format!("{} ({})", filter.label(), total), style)
})
.collect();
let selected = filters.iter().position(|f| *f == app.filter).unwrap_or(0);
let tabs = Tabs::new(titles)
.block(
Block::default()
.borders(Borders::ALL)
.title(format!("Todos ({})", app.todos.len())),
)
.select(selected)
.highlight_style(Style::default().fg(Color::Yellow));
frame.render_widget(tabs, area);
}
fn render_todo_list(frame: &mut Frame<'_>, app: &App, area: Rect) {
let todos = app.filtered_todos();
let available_width = area.width.saturating_sub(6).max(10) as usize;
if todos.is_empty() {
let text = if app.mode == InputMode::Adding {
"Start typing a new todo..."
} else {
"No todos match this filter. Press 'a' to add one."
};
frame.render_widget(
Paragraph::new(text)
.block(Block::default().borders(Borders::ALL).title("Todos"))
.alignment(Alignment::Center),
area,
);
return;
}
let items: Vec<ListItem> = todos
.iter()
.map(|todo| {
let status_icon = if todo.completed { "✅ " } else { "⌛ " };
let priority_style = priority_style(todo.priority.as_str());
let primary = Line::from(vec![Span::styled(
format!("{status_icon} Title: {}", todo.title.trim()),
Style::default().add_modifier(if todo.completed {
Modifier::DIM
} else {
Modifier::BOLD
}),
)]);
let priority = Line::from(vec![
Span::styled("Priority: ", Style::default()),
Span::styled(format!("[{}]", todo.priority), priority_style),
]);
let description = todo
.description
.as_deref()
.filter(|desc| !desc.trim().is_empty())
.unwrap_or("No description provided");
let timestamp = format!(
"Created: {} Updated: {}",
todo.created_at.format("%Y-%m-%d %H:%M"),
todo.updated_at.format("%Y-%m-%d %H:%M"),
);
let description_lines: Vec<Line> = wrap(description, available_width)
.into_iter()
.map(|segment| Line::from(segment.into_owned()))
.collect();
let timestamp_line = Line::from(Span::styled(
timestamp,
Style::default().fg(Color::DarkGray),
));
let mut lines = vec![primary, priority];
lines.extend(description_lines);
lines.push(timestamp_line);
ListItem::new(lines).style(if todo.completed {
Style::default().fg(Color::Gray)
} else {
Style::default()
})
})
.collect();
let mut state = ListState::default();
state.select(Some(app.selected.min(items.len() - 1)));
let list = List::new(items)
.block(Block::default().borders(Borders::ALL).title("Todos"))
.highlight_style(Style::default().fg(Color::LightYellow))
.highlight_symbol("» ");
frame.render_stateful_widget(list, area, &mut state);
}
fn render_form(frame: &mut Frame<'_>, app: &App, area: Rect) {
let highlight = Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD);
let title_label = if app.active_field == FormField::Title {
Span::styled(format!("Title: {}", app.form.title), highlight)
} else {
Span::raw(format!("Title: {}", app.form.title))
};
let description_label = if app.active_field == FormField::Description {
Span::styled(format!("Description: {}", app.form.description), highlight)
} else {
Span::raw(format!("Description: {}", app.form.description))
};
let priority_span = priority_label(app.form.priority);
let instructions = Text::from(vec![
Line::from(title_label),
Line::from(description_label),
Line::from(vec![Span::raw("Priority: "), priority_span]),
Line::from("Enter to save • Esc to cancel • Tab switches field • ↑/↓ adjust priority"),
]);
let paragraph = Paragraph::new(instructions)
.wrap(Wrap { trim: false })
.block(Block::default().borders(Borders::ALL).title("Add Todo"));
frame.render_widget(paragraph, area);
}
fn render_help(frame: &mut Frame<'_>, area: Rect) {
let help_text = Text::from(vec![
Line::from("Navigation: ↑/↓ or j/k"),
Line::from("Filter: Tab"),
Line::from("Toggle Complete: Space"),
Line::from("Add Todo: a"),
Line::from("Delete Todo: d"),
Line::from("Refresh: r"),
Line::from("Quit: q"),
]);
let paragraph = Paragraph::new(help_text)
.block(Block::default().borders(Borders::ALL).title("Help"))
.wrap(Wrap { trim: true });
frame.render_widget(paragraph, area);
}
fn render_status(frame: &mut Frame<'_>, app: &App, area: Rect) {
let (text, style) = if let Some(status) = &app.status {
let style = match status.kind {
StatusKind::Info => Style::default().fg(Color::Cyan),
StatusKind::Success => Style::default().fg(Color::Green),
StatusKind::Error => Style::default().fg(Color::Red),
};
(status.message.clone(), style)
} else if app.is_loading {
("Loading...".to_string(), Style::default().fg(Color::Yellow))
} else {
(
"Press 'a' to add, Space to toggle completion, 'q' to quit.".to_string(),
Style::default(),
)
};
let paragraph = Paragraph::new(Line::from(Span::styled(text, style)))
.block(Block::default().borders(Borders::ALL).title("Status"));
frame.render_widget(paragraph, area);
}
fn priority_label(priority: Priority) -> Span<'static> {
let style = priority_style(priority.as_str());
Span::styled(priority.label(), style)
}
fn priority_style(value: &str) -> Style {
match value {
"high" => Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
"low" => Style::default().fg(Color::Green),
_ => Style::default().fg(Color::Yellow),
}
}