use super::*;
pub(crate) fn draw_tasks(f: &mut Frame, app: &mut App, area: Rect) {
let theme = &app.theme;
let icons = app.icons;
let chunks = Layout::default()
.direction(Direction::Horizontal)
.margin(1)
.constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
.split(area);
let indices = app.filtered_task_indices();
let filtered_count = indices.len();
let total_count = app.data.tasks.len();
let selected_idx = app.task_state.selected();
let title_max = chunks[0].width.saturating_sub(22) as usize;
let items: Vec<ListItem> = indices
.iter()
.enumerate()
.map(|(list_idx, &idx)| {
let t = &app.data.tasks[idx];
let marker = task_status_icon(icons, t.status);
let prio_color = match t.priority {
crate::model::Priority::High => theme.warning,
crate::model::Priority::Medium => theme.info,
crate::model::Priority::Low => theme.dim,
};
let is_active = app.active_task == Some(t.id);
let is_cursor = selected_idx == Some(list_idx);
let style = if is_active && !is_cursor {
Style::default()
.bg(theme.active_bg)
.fg(theme.active_fg)
.add_modifier(Modifier::BOLD)
} else if t.is_overdue() && !is_cursor {
Style::default().fg(theme.error)
} else {
Style::default().fg(theme.text)
};
let overdue_mark = if t.is_overdue() { icons.alert } else { " " };
let today_mark = if t.today { icons.star } else { " " };
let active_mark = if is_active { icons.task_active } else { " " };
let active_style = if is_active {
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD)
} else {
style
};
let tags_label = if t.tags.is_empty() {
String::new()
} else {
format!(" #{}", truncate(&t.tags.join(", "), 12))
};
let mut spans = vec![
Span::styled(
format!("{}{}{}{} ", active_mark, overdue_mark, today_mark, marker),
if is_active && is_cursor {
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD)
} else if is_active {
active_style
} else {
style
},
),
Span::styled(
format!("{:<3} ", t.priority.label()),
Style::default().fg(prio_color),
),
Span::styled(format!("{} ", truncate(&t.title, title_max.max(8))), style),
Span::styled(
format!("{:>3}/{:<3}m", t.actual_minutes, t.estimated_minutes),
Style::default().fg(theme.dim),
),
];
if !tags_label.is_empty() {
spans.push(Span::styled(tags_label, Style::default().fg(theme.info)));
}
ListItem::new(Line::from(spans))
})
.collect();
let filter_label = if app.task_search.is_empty() {
app.task_filter.label().to_string()
} else {
format!("'{}'", app.task_search)
};
let visible_height = chunks[0].height.saturating_sub(2) as usize;
let has_overflow = filtered_count > visible_height;
let at_bottom = app
.task_state
.selected()
.map(|sel| sel + 1 >= filtered_count)
.unwrap_or(true);
let more_indicator = if has_overflow && !at_bottom {
" ↓ more "
} else {
""
};
let block = themed_panel(
theme,
Line::from(vec![
Span::styled(
format!(
" {} Tasks [{}] ({}/{}) ",
icons.tasks, filter_label, filtered_count, total_count
),
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
),
Span::styled(more_indicator, Style::default().fg(theme.dim)),
]),
);
let list = List::new(items)
.block(block)
.highlight_style(
Style::default()
.bg(theme.select_bg)
.fg(theme.select_fg)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("▸ ");
f.render_stateful_widget(list, chunks[0], &mut app.task_state);
let detail_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(0)])
.split(chunks[1]);
let progress_ratio = app
.task_state
.selected()
.and_then(|sel| indices.get(sel).copied())
.map(|idx| app.data.tasks[idx].progress_ratio())
.unwrap_or(0.0);
f.render_widget(
Gauge::default()
.gauge_style(Style::default().fg(theme.accent).bg(theme.dim))
.ratio(progress_ratio)
.label(format!("Progress {}%", (progress_ratio * 100.0) as u32))
.block(themed_panel(
theme,
Line::from(Span::styled(
" Progress ",
Style::default().fg(theme.accent),
)),
)),
detail_layout[0],
);
let detail = build_task_detail(app);
let detail_block = themed_panel(
theme,
Line::from(Span::styled(" Details ", Style::default().fg(theme.accent))),
);
f.render_widget(
Paragraph::new(detail)
.block(detail_block)
.wrap(Wrap { trim: false }),
detail_layout[1],
);
if app.searching {
let search_area = centered_rect(50, 20, area);
f.render_widget(Clear, search_area);
f.render_widget(
Paragraph::new(vec![
Line::from(Span::styled(
"Search tasks (title, notes, tags)",
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
)),
Line::from(format!("{}|", app.task_search)),
Line::from(Span::styled(
"Enter confirm · Esc cancel",
Style::default().fg(theme.dim),
)),
])
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.accent)),
),
search_area,
);
}
}
pub(crate) fn build_task_detail(app: &App) -> Vec<Line<'_>> {
let theme = &app.theme;
let indices = app.filtered_task_indices();
if indices.is_empty() {
let msg = match app.task_filter {
TaskFilter::All => "No tasks yet. Press 'a' to add one.",
TaskFilter::Pending => "All tasks done! Great work.",
TaskFilter::Done => "No completed tasks yet.",
TaskFilter::Today => "Nothing queued for today. Press 't' to tag tasks.",
};
return vec![Line::from(Span::styled(
msg,
Style::default().fg(theme.dim),
))];
}
let sel = app
.task_state
.selected()
.unwrap_or(0)
.min(indices.len() - 1);
let t = &app.data.tasks[indices[sel]];
let mut lines = Vec::new();
if t.is_overdue() {
lines.push(Line::from(Span::styled(
"OVERDUE",
Style::default()
.fg(theme.error)
.add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(""));
}
let status_color = match t.status {
crate::model::TaskStatus::Done => theme.success,
crate::model::TaskStatus::InProgress => theme.warning,
crate::model::TaskStatus::Pending => theme.dim,
};
lines.push(Line::from(Span::styled(
t.title.clone(),
Style::default().fg(theme.text).add_modifier(Modifier::BOLD),
)));
lines.extend(vec![
Line::from(""),
Line::from(vec![
Span::styled("ID: ", Style::default().fg(theme.dim)),
Span::styled(format!("{}", t.id), Style::default().fg(theme.text)),
]),
Line::from(vec![
Span::styled("Priority: ", Style::default().fg(theme.dim)),
Span::styled(t.priority.label(), Style::default().fg(theme.warning)),
]),
Line::from(vec![
Span::styled("Status: ", Style::default().fg(theme.dim)),
Span::styled(t.status.label(), Style::default().fg(status_color)),
]),
Line::from(vec![
Span::styled("Estimate: ", Style::default().fg(theme.dim)),
Span::styled(
format_minutes(t.estimated_minutes),
Style::default().fg(theme.text),
),
]),
Line::from(vec![
Span::styled("Logged: ", Style::default().fg(theme.dim)),
Span::styled(
format!(
"{} across {} sessions",
format_minutes(t.actual_minutes),
t.sessions
),
Style::default().fg(theme.success),
),
]),
Line::from(vec![
Span::styled("Remaining: ", Style::default().fg(theme.dim)),
Span::styled(
format!(
"~{} sessions ({}m each)",
crate::storage::sessions_remaining_hint(t, app.data.focus_minutes),
app.data.focus_minutes
),
Style::default().fg(theme.info),
),
]),
Line::from(vec![
Span::styled("Today: ", Style::default().fg(theme.dim)),
Span::styled(
if t.today { "yes" } else { "no" },
Style::default().fg(if t.today { theme.success } else { theme.dim }),
),
]),
Line::from(vec![
Span::styled("Created: ", Style::default().fg(theme.dim)),
Span::styled(
t.created_at.format("%Y-%m-%d %H:%M").to_string(),
Style::default().fg(theme.text),
),
]),
]);
if let Some(c) = t.completed_at {
lines.push(Line::from(vec![
Span::styled("Done: ", Style::default().fg(theme.dim)),
Span::styled(
c.format("%Y-%m-%d %H:%M").to_string(),
Style::default().fg(theme.success),
),
]));
}
if let Some(ref due) = t.due_date {
let overdue = t.is_overdue();
lines.push(Line::from(vec![
Span::styled("Due: ", Style::default().fg(theme.dim)),
Span::styled(
due.clone(),
Style::default().fg(if overdue { theme.error } else { theme.text }),
),
]));
}
if !t.tags.is_empty() {
lines.push(Line::from(vec![
Span::styled("Tags: ", Style::default().fg(theme.dim)),
Span::styled(t.tags.join(", "), Style::default().fg(theme.info)),
]));
}
if !t.notes.is_empty() {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"Notes:",
Style::default().fg(theme.dim).add_modifier(Modifier::BOLD),
)));
for l in t.notes.lines() {
lines.push(Line::from(Span::styled(
l.to_string(),
Style::default().fg(theme.text),
)));
}
}
if app.active_task == Some(t.id) {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
format!("{} ACTIVE — press [f] to focus", app.icons.focus),
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
)));
}
lines
}