use chrono::Local;
use ratatui::{
layout::{Alignment, Constraint, Margin, Rect},
style::{Color, Style},
widgets::{Block, Borders, Cell, Paragraph, Row, Table},
};
use crate::actions::AppAction;
use crate::app::App;
use crate::domain::TodoStatus;
use crate::screens::layout::vstack;
const STATUS_COL_WIDTH: u16 = 2;
const PRIORITY_COL_WIDTH: u16 = 1;
const TIME_COL_WIDTH: u16 = 16;
fn format_item_time(
todo: &crate::domain::todo::Todo,
sort_mode: crate::domain::SortMode,
) -> String {
let use_created_at = matches!(sort_mode, crate::domain::SortMode::CreatedAt);
if !use_created_at && matches!(todo.status, TodoStatus::Completed) {
if let Some(completed_at) = &todo.completed_at {
let local = completed_at.with_timezone(&Local);
local.format("%Y-%m-%d %H:%M").to_string()
} else {
String::new()
}
} else {
let local = todo.created_at.with_timezone(&Local);
local.format("%Y-%m-%d %H:%M").to_string()
}
}
fn priority_color(priority: i32) -> Color {
match priority {
1 => Color::Magenta,
2 => Color::Yellow,
3 => Color::Cyan,
4 => Color::Green,
_ => Color::DarkGray,
}
}
pub fn render_list(app: &mut App, frame: &mut ratatui::Frame) {
let [header, body, footer] = vstack(frame.area());
render_header(app, frame, header);
render_todo_table(app, frame, body);
render_footer(app, frame, footer);
}
fn render_header(app: &App, frame: &mut ratatui::Frame, area: Rect) {
let remaining = app
.todos
.iter()
.filter(|t| matches!(t.status, TodoStatus::Pending))
.count();
let sort_label = app.sort_mode.label();
let header_text = if let Some(err) = &app.error_message {
format!(
"Remaining: {} Sort: {} Error: {}",
remaining, sort_label, err
)
} else {
format!("Remaining: {} Sort: {}", remaining, sort_label)
};
let header = Paragraph::new(header_text).block(
Block::default()
.title(" Todos ")
.title_alignment(Alignment::Left)
.borders(Borders::ALL)
.border_style(Style::new().fg(Color::White)),
);
frame.render_widget(header, area);
}
fn render_todo_table(app: &mut App, frame: &mut ratatui::Frame, area: Rect) {
if app.todos.is_empty() {
let empty = Paragraph::new("No todos")
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::new().fg(Color::White)),
)
.alignment(Alignment::Center);
frame.render_widget(empty, area);
return;
}
let selected_idx = app.list_state.selected();
let sort_mode = app.sort_mode;
let rows: Vec<Row> = app
.todos
.iter()
.enumerate()
.map(|(idx, todo)| {
let is_selected = selected_idx == Some(idx);
let is_completed = matches!(todo.status, TodoStatus::Completed);
let status_char = if is_completed { "◉" } else { "○" };
let status_style = Style::new().fg(Color::DarkGray);
let priority_str = todo.priority.to_char();
let has_priority = !priority_str.is_empty();
let time_str = format_item_time(todo, sort_mode);
let time_style = Style::new().fg(Color::DarkGray);
let selection_indicator = if is_selected { "▸" } else { " " };
let priority_display = if has_priority {
priority_str.to_string()
} else {
" ".to_string()
};
let p_color = if has_priority {
priority_color(todo.priority.into())
} else {
Color::DarkGray
};
let title_style = if is_completed {
Style::new().fg(Color::DarkGray)
} else {
Style::new()
};
Row::new(vec![
Cell::from(format!("{}{}", status_char, selection_indicator)).style(status_style),
Cell::from(priority_display).style(Style::new().fg(p_color)),
Cell::from(todo.title.clone()).style(title_style),
Cell::from(time_str.clone()).style(time_style),
])
})
.collect();
let table = Table::new(
rows,
&[
Constraint::Length(STATUS_COL_WIDTH),
Constraint::Length(PRIORITY_COL_WIDTH),
Constraint::Min(10),
Constraint::Length(TIME_COL_WIDTH),
],
)
.row_highlight_style(Style::new().reversed())
.highlight_symbol("")
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::new().fg(Color::White)),
);
frame.render_stateful_widget(table, area, &mut app.list_state);
}
fn render_footer(_app: &App, frame: &mut ratatui::Frame, area: Rect) {
frame.render_widget(
ratatui::widgets::Paragraph::new(
"[Mouse] Click/Scroll | [J/K] Select | [Enter] Open | [Space] Toggle | [E] Edit | [P] Priority | [S] Sort | [A] New | [D] Delete | [Q/Ctrl+C] Quit",
)
.alignment(Alignment::Left),
area,
);
}
pub fn mouse_action(app: &App, area: Rect, row: u16, col: u16) -> Option<AppAction> {
let [_, table_area, _] = vstack(area);
click_action(app, table_area, row, col)
}
fn click_action(app: &App, table_area: Rect, row: u16, col: u16) -> Option<AppAction> {
if app.todos.is_empty() || table_area.width < 3 || table_area.height < 3 {
return None;
}
let inner = table_area.inner(Margin {
vertical: 1,
horizontal: 1,
});
if row < inner.y
|| row >= inner.y.saturating_add(inner.height)
|| col < inner.x
|| col >= inner.x.saturating_add(inner.width)
{
return None;
}
let visible_row = (row - inner.y) as usize;
let item_index = app.list_state.offset().saturating_add(visible_row);
if item_index >= app.todos.len() {
return None;
}
let was_selected = app.list_state.selected() == Some(item_index);
let todo_id = app.todos[item_index].id.clone();
let rel_col = col - inner.x;
if rel_col < STATUS_COL_WIDTH {
return Some(AppAction::ToggleTodo(todo_id));
}
if rel_col < STATUS_COL_WIDTH + PRIORITY_COL_WIDTH {
let next_priority = app.todos[item_index].priority.next();
return Some(AppAction::SetPriority(
todo_id,
next_priority.to_char().to_string(),
));
}
let title_start = STATUS_COL_WIDTH + PRIORITY_COL_WIDTH;
let title_end = inner.width.saturating_sub(TIME_COL_WIDTH);
if rel_col >= title_start && rel_col < title_end && was_selected {
return Some(AppAction::OpenDetail(todo_id));
}
Some(AppAction::SelectTodo(item_index))
}