Skip to main content

void/ui/
tasks.rs

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        // Shown in build_task_detail for tasks without subtask panel
454    }
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}