1use super::*;
2
3pub(crate) fn draw_tasks(f: &mut Frame, app: &mut App, area: Rect) {
4 let theme = app.theme.clone();
5 let icons = app.icons;
6 let chunks = Layout::default()
7 .direction(Direction::Horizontal)
8 .margin(1)
9 .constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
10 .split(area);
11
12 let indices = app.filtered_task_indices();
13 let filtered_count = indices.len();
14 let total_count = app.data.tasks.len();
15 let selected_idx = app.task_state.selected();
16 let title_max = chunks[0].width.saturating_sub(22) as usize;
17 let items: Vec<ListItem> = indices
18 .iter()
19 .enumerate()
20 .map(|(list_idx, &idx)| {
21 let t = &app.data.tasks[idx];
22 let marker = task_status_icon(icons, t.status);
23 let prio_color = match t.priority {
24 crate::model::Priority::High => theme.warning,
25 crate::model::Priority::Medium => theme.info,
26 crate::model::Priority::Low => theme.dim,
27 };
28 let is_active = app.active_task == Some(t.id);
29 let subtask_mark = t
30 .subtask_progress()
31 .map(|(d, n)| format!(" ({d}/{n})"))
32 .unwrap_or_default();
33 let blocked_mark = if t.is_blocked(&app.data.tasks) {
34 "!"
35 } else {
36 ""
37 };
38 let is_reordering = app.reordering_task == Some(t.id);
39 let reorder_mark = if is_reordering { " ↕ " } else { "" };
40 let is_cursor = selected_idx == Some(list_idx);
41 let bulk_selected = app.bulk_mode && app.bulk_selected.contains(&t.id);
42 let bulk_mark = if app.bulk_mode {
43 if bulk_selected {
44 Span::styled(
45 icons.check,
46 Style::default()
47 .fg(theme.on_accent)
48 .add_modifier(Modifier::BOLD),
49 )
50 } else {
51 Span::styled("○", Style::default().fg(theme.dim))
52 }
53 } else {
54 Span::raw("")
55 };
56 let style = if bulk_selected {
57 Style::default()
58 .bg(theme.info)
59 .fg(theme.on_accent)
60 .add_modifier(Modifier::BOLD)
61 } else if is_active && !is_cursor {
62 Style::default()
63 .bg(theme.active_bg)
64 .fg(theme.active_fg)
65 .add_modifier(Modifier::BOLD)
66 } else if t.is_overdue() && !is_cursor {
67 Style::default().fg(theme.error)
68 } else {
69 Style::default().fg(theme.text)
70 };
71 let overdue_mark = if t.is_overdue() { icons.alert } else { " " };
72 let today_mark = if t.today { icons.star } else { " " };
73 let active_mark = if is_active { icons.task_active } else { " " };
74 let active_style = if is_active {
75 Style::default()
76 .fg(theme.accent)
77 .add_modifier(Modifier::BOLD)
78 } else {
79 style
80 };
81 let tags_label = if t.tags.is_empty() {
82 String::new()
83 } else {
84 format!(" #{}", truncate(&t.tags.join(", "), 12))
85 };
86 let mut spans = vec![Span::styled(
87 format!("{} ", active_mark),
88 if is_active && is_cursor {
89 Style::default()
90 .fg(theme.accent)
91 .add_modifier(Modifier::BOLD)
92 } else if is_active {
93 active_style
94 } else {
95 style
96 },
97 )];
98 if app.bulk_mode {
99 spans.push(bulk_mark);
100 spans.push(Span::raw(" "));
101 }
102 spans.push(Span::styled(
103 format!("{}{}{}{} ", overdue_mark, today_mark, marker, reorder_mark),
104 if is_active && is_cursor {
105 Style::default()
106 .fg(theme.accent)
107 .add_modifier(Modifier::BOLD)
108 } else if is_active {
109 active_style
110 } else {
111 style
112 },
113 ));
114 if !blocked_mark.is_empty() {
115 spans.push(Span::styled(
116 blocked_mark,
117 Style::default().fg(theme.warning),
118 ));
119 }
120 spans.extend([
121 Span::styled(
122 format!("{:<3} ", t.priority.label()),
123 Style::default().fg(prio_color),
124 ),
125 Span::styled(format!("{} ", truncate(&t.title, title_max.max(8))), style),
126 Span::styled(subtask_mark, Style::default().fg(theme.dim)),
127 Span::styled(
128 format!("{:>3}/{:<3}m", t.actual_minutes, t.estimated_minutes),
129 Style::default().fg(theme.dim),
130 ),
131 ]);
132 if !tags_label.is_empty() {
133 spans.push(Span::styled(tags_label, Style::default().fg(theme.info)));
134 }
135 ListItem::new(Line::from(spans))
136 })
137 .collect();
138
139 let filter_label = if app.task_search.is_empty() {
140 app.task_filter.label().to_string()
141 } else {
142 format!("'{}'", app.task_search)
143 };
144
145 let visible_height = chunks[0].height.saturating_sub(2) as usize;
146 let has_overflow = filtered_count > visible_height;
147 let at_bottom = app
148 .task_state
149 .selected()
150 .map(|sel| sel + 1 >= filtered_count)
151 .unwrap_or(true);
152 let more_indicator = if has_overflow && !at_bottom {
153 " ↓ more "
154 } else {
155 ""
156 };
157
158 let bulk_hint = if app.bulk_mode { " · BULK" } else { "" };
159 let block = themed_panel(
160 &theme,
161 Line::from(vec![
162 Span::styled(
163 format!(
164 " {} Tasks [{}] ({}/{}){} ",
165 icons.tasks, filter_label, filtered_count, total_count, bulk_hint
166 ),
167 Style::default()
168 .fg(if app.bulk_mode {
169 theme.info
170 } else {
171 theme.accent
172 })
173 .add_modifier(Modifier::BOLD),
174 ),
175 Span::styled(more_indicator, Style::default().fg(theme.dim)),
176 ]),
177 );
178 let list = List::new(items)
179 .block(block)
180 .highlight_style(
181 Style::default()
182 .bg(theme.select_bg)
183 .fg(theme.select_fg)
184 .add_modifier(Modifier::BOLD),
185 )
186 .highlight_symbol("▸ ");
187 f.render_stateful_widget(list, chunks[0], &mut app.task_state);
188
189 let detail_layout = Layout::default()
190 .direction(Direction::Vertical)
191 .constraints([Constraint::Length(3), Constraint::Min(0)])
192 .split(chunks[1]);
193 let progress_ratio = app
194 .task_state
195 .selected()
196 .and_then(|sel| indices.get(sel).copied())
197 .map(|idx| app.data.tasks[idx].progress_ratio())
198 .unwrap_or(0.0);
199 f.render_widget(
200 Gauge::default()
201 .gauge_style(Style::default().fg(theme.accent).bg(theme.dim))
202 .ratio(progress_ratio)
203 .label(format!("Progress {}%", (progress_ratio * 100.0) as u32))
204 .block(themed_panel(
205 &theme,
206 Line::from(Span::styled(
207 " Progress ",
208 Style::default().fg(theme.accent),
209 )),
210 )),
211 detail_layout[0],
212 );
213 let has_subtasks = app
214 .task_state
215 .selected()
216 .and_then(|s| indices.get(s).copied())
217 .map(|idx| !app.data.tasks[idx].subtasks.is_empty())
218 .unwrap_or(false);
219
220 if has_subtasks {
221 let sub_chunks = Layout::default()
222 .direction(Direction::Vertical)
223 .constraints([Constraint::Percentage(55), Constraint::Percentage(45)])
224 .split(detail_layout[1]);
225 let meta_block = themed_panel(
226 &theme,
227 Line::from(Span::styled(" Details ", Style::default().fg(theme.accent))),
228 );
229 f.render_widget(
230 Paragraph::new(build_task_detail_meta(app))
231 .block(meta_block)
232 .wrap(Wrap { trim: false }),
233 sub_chunks[0],
234 );
235 if let Some(sel) = app
236 .task_state
237 .selected()
238 .and_then(|s| indices.get(s).copied())
239 {
240 let task = app.data.tasks[sel].clone();
241 draw_subtask_panel(f, app, sub_chunks[1], &theme, &task);
242 }
243 } else {
244 let detail_block = themed_panel(
245 &theme,
246 Line::from(Span::styled(" Details ", Style::default().fg(theme.accent))),
247 );
248 f.render_widget(
249 Paragraph::new(build_task_detail(app))
250 .block(detail_block)
251 .wrap(Wrap { trim: false }),
252 detail_layout[1],
253 );
254 }
255
256 if app.searching {
257 let search_area = centered_rect(50, 20, area);
258 f.render_widget(Clear, search_area);
259 f.render_widget(
260 Paragraph::new(vec![
261 Line::from(Span::styled(
262 "Search tasks (title or tags)",
263 Style::default()
264 .fg(theme.accent)
265 .add_modifier(Modifier::BOLD),
266 )),
267 Line::from(format!("{}|", app.task_search)),
268 Line::from(Span::styled(
269 "Enter confirm · Esc cancel",
270 Style::default().fg(theme.dim),
271 )),
272 ])
273 .block(
274 Block::default()
275 .borders(Borders::ALL)
276 .border_type(BorderType::Rounded)
277 .border_style(Style::default().fg(theme.accent)),
278 ),
279 search_area,
280 );
281 }
282}
283
284pub(crate) fn build_task_detail(app: &App) -> Vec<Line<'_>> {
285 let mut lines = build_task_detail_meta(app);
286 let indices = app.filtered_task_indices();
287 if indices.is_empty() {
288 return lines;
289 }
290 let sel = app
291 .task_state
292 .selected()
293 .unwrap_or(0)
294 .min(indices.len() - 1);
295 let t = &app.data.tasks[indices[sel]];
296 if t.subtasks.is_empty() && t.status != crate::model::TaskStatus::Done {
297 lines.push(Line::from(Span::styled(
298 "No subtasks — [c] add · [Tab] focus when added",
299 Style::default().fg(app.theme.dim),
300 )));
301 }
302 lines
303}
304
305fn build_task_detail_meta(app: &App) -> Vec<Line<'_>> {
306 let theme = &app.theme;
307 let indices = app.filtered_task_indices();
308 if indices.is_empty() {
309 let msg = match app.task_filter {
310 TaskFilter::All => "No tasks yet. Press 'a' to add one.",
311 TaskFilter::Pending => "All tasks done! Great work.",
312 TaskFilter::Done => "No completed tasks yet.",
313 TaskFilter::Today => "Nothing queued for today. Press 't' to tag tasks.",
314 TaskFilter::Archived => "No archived tasks.",
315 };
316 return vec![Line::from(Span::styled(
317 msg,
318 Style::default().fg(theme.dim),
319 ))];
320 }
321 let sel = app
322 .task_state
323 .selected()
324 .unwrap_or(0)
325 .min(indices.len() - 1);
326 let t = &app.data.tasks[indices[sel]];
327 let mut lines = Vec::new();
328 if t.is_overdue() {
329 lines.push(Line::from(Span::styled(
330 "OVERDUE",
331 Style::default()
332 .fg(theme.error)
333 .add_modifier(Modifier::BOLD),
334 )));
335 lines.push(Line::from(""));
336 }
337 let status_color = match t.status {
338 crate::model::TaskStatus::Done => theme.success,
339 crate::model::TaskStatus::InProgress => theme.warning,
340 crate::model::TaskStatus::Pending => theme.dim,
341 };
342 lines.push(Line::from(Span::styled(
343 t.title.clone(),
344 Style::default().fg(theme.text).add_modifier(Modifier::BOLD),
345 )));
346 lines.extend(vec![
347 Line::from(""),
348 Line::from(vec![
349 Span::styled("ID: ", Style::default().fg(theme.dim)),
350 Span::styled(format!("{}", t.id), Style::default().fg(theme.text)),
351 ]),
352 Line::from(vec![
353 Span::styled("Priority: ", Style::default().fg(theme.dim)),
354 Span::styled(t.priority.label(), Style::default().fg(theme.warning)),
355 ]),
356 Line::from(vec![
357 Span::styled("Status: ", Style::default().fg(theme.dim)),
358 Span::styled(t.status.label(), Style::default().fg(status_color)),
359 ]),
360 Line::from(vec![
361 Span::styled("Estimate: ", Style::default().fg(theme.dim)),
362 Span::styled(
363 format_minutes(t.estimated_minutes),
364 Style::default().fg(theme.text),
365 ),
366 ]),
367 Line::from(vec![
368 Span::styled("Logged: ", Style::default().fg(theme.dim)),
369 Span::styled(
370 format!(
371 "{} across {} sessions",
372 format_minutes(t.actual_minutes),
373 t.sessions
374 ),
375 Style::default().fg(theme.success),
376 ),
377 ]),
378 Line::from(vec![
379 Span::styled("Remaining: ", Style::default().fg(theme.dim)),
380 Span::styled(
381 format!(
382 "~{} sessions ({}m each)",
383 crate::storage::sessions_remaining_hint(t, app.data.focus_minutes),
384 app.data.focus_minutes
385 ),
386 Style::default().fg(theme.info),
387 ),
388 ]),
389 Line::from(vec![
390 Span::styled("Today: ", Style::default().fg(theme.dim)),
391 Span::styled(
392 if t.today { "yes" } else { "no" },
393 Style::default().fg(if t.today { theme.success } else { theme.dim }),
394 ),
395 ]),
396 Line::from(vec![
397 Span::styled("Created: ", Style::default().fg(theme.dim)),
398 Span::styled(
399 t.created_at.format("%Y-%m-%d %H:%M").to_string(),
400 Style::default().fg(theme.text),
401 ),
402 ]),
403 ]);
404 if let Some(c) = t.completed_at {
405 lines.push(Line::from(vec![
406 Span::styled("Done: ", Style::default().fg(theme.dim)),
407 Span::styled(
408 c.format("%Y-%m-%d %H:%M").to_string(),
409 Style::default().fg(theme.success),
410 ),
411 ]));
412 }
413 if let Some(ref due) = t.due_date {
414 let overdue = t.is_overdue();
415 lines.push(Line::from(vec![
416 Span::styled("Due: ", Style::default().fg(theme.dim)),
417 Span::styled(
418 due.clone(),
419 Style::default().fg(if overdue { theme.error } else { theme.text }),
420 ),
421 ]));
422 }
423 if !t.tags.is_empty() {
424 lines.push(Line::from(vec![
425 Span::styled("Tags: ", Style::default().fg(theme.dim)),
426 Span::styled(t.tags.join(", "), Style::default().fg(theme.info)),
427 ]));
428 }
429 if t.recurrence != crate::model::TaskRecurrence::None {
430 lines.push(Line::from(vec![
431 Span::styled("Repeats: ", Style::default().fg(theme.dim)),
432 Span::styled(t.recurrence.label(), Style::default().fg(theme.info)),
433 ]));
434 }
435 if !t.blocked_by.is_empty() {
436 lines.push(Line::from(vec![
437 Span::styled("Blocked: ", Style::default().fg(theme.dim)),
438 Span::styled(
439 t.blocked_by
440 .iter()
441 .map(|id| id.to_string())
442 .collect::<Vec<_>>()
443 .join(", "),
444 Style::default().fg(if t.is_blocked(&app.data.tasks) {
445 theme.error
446 } else {
447 theme.text
448 }),
449 ),
450 ]));
451 }
452 if t.subtasks.is_empty() && t.status != crate::model::TaskStatus::Done {
453 }
455 if app.active_task == Some(t.id) {
456 lines.push(Line::from(""));
457 lines.push(Line::from(Span::styled(
458 format!("{} ACTIVE — press [f] to focus", app.icons.focus),
459 Style::default()
460 .fg(theme.accent)
461 .add_modifier(Modifier::BOLD),
462 )));
463 }
464 lines
465}
466
467fn draw_subtask_panel(
468 f: &mut Frame,
469 app: &mut App,
470 area: Rect,
471 theme: &crate::app::Theme,
472 task: &crate::model::Task,
473) {
474 let (done, total) = task.subtask_progress().unwrap_or((0, 0));
475 let focus_label = if app.subtask_focus { " · FOCUS" } else { "" };
476 let border_color = if app.subtask_focus {
477 theme.accent
478 } else {
479 theme.panel_border
480 };
481 let block = Block::default()
482 .borders(Borders::ALL)
483 .border_type(BorderType::Rounded)
484 .border_style(Style::default().fg(border_color))
485 .title(Line::from(vec![
486 Span::styled(
487 format!(" Subtasks ({done}/{total}){focus_label} ",),
488 Style::default()
489 .fg(theme.accent)
490 .add_modifier(Modifier::BOLD),
491 ),
492 Span::styled(
493 "[Tab] focus · j/k nav · x toggle · q back · - remove",
494 Style::default().fg(theme.dim),
495 ),
496 ]));
497 let inner = block.inner(area);
498 f.render_widget(block, area);
499
500 app.subtask_state.select(Some(app.subtask_selected));
501 let items: Vec<ListItem> = task
502 .subtasks
503 .iter()
504 .map(|s| {
505 let mark = if s.done {
506 Span::styled(
507 format!("{} ", app.icons.check),
508 Style::default().fg(theme.success),
509 )
510 } else {
511 Span::styled("○ ", Style::default().fg(theme.dim))
512 };
513 let title = Span::styled(
514 s.title.clone(),
515 if s.done {
516 Style::default().fg(theme.dim)
517 } else {
518 Style::default().fg(theme.text)
519 },
520 );
521 ListItem::new(Line::from(vec![mark, title]))
522 })
523 .collect();
524
525 let list = List::new(items).highlight_style(
526 Style::default()
527 .bg(theme.select_bg)
528 .fg(theme.select_fg)
529 .add_modifier(Modifier::BOLD),
530 );
531 f.render_stateful_widget(list, inner, &mut app.subtask_state);
532}