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;
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 is_cursor = selected_idx == Some(list_idx);
30            let style = if is_active && !is_cursor {
31                Style::default()
32                    .bg(theme.active_bg)
33                    .fg(theme.active_fg)
34                    .add_modifier(Modifier::BOLD)
35            } else if t.is_overdue() && !is_cursor {
36                Style::default().fg(theme.error)
37            } else {
38                Style::default().fg(theme.text)
39            };
40            let overdue_mark = if t.is_overdue() { icons.alert } else { " " };
41            let today_mark = if t.today { icons.star } else { " " };
42            let active_mark = if is_active { icons.task_active } else { " " };
43            let active_style = if is_active {
44                Style::default()
45                    .fg(theme.accent)
46                    .add_modifier(Modifier::BOLD)
47            } else {
48                style
49            };
50            let tags_label = if t.tags.is_empty() {
51                String::new()
52            } else {
53                format!(" #{}", truncate(&t.tags.join(", "), 12))
54            };
55            let mut spans = vec![
56                Span::styled(
57                    format!("{}{}{}{} ", active_mark, overdue_mark, today_mark, marker),
58                    if is_active && is_cursor {
59                        Style::default()
60                            .fg(theme.accent)
61                            .add_modifier(Modifier::BOLD)
62                    } else if is_active {
63                        active_style
64                    } else {
65                        style
66                    },
67                ),
68                Span::styled(
69                    format!("{:<3} ", t.priority.label()),
70                    Style::default().fg(prio_color),
71                ),
72                Span::styled(format!("{} ", truncate(&t.title, title_max.max(8))), style),
73                Span::styled(
74                    format!("{:>3}/{:<3}m", t.actual_minutes, t.estimated_minutes),
75                    Style::default().fg(theme.dim),
76                ),
77            ];
78            if !tags_label.is_empty() {
79                spans.push(Span::styled(tags_label, Style::default().fg(theme.info)));
80            }
81            ListItem::new(Line::from(spans))
82        })
83        .collect();
84
85    let filter_label = if app.task_search.is_empty() {
86        app.task_filter.label().to_string()
87    } else {
88        format!("'{}'", app.task_search)
89    };
90
91    let visible_height = chunks[0].height.saturating_sub(2) as usize;
92    let has_overflow = filtered_count > visible_height;
93    let at_bottom = app
94        .task_state
95        .selected()
96        .map(|sel| sel + 1 >= filtered_count)
97        .unwrap_or(true);
98    let more_indicator = if has_overflow && !at_bottom {
99        " ↓ more "
100    } else {
101        ""
102    };
103
104    let block = themed_panel(
105        theme,
106        Line::from(vec![
107            Span::styled(
108                format!(
109                    " {} Tasks [{}] ({}/{}) ",
110                    icons.tasks, filter_label, filtered_count, total_count
111                ),
112                Style::default()
113                    .fg(theme.accent)
114                    .add_modifier(Modifier::BOLD),
115            ),
116            Span::styled(more_indicator, Style::default().fg(theme.dim)),
117        ]),
118    );
119    let list = List::new(items)
120        .block(block)
121        .highlight_style(
122            Style::default()
123                .bg(theme.select_bg)
124                .fg(theme.select_fg)
125                .add_modifier(Modifier::BOLD),
126        )
127        .highlight_symbol("▸ ");
128    f.render_stateful_widget(list, chunks[0], &mut app.task_state);
129
130    let detail_layout = Layout::default()
131        .direction(Direction::Vertical)
132        .constraints([Constraint::Length(3), Constraint::Min(0)])
133        .split(chunks[1]);
134    let progress_ratio = app
135        .task_state
136        .selected()
137        .and_then(|sel| indices.get(sel).copied())
138        .map(|idx| app.data.tasks[idx].progress_ratio())
139        .unwrap_or(0.0);
140    f.render_widget(
141        Gauge::default()
142            .gauge_style(Style::default().fg(theme.accent).bg(theme.dim))
143            .ratio(progress_ratio)
144            .label(format!("Progress {}%", (progress_ratio * 100.0) as u32))
145            .block(themed_panel(
146                theme,
147                Line::from(Span::styled(
148                    " Progress ",
149                    Style::default().fg(theme.accent),
150                )),
151            )),
152        detail_layout[0],
153    );
154    let detail = build_task_detail(app);
155    let detail_block = themed_panel(
156        theme,
157        Line::from(Span::styled(" Details ", Style::default().fg(theme.accent))),
158    );
159    f.render_widget(
160        Paragraph::new(detail)
161            .block(detail_block)
162            .wrap(Wrap { trim: false }),
163        detail_layout[1],
164    );
165
166    if app.searching {
167        let search_area = centered_rect(50, 20, area);
168        f.render_widget(Clear, search_area);
169        f.render_widget(
170            Paragraph::new(vec![
171                Line::from(Span::styled(
172                    "Search tasks (title, notes, tags)",
173                    Style::default()
174                        .fg(theme.accent)
175                        .add_modifier(Modifier::BOLD),
176                )),
177                Line::from(format!("{}|", app.task_search)),
178                Line::from(Span::styled(
179                    "Enter confirm · Esc cancel",
180                    Style::default().fg(theme.dim),
181                )),
182            ])
183            .block(
184                Block::default()
185                    .borders(Borders::ALL)
186                    .border_type(BorderType::Rounded)
187                    .border_style(Style::default().fg(theme.accent)),
188            ),
189            search_area,
190        );
191    }
192}
193
194pub(crate) fn build_task_detail(app: &App) -> Vec<Line<'_>> {
195    let theme = &app.theme;
196    let indices = app.filtered_task_indices();
197    if indices.is_empty() {
198        let msg = match app.task_filter {
199            TaskFilter::All => "No tasks yet. Press 'a' to add one.",
200            TaskFilter::Pending => "All tasks done! Great work.",
201            TaskFilter::Done => "No completed tasks yet.",
202            TaskFilter::Today => "Nothing queued for today. Press 't' to tag tasks.",
203        };
204        return vec![Line::from(Span::styled(
205            msg,
206            Style::default().fg(theme.dim),
207        ))];
208    }
209    let sel = app
210        .task_state
211        .selected()
212        .unwrap_or(0)
213        .min(indices.len() - 1);
214    let t = &app.data.tasks[indices[sel]];
215    let mut lines = Vec::new();
216    if t.is_overdue() {
217        lines.push(Line::from(Span::styled(
218            "OVERDUE",
219            Style::default()
220                .fg(theme.error)
221                .add_modifier(Modifier::BOLD),
222        )));
223        lines.push(Line::from(""));
224    }
225    let status_color = match t.status {
226        crate::model::TaskStatus::Done => theme.success,
227        crate::model::TaskStatus::InProgress => theme.warning,
228        crate::model::TaskStatus::Pending => theme.dim,
229    };
230    lines.push(Line::from(Span::styled(
231        t.title.clone(),
232        Style::default().fg(theme.text).add_modifier(Modifier::BOLD),
233    )));
234    lines.extend(vec![
235        Line::from(""),
236        Line::from(vec![
237            Span::styled("ID:        ", Style::default().fg(theme.dim)),
238            Span::styled(format!("{}", t.id), Style::default().fg(theme.text)),
239        ]),
240        Line::from(vec![
241            Span::styled("Priority:  ", Style::default().fg(theme.dim)),
242            Span::styled(t.priority.label(), Style::default().fg(theme.warning)),
243        ]),
244        Line::from(vec![
245            Span::styled("Status:    ", Style::default().fg(theme.dim)),
246            Span::styled(t.status.label(), Style::default().fg(status_color)),
247        ]),
248        Line::from(vec![
249            Span::styled("Estimate:  ", Style::default().fg(theme.dim)),
250            Span::styled(
251                format_minutes(t.estimated_minutes),
252                Style::default().fg(theme.text),
253            ),
254        ]),
255        Line::from(vec![
256            Span::styled("Logged:    ", Style::default().fg(theme.dim)),
257            Span::styled(
258                format!(
259                    "{} across {} sessions",
260                    format_minutes(t.actual_minutes),
261                    t.sessions
262                ),
263                Style::default().fg(theme.success),
264            ),
265        ]),
266        Line::from(vec![
267            Span::styled("Remaining: ", Style::default().fg(theme.dim)),
268            Span::styled(
269                format!(
270                    "~{} sessions ({}m each)",
271                    crate::storage::sessions_remaining_hint(t, app.data.focus_minutes),
272                    app.data.focus_minutes
273                ),
274                Style::default().fg(theme.info),
275            ),
276        ]),
277        Line::from(vec![
278            Span::styled("Today:     ", Style::default().fg(theme.dim)),
279            Span::styled(
280                if t.today { "yes" } else { "no" },
281                Style::default().fg(if t.today { theme.success } else { theme.dim }),
282            ),
283        ]),
284        Line::from(vec![
285            Span::styled("Created:   ", Style::default().fg(theme.dim)),
286            Span::styled(
287                t.created_at.format("%Y-%m-%d %H:%M").to_string(),
288                Style::default().fg(theme.text),
289            ),
290        ]),
291    ]);
292    if let Some(c) = t.completed_at {
293        lines.push(Line::from(vec![
294            Span::styled("Done:      ", Style::default().fg(theme.dim)),
295            Span::styled(
296                c.format("%Y-%m-%d %H:%M").to_string(),
297                Style::default().fg(theme.success),
298            ),
299        ]));
300    }
301    if let Some(ref due) = t.due_date {
302        let overdue = t.is_overdue();
303        lines.push(Line::from(vec![
304            Span::styled("Due:       ", Style::default().fg(theme.dim)),
305            Span::styled(
306                due.clone(),
307                Style::default().fg(if overdue { theme.error } else { theme.text }),
308            ),
309        ]));
310    }
311    if !t.tags.is_empty() {
312        lines.push(Line::from(vec![
313            Span::styled("Tags:      ", Style::default().fg(theme.dim)),
314            Span::styled(t.tags.join(", "), Style::default().fg(theme.info)),
315        ]));
316    }
317    if !t.notes.is_empty() {
318        lines.push(Line::from(""));
319        lines.push(Line::from(Span::styled(
320            "Notes:",
321            Style::default().fg(theme.dim).add_modifier(Modifier::BOLD),
322        )));
323        for l in t.notes.lines() {
324            lines.push(Line::from(Span::styled(
325                l.to_string(),
326                Style::default().fg(theme.text),
327            )));
328        }
329    }
330    if app.active_task == Some(t.id) {
331        lines.push(Line::from(""));
332        lines.push(Line::from(Span::styled(
333            format!("{} ACTIVE — press [f] to focus", app.icons.focus),
334            Style::default()
335                .fg(theme.accent)
336                .add_modifier(Modifier::BOLD),
337        )));
338    }
339    lines
340}