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}