use crate::core::model::{Category, ColorTag};
use crate::tui::app::{App, InputMode, PopupState, RowRef};
use chrono::Local;
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap},
Frame,
};
pub fn draw(f: &mut Frame, app: &mut App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(0), Constraint::Length(3)])
.split(f.size());
if app.show_notes {
let horizontal = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
.split(chunks[0]);
draw_document(f, app, horizontal[0]);
draw_notes(f, app, horizontal[1]);
} else {
draw_document(f, app, chunks[0]);
}
draw_help(f, chunks[1]);
if let InputMode::Popup(ref popup) = app.mode {
draw_popup(f, popup);
}
}
fn draw_popup(f: &mut Frame, popup: &PopupState) {
let area = centered_rect(60, 40, f.size());
f.render_widget(Clear, area);
let block = Block::default()
.borders(Borders::ALL)
.title(match popup {
PopupState::AddTodo { .. } => " Add Todo ",
PopupState::EditTodo { .. } => " Edit Todo ",
PopupState::AddSub { .. } => " Add Subtask ",
PopupState::EditSub { .. } => " Edit Subtask ",
PopupState::DeleteConfirm { .. } => " Delete Confirm ",
PopupState::Move { .. } => " Move Todo ",
PopupState::Message { .. } => " Warning ",
})
.border_style(Style::default().fg(Color::Yellow));
match popup {
PopupState::AddTodo { input, notes, editing_notes, .. } |
PopupState::EditTodo { input, notes, editing_notes, .. } => {
let chunks = Layout::default()
.direction(Direction::Vertical)
.margin(1)
.constraints([Constraint::Length(3), Constraint::Min(0)])
.split(area);
let title_block = Block::default()
.borders(Borders::ALL)
.title(" Title ")
.border_style(Style::default().fg(if !*editing_notes { Color::Yellow } else { Color::White }));
let p_title = Paragraph::new(input.as_str()).block(title_block);
f.render_widget(p_title, chunks[0]);
let notes_block = Block::default()
.borders(Borders::ALL)
.title(" Notes ")
.border_style(Style::default().fg(if *editing_notes { Color::Yellow } else { Color::White }));
let p_notes = Paragraph::new(notes.as_str()).block(notes_block).wrap(Wrap { trim: true });
f.render_widget(p_notes, chunks[1]);
f.render_widget(block, area);
}
PopupState::AddSub { input, .. } | PopupState::EditSub { input, .. } => {
let chunks = Layout::default()
.direction(Direction::Vertical)
.margin(1)
.constraints([Constraint::Length(3), Constraint::Min(0)])
.split(area);
let sub_block = Block::default().borders(Borders::ALL).title(" Subtask Title ");
let p = Paragraph::new(input.as_str()).block(sub_block);
f.render_widget(p, chunks[0]);
f.render_widget(block, area);
}
PopupState::DeleteConfirm { is_sub, .. } => {
let text = if *is_sub { "Delete this subtask?\n(Enter to confirm, Esc to cancel)" } else { "Delete this todo and all its subtasks?\n(Enter to confirm, Esc to cancel)" };
let p = Paragraph::new(text)
.block(block)
.alignment(ratatui::layout::Alignment::Center);
f.render_widget(p, area);
}
PopupState::Move { current_selection, .. } => {
let cats = ["Short", "Medium", "Long", "Completed"];
let items: Vec<ListItem> = cats.iter().enumerate().map(|(i, &c)| {
let style = if i == *current_selection {
Style::default().fg(Color::Black).bg(Color::Yellow)
} else {
Style::default()
};
ListItem::new(c).style(style)
}).collect();
let list = List::new(items).block(block);
f.render_widget(list, area);
}
PopupState::Message { text } => {
let p = Paragraph::new(text.as_str())
.block(block)
.alignment(ratatui::layout::Alignment::Center);
f.render_widget(p, area);
}
}
}
fn draw_document(f: &mut Frame, app: &mut App, area: Rect) {
let block = Block::default().borders(Borders::ALL).title(" todomd ");
let inner_area = block.inner(area);
f.render_widget(block, area);
let height = inner_area.height as usize;
if app.cursor < app.scroll {
app.scroll = app.cursor;
} else if app.cursor >= app.scroll + height {
app.scroll = app.cursor.saturating_sub(height).saturating_add(1);
}
let start = app.scroll;
let end = (start + height).min(app.rows.len());
let mut items = Vec::new();
let now = Local::now().timestamp();
for (i, row) in app.rows.iter().enumerate().skip(start).take(end - start) {
let is_selected = i == app.cursor;
let content = match row {
RowRef::Header(cat) => {
let color = match cat {
Category::Short => Color::Green,
Category::Medium => Color::Yellow,
Category::Long => Color::Magenta,
Category::Completed => Color::DarkGray,
};
let style = if is_selected {
Style::default().fg(Color::Black).bg(color).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(color).add_modifier(Modifier::BOLD)
};
Line::from(vec![Span::styled(cat.as_str().to_uppercase(), style)])
}
RowRef::Item { category, id, depth } => {
if *depth == 0 {
if let Some(todo) = find_todo_in_state(app, *category, *id) {
let check = if *category == Category::Completed { "[x]" } else { "[ ]" };
let color = get_color(todo.color, *category);
let title_style = if is_selected {
Style::default().fg(Color::Black).bg(color)
} else {
Style::default().fg(color)
};
let age_c = format_duration(now - todo.created_at);
let age_u = format_duration(now - todo.updated_at);
let sub_info = if !todo.subtasks.is_empty() {
let (done, total) = count_subs_recursive(&todo.subtasks);
format!(" subs {}/{}", done, total)
} else {
String::new()
};
let text = format!(" {} ({:3}) {} u:{} c:{}{}", check, todo.id, todo.title, age_u, age_c, sub_info);
Line::from(vec![Span::styled(text, title_style)])
} else {
Line::from("Error: Todo not found")
}
} else {
if let Some(sub) = find_sub_in_cat(app, *category, *id) {
let check = if sub.done { "[x]" } else { "[ ]" };
let color = if matches!(sub.color, ColorTag::None) {
get_color(ColorTag::None, *category)
} else {
get_color(sub.color, *category)
};
let title_style = if is_selected {
Style::default().fg(Color::Black).bg(color)
} else {
Style::default().fg(color)
};
let age_u = format_duration(now - sub.updated_at);
let indent = " ".repeat(*depth);
let text = format!("{} {} ({}) {} u:{}", indent, check, sub.id, sub.title, age_u);
Line::from(vec![Span::styled(text, title_style)])
} else {
Line::from("Error: Subtask not found")
}
}
}
};
items.push(ListItem::new(content));
}
let list = List::new(items);
f.render_widget(list, inner_area);
}
fn count_subs_recursive(subs: &[crate::core::model::SubTask]) -> (usize, usize) {
let mut done = 0;
let mut total = 0;
for sub in subs {
total += 1;
if sub.done { done += 1; }
let (d, t) = count_subs_recursive(&sub.subtasks);
done += d;
total += t;
}
(done, total)
}
fn find_sub_in_cat<'a>(app: &'a App, cat: Category, id: u64) -> Option<&'a crate::core::model::SubTask> {
for todo in app.state.get_category(cat) {
if let Some(sub) = find_sub_recursive_static(&todo.subtasks, id) {
return Some(sub);
}
}
None
}
fn find_sub_recursive_static<'a>(subs: &'a [crate::core::model::SubTask], id: u64) -> Option<&'a crate::core::model::SubTask> {
for sub in subs {
if sub.id == id { return Some(sub); }
if let Some(found) = find_sub_recursive_static(&sub.subtasks, id) {
return Some(found);
}
}
None
}
fn draw_help(f: &mut Frame, area: Rect) {
let block = Block::default().borders(Borders::TOP);
let text = "j/k move | Shift+j/k reorder | enter edit | a add | s add-sub | d del | space/x done | n notes | C move-completed | u undo | q quit";
let p = Paragraph::new(text).block(block).style(Style::default().fg(Color::DarkGray));
f.render_widget(p, area);
}
fn draw_notes(f: &mut Frame, app: &mut App, area: Rect) {
let block = Block::default()
.borders(Borders::ALL)
.title(" Notes ")
.border_style(Style::default().fg(Color::Cyan));
let notes = app.get_current_item_notes().unwrap_or_else(|| "No notes.".to_string());
let p = Paragraph::new(notes)
.block(block)
.wrap(Wrap { trim: true });
f.render_widget(p, area);
}
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
let popup_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
])
.split(r);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(popup_layout[1])[1]
}
fn get_color(tag: ColorTag, cat: Category) -> Color {
match tag {
ColorTag::Red => Color::Red,
ColorTag::Yellow => Color::Yellow,
ColorTag::Green => Color::Green,
ColorTag::Blue => Color::Blue,
ColorTag::Magenta => Color::Magenta,
ColorTag::Cyan => Color::Cyan,
ColorTag::Gray => Color::DarkGray,
ColorTag::None => match cat {
Category::Short => Color::Green,
Category::Medium => Color::Yellow,
Category::Long => Color::Blue,
Category::Completed => Color::DarkGray,
},
}
}
fn format_duration(seconds: i64) -> String {
let seconds = seconds.max(0);
let days = seconds / 86400;
if days > 0 {
format!("{}d", days)
} else {
"0d".to_string()
}
}
fn find_todo_in_state<'a>(app: &'a App, cat: Category, id: u64) -> Option<&'a crate::core::model::Todo> {
app.state.get_category(cat).iter().find(|t| t.id == id)
}