use crate::core::model::{Category, ColorTag, State};
use crate::tui::app::{App, FocusedField, InputMode, PopupState, RowRef, ViewMode};
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], app);
if let InputMode::Popup(ref popup) = app.mode {
draw_popup(f, app, popup);
}
}
fn draw_popup(f: &mut Frame, app: &App, 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, created_at, updated_at, focus, date_unit, .. } |
PopupState::EditTodo { input, notes, created_at, updated_at, focus, date_unit, .. } |
PopupState::AddSub { input, notes, created_at, updated_at, focus, date_unit, .. } |
PopupState::EditSub { input, notes, created_at, updated_at, focus, date_unit, .. } => {
let area = centered_rect(80, 50, 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 ",
_ => " Edit ",
})
.border_style(Style::default().fg(Color::Yellow));
f.render_widget(block, area);
let inner_area = area.inner(&ratatui::layout::Margin { horizontal: 1, vertical: 1 });
let outer_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(70), Constraint::Min(25)])
.split(inner_area);
let left_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(0)])
.split(outer_chunks[0]);
let title_label = match popup {
PopupState::AddSub { .. } | PopupState::EditSub { .. } => " Subtask Title ",
_ => " Title ",
};
let title_style = if matches!(focus, FocusedField::Title) { Style::default().fg(Color::Yellow) } else { Style::default().fg(Color::White) };
let title_block = Block::default().borders(Borders::ALL).title(title_label).border_style(title_style);
let title_inner = title_block.inner(left_chunks[0]);
f.render_widget(Paragraph::new(input.as_str()).block(title_block), left_chunks[0]);
let notes_style = if matches!(focus, FocusedField::Notes) { Style::default().fg(Color::Yellow) } else { Style::default().fg(Color::White) };
let notes_block = Block::default().borders(Borders::ALL).title(" Notes ").border_style(notes_style);
let notes_inner = notes_block.inner(left_chunks[1]);
f.render_widget(Paragraph::new(notes.as_str()).block(notes_block).wrap(Wrap { trim: false }), left_chunks[1]);
let right_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(4), Constraint::Length(4), Constraint::Min(0)])
.split(outer_chunks[1]);
let c_style = if matches!(focus, FocusedField::Created) { Style::default().fg(Color::Yellow) } else { Style::default().fg(Color::White) };
let c_block = Block::default().borders(Borders::ALL).title(" Created At ").border_style(c_style);
let c_para = render_datetime(*created_at, matches!(focus, FocusedField::Created), *date_unit).block(c_block);
f.render_widget(c_para, right_chunks[0]);
let u_style = if matches!(focus, FocusedField::Updated) { Style::default().fg(Color::Yellow) } else { Style::default().fg(Color::White) };
let u_block = Block::default().borders(Borders::ALL).title(" Updated At ").border_style(u_style);
let u_para = render_datetime(*updated_at, matches!(focus, FocusedField::Updated), *date_unit).block(u_block);
f.render_widget(u_para, right_chunks[1]);
if let Some((cursor_field, cursor_index)) = app.current_popup_cursor() {
match cursor_field {
FocusedField::Title => {
let (x, y) = text_cursor_position(input, cursor_index, title_inner);
f.set_cursor(x, y);
}
FocusedField::Notes => {
let (x, y) = text_cursor_position(notes, cursor_index, notes_inner);
f.set_cursor(x, y);
}
_ => {}
}
}
}
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) {
match app.view_mode {
ViewMode::Classic => draw_classic_document(f, app, area),
ViewMode::Kanban => draw_kanban_document(f, app, area),
}
}
fn draw_classic_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();
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 time_c = format_timestamp(todo.created_at);
let time_u = format_timestamp(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}) {:<40} u: {} c: {}{}", check, todo.id, todo.title, time_u, time_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 time_u = format_timestamp(sub.updated_at);
let indent = " ".repeat(*depth);
let title_pad = 40_usize.saturating_sub(indent.len());
let text = format!("{} {} ({:3}) {:<width$} u: {}", indent, check, sub.id, sub.title, time_u, width = title_pad);
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 draw_kanban_document(f: &mut Frame, app: &mut App, area: Rect) {
let block = Block::default().borders(Borders::ALL).title(" todomd kanban ");
let inner_area = block.inner(area);
f.render_widget(block, area);
let columns = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Ratio(1, 4),
Constraint::Ratio(1, 4),
Constraint::Ratio(1, 4),
Constraint::Ratio(1, 4),
])
.split(inner_area);
for (index, cat) in State::all_categories().iter().enumerate() {
draw_kanban_column(f, app, columns[index], *cat);
}
}
fn draw_kanban_column(f: &mut Frame, app: &App, area: Rect, cat: Category) {
if area.width < 3 || area.height < 3 {
return;
}
let rows = rows_for_category(app, cat);
let category_selected = matches!(
app.rows.get(app.cursor),
Some(RowRef::Header(selected_cat)) if *selected_cat == cat
) || matches!(
app.rows.get(app.cursor),
Some(RowRef::Item { category, .. }) if *category == cat
);
let border_style = if category_selected {
Style::default()
.fg(category_color(cat))
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::DarkGray)
};
let title = format!(" {} ({}) ", cat.as_str(), rows.len());
let block = Block::default()
.borders(Borders::ALL)
.title(title)
.border_style(border_style);
let inner_area = block.inner(area);
f.render_widget(block, area);
if inner_area.width == 0 || inner_area.height == 0 {
return;
}
if rows.is_empty() {
let empty = Paragraph::new("No tasks")
.style(Style::default().fg(Color::DarkGray))
.alignment(ratatui::layout::Alignment::Center);
f.render_widget(empty, inner_area);
return;
}
let max_items = inner_area.height as usize;
let selected_pos = rows.iter().position(|(row_index, _, _)| *row_index == app.cursor);
let start = match selected_pos {
Some(pos) if pos >= max_items => pos + 1 - max_items,
_ => 0,
};
let end = (start + max_items).min(rows.len());
let items: Vec<ListItem> = rows[start..end]
.iter()
.map(|(row_index, id, depth)| {
let is_selected = *row_index == app.cursor;
ListItem::new(render_kanban_row(app, cat, *id, *depth, inner_area.width, is_selected))
})
.collect();
f.render_widget(List::new(items), inner_area);
}
fn render_kanban_row(
app: &App,
cat: Category,
id: u64,
depth: usize,
width: u16,
is_selected: bool,
) -> Line<'static> {
let base_color = category_color(cat);
if depth == 0 {
if let Some(todo) = find_todo_in_state(app, cat, id) {
let check = if cat == Category::Completed { "[x]" } else { "[ ]" };
let (done, total) = count_subs_recursive(&todo.subtasks);
let suffix = if total > 0 {
format!(" [{} / {}]", done, total)
} else {
String::new()
};
let text = truncate_for_width(
&format!("#{} {} {}{}", todo.id, check, todo.title, suffix),
width as usize,
);
return Line::from(vec![Span::styled(text, card_style(todo.color, cat, is_selected))]);
}
} else if let Some(sub) = find_sub_in_cat(app, cat, id) {
let check = if sub.done { "[x]" } else { "[ ]" };
let indent = " ".repeat(depth.saturating_sub(1));
let text = truncate_for_width(
&format!("{}-> #{} {} {}", indent, sub.id, check, sub.title),
width as usize,
);
return Line::from(vec![Span::styled(text, card_style(sub.color, cat, is_selected))]);
}
let fallback_style = if is_selected {
Style::default().fg(Color::Black).bg(base_color)
} else {
Style::default().fg(base_color)
};
Line::from(vec![Span::styled("Error: item not found", fallback_style)])
}
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(app: &App, cat: Category, id: u64) -> Option<&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(subs: &[crate::core::model::SubTask], id: u64) -> Option<&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, app: &App) {
let block = Block::default().borders(Borders::TOP);
let text = match app.mode {
InputMode::InteractiveMove => "h/l move task | j/k select | enter/esc exit move mode",
_ => match app.view_mode {
ViewMode::Classic => "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",
ViewMode::Kanban => "j/k move | h/l lane | 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 category_color(cat: Category) -> Color {
match cat {
Category::Short => Color::Green,
Category::Medium => Color::Yellow,
Category::Long => Color::Blue,
Category::Completed => Color::DarkGray,
}
}
fn card_style(tag: ColorTag, cat: Category, is_selected: bool) -> Style {
let color = get_color(tag, cat);
if is_selected {
let fg = if color == Color::DarkGray {
Color::White
} else {
Color::Black
};
Style::default().fg(fg).bg(color).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(color)
}
}
fn rows_for_category(app: &App, cat: Category) -> Vec<(usize, u64, usize)> {
app.rows
.iter()
.enumerate()
.filter_map(|(row_index, row)| match row {
RowRef::Item {
category,
id,
depth,
} if *category == cat => Some((row_index, *id, *depth)),
_ => None,
})
.collect()
}
fn truncate_for_width(text: &str, width: usize) -> String {
if width == 0 {
return String::new();
}
let max_chars = width.saturating_sub(1);
let char_count = text.chars().count();
if char_count <= max_chars {
return text.to_string();
}
if max_chars <= 3 {
return ".".repeat(max_chars);
}
let mut truncated: String = text.chars().take(max_chars - 3).collect();
truncated.push_str("...");
truncated
}
fn text_cursor_position(text: &str, cursor_index: usize, area: Rect) -> (u16, u16) {
if area.width == 0 || area.height == 0 {
return (area.x, area.y);
}
let max_width = area.width as usize;
let max_height = area.height as usize;
let mut x = 0usize;
let mut y = 0usize;
for ch in text.chars().take(cursor_index) {
if ch == '\n' {
x = 0;
y += 1;
continue;
}
if x >= max_width {
x = 0;
y += 1;
}
x += 1;
}
if x >= max_width {
x = 0;
y += 1;
}
let clamped_x = x.min(max_width.saturating_sub(1));
let clamped_y = y.min(max_height.saturating_sub(1));
(area.x + clamped_x as u16, area.y + clamped_y as u16)
}
fn format_timestamp(ts: i64) -> String {
use chrono::{DateTime, TimeZone};
let dt: DateTime<Local> = Local.timestamp_opt(ts, 0).unwrap();
dt.format("%Y/%m/%d %H:%M:%S").to_string()
}
fn find_todo_in_state(app: &App, cat: Category, id: u64) -> Option<&crate::core::model::Todo> {
app.state.get_category(cat).iter().find(|t| t.id == id)
}
fn render_datetime(ts: i64, focused: bool, unit: usize) -> Paragraph<'static> {
use chrono::{Datelike, DateTime, TimeZone, Timelike};
let dt: DateTime<Local> = Local.timestamp_opt(ts, 0).unwrap();
let parts = [
format!("{:04}", dt.year()),
format!("{:02}", dt.month()),
format!("{:02}", dt.day()),
format!("{:02}", dt.hour()),
format!("{:02}", dt.minute()),
format!("{:02}", dt.second()),
];
let mut spans = Vec::new();
for (i, p) in parts.iter().enumerate() {
let style = if focused && i == unit {
Style::default().fg(Color::Black).bg(Color::Yellow)
} else {
Style::default()
};
spans.push(Span::styled(p.clone(), style));
if i == 0 || i == 1 { spans.push(Span::raw("/")); }
else if i == 2 { spans.push(Span::raw(" ")); }
else if i == 3 || i == 4 { spans.push(Span::raw(":")); }
}
Paragraph::new(Line::from(spans))
}