1use super::*;
2
3fn popup_size(popup: &crate::app::Popup) -> (u16, u16) {
4 match popup {
5 crate::app::Popup::AddSubtask(_) => (48, 24),
6 crate::app::Popup::ConfirmDelete(_)
7 | crate::app::Popup::BulkConfirm(_)
8 | crate::app::Popup::EmptyQueueChoice => (50, 28),
9 _ => (68, 78),
10 }
11}
12
13fn popup_min_size(popup: &crate::app::Popup) -> (u16, u16) {
14 match popup {
15 crate::app::Popup::AddSubtask(_) => (36, 10),
16 _ => (32, 10),
17 }
18}
19
20fn popup_rect(popup: &crate::app::Popup, area: Rect) -> Rect {
21 let (pw, ph) = popup_size(popup);
22 let (min_w, min_h) = popup_min_size(popup);
23 let mut r = centered_rect(pw, ph, area);
24 r.width = r.width.max(min_w).min(area.width);
25 r.height = r.height.max(min_h).min(area.height);
26 r
27}
28
29fn rect_ok(area: Rect) -> bool {
30 area.width > 0 && area.height > 0
31}
32
33pub(crate) fn draw_popup(f: &mut Frame, app: &mut App, popup: &crate::app::Popup) {
34 let icons = app.icons;
35 let area = f.area();
36 let popup_area = popup_rect(popup, area);
37 f.render_widget(Clear, popup_area);
38 let block = Block::default()
39 .borders(Borders::ALL)
40 .border_type(BorderType::Rounded)
41 .border_style(Style::default().fg(app.theme.accent))
42 .style(Style::default().bg(app.theme.bg))
43 .title(Span::styled(
44 match popup {
45 crate::app::Popup::AddTask => format!(" {} Add Task ", icons.plus),
46 crate::app::Popup::EditTask(_) => format!(" {} Edit Task ", icons.edit),
47 crate::app::Popup::ConfirmDelete(_) => format!(" {} Confirm Delete ", icons.delete),
48 crate::app::Popup::EmptyQueueChoice => format!(" {} All Tasks Done ", icons.check),
49 crate::app::Popup::AddSubtask(_) => format!(" {} Add Subtask ", icons.plus),
50 crate::app::Popup::BulkConfirm(_) => format!(" {} Bulk Action ", icons.tasks),
51 },
52 Style::default()
53 .fg(app.theme.accent)
54 .add_modifier(Modifier::BOLD),
55 ));
56 let body = block.inner(popup_area);
57 f.render_widget(block, popup_area);
58
59 match popup {
60 crate::app::Popup::AddTask | crate::app::Popup::EditTask(_) => {
61 let theme = &app.theme;
62 let chunks = popup_body_layout(body, PopupLayout::Form);
63 if chunks.is_empty() {
64 return;
65 }
66 let form_area = chunks[0];
67 let (left_area, right_area) = if matches!(app.input_field, InputField::DueDate) {
68 let cols = Layout::default()
69 .direction(Direction::Horizontal)
70 .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
71 .split(form_area);
72 (cols[0], Some(cols[1]))
73 } else {
74 (form_area, None)
75 };
76
77 let cursor = |active: bool, text: &str| -> String {
78 if active {
79 if text.is_empty() {
80 "|".to_string()
81 } else {
82 format!("{}|", text)
83 }
84 } else if text.is_empty() {
85 "—".to_string()
86 } else {
87 text.to_string()
88 }
89 };
90 let due_display = if matches!(app.input_field, InputField::DueDate) {
91 cursor(true, &app.input_due_date)
92 } else if app.input_due_date.is_empty() {
93 "—".to_string()
94 } else {
95 app.input_due_date.clone()
96 };
97 let tags_display = cursor(matches!(app.input_field, InputField::Tags), &app.input_tags);
98 let value_max = left_area.width.saturating_sub(22) as usize;
99 let p = Paragraph::new(vec![
100 popup_field_line(
101 theme,
102 "Title",
103 cursor(
104 matches!(app.input_field, InputField::Title),
105 &truncate_field(&app.input_buffer, value_max),
106 ),
107 matches!(app.input_field, InputField::Title),
108 value_max,
109 ),
110 popup_field_line(
111 theme,
112 "Estimate (min)",
113 if matches!(app.input_field, InputField::Estimate) {
114 format!("{}|", app.input_number)
115 } else {
116 app.input_number.to_string()
117 },
118 matches!(app.input_field, InputField::Estimate),
119 value_max,
120 ),
121 popup_field_line(
122 theme,
123 "Priority",
124 app.input_priority.label().to_string(),
125 matches!(app.input_field, InputField::Priority),
126 value_max,
127 ),
128 popup_field_line(
129 theme,
130 "Due (YYYY-MM-DD)",
131 truncate_field(&due_display, value_max),
132 matches!(app.input_field, InputField::DueDate),
133 value_max,
134 ),
135 popup_field_line(
136 theme,
137 "Tags (comma-sep)",
138 truncate_field(&tags_display, value_max),
139 matches!(app.input_field, InputField::Tags),
140 value_max,
141 ),
142 ]);
143 f.render_widget(p, left_area);
144
145 if let Some(r) = right_area {
146 let d = app.calendar_date;
147 if let Ok(time_date) = time::Date::from_calendar_date(
148 d.year(),
149 time::Month::try_from(d.month() as u8).unwrap_or(time::Month::January),
150 d.day() as u8,
151 ) {
152 let mut store = ratatui::widgets::calendar::CalendarEventStore::default();
153 store.add(
154 time_date,
155 Style::default()
156 .bg(theme.accent)
157 .fg(theme.on_accent)
158 .add_modifier(Modifier::BOLD),
159 );
160
161 let monthly = ratatui::widgets::calendar::Monthly::new(time_date, store)
162 .show_month_header(
163 Style::default()
164 .fg(theme.accent)
165 .add_modifier(Modifier::BOLD),
166 )
167 .show_weekdays_header(Style::default().fg(theme.dim));
168 f.render_widget(monthly, r);
169 }
170 }
171 let hint = if matches!(app.input_field, InputField::DueDate) {
172 "←→ day · ↑↓ week · Tab field · Enter save · Esc cancel"
173 } else {
174 "Tab field · Enter save · Esc cancel"
175 };
176 if chunks.len() > 1 {
177 draw_popup_hint(f, chunks[1], theme, hint);
178 }
179 }
180 crate::app::Popup::ConfirmDelete(id) => {
181 let theme = &app.theme;
182 let chunks = popup_body_layout(body, PopupLayout::Message);
183 if chunks.is_empty() {
184 return;
185 }
186 let title = app
187 .data
188 .tasks
189 .iter()
190 .find(|t| t.id == *id)
191 .map(|t| t.title.as_str())
192 .unwrap_or("Unknown task");
193 let p = Paragraph::new(vec![
194 Line::from(Span::styled(
195 format!("Delete \"{}\"?", title),
196 Style::default().fg(theme.text).add_modifier(Modifier::BOLD),
197 )),
198 Line::from(""),
199 Line::from(Span::styled(
200 "Press y or Enter to confirm, n or Esc to cancel.",
201 Style::default().fg(theme.dim),
202 )),
203 Line::from(Span::styled(
204 "This cannot be undone.",
205 Style::default().fg(theme.error),
206 )),
207 ]);
208 f.render_widget(p, chunks[0]);
209 }
210 crate::app::Popup::EmptyQueueChoice => {
211 let theme = &app.theme;
212 let chunks = popup_body_layout(body, PopupLayout::Message);
213 if chunks.is_empty() {
214 return;
215 }
216 let p = Paragraph::new(vec![
217 Line::from(Span::styled(
218 "You've completed every task in your queue.",
219 Style::default().fg(theme.text),
220 )),
221 Line::from(""),
222 Line::from(Span::styled(
223 "[Enter] Continue free focus (log general sessions)",
224 Style::default().fg(theme.success),
225 )),
226 Line::from(Span::styled(
227 "[p] Pause the timer",
228 Style::default().fg(theme.warning),
229 )),
230 Line::from(Span::styled(
231 "[a] Add another task",
232 Style::default().fg(theme.accent),
233 )),
234 Line::from(Span::styled(
235 "[Esc] Dismiss",
236 Style::default().fg(theme.dim),
237 )),
238 ]);
239 f.render_widget(p, chunks[0]);
240 }
241 crate::app::Popup::AddSubtask(id) => {
242 draw_add_subtask_popup(f, app, body, *id);
243 }
244 crate::app::Popup::BulkConfirm(action) => {
245 let theme = &app.theme;
246 let chunks = popup_body_layout(body, PopupLayout::Message);
247 if chunks.is_empty() {
248 return;
249 }
250 let (title, detail, accent) = match action {
251 crate::app::BulkAction::MarkDone => (
252 "Mark selected tasks as done?",
253 format!("{} task(s) selected.", app.bulk_selected.len()),
254 theme.success,
255 ),
256 crate::app::BulkAction::Delete => (
257 "Delete selected tasks?",
258 format!(
259 "{} task(s) will be removed permanently.",
260 app.bulk_selected.len()
261 ),
262 theme.error,
263 ),
264 };
265 let p = Paragraph::new(vec![
266 Line::from(Span::styled(
267 title,
268 Style::default().fg(accent).add_modifier(Modifier::BOLD),
269 )),
270 Line::from(""),
271 Line::from(Span::styled(detail, Style::default().fg(theme.text))),
272 Line::from(""),
273 Line::from(Span::styled(
274 "[y] confirm [n/Esc] cancel",
275 Style::default().fg(theme.dim),
276 )),
277 ]);
278 f.render_widget(p, chunks[0]);
279 }
280 }
281}
282
283enum PopupLayout {
284 Form,
285 Subtask,
286 Message,
287}
288
289fn popup_body_layout(body: Rect, kind: PopupLayout) -> Vec<Rect> {
290 if !rect_ok(body) {
291 return vec![];
292 }
293 let margin = u16::from(body.height >= 8 && body.width >= 8);
294 let constraints = match kind {
295 PopupLayout::Form => vec![Constraint::Min(4), Constraint::Length(1)],
296 PopupLayout::Subtask => vec![
297 Constraint::Length(1),
298 Constraint::Length(3),
299 Constraint::Length(1),
300 ],
301 PopupLayout::Message => vec![Constraint::Min(1)],
302 };
303 Layout::default()
304 .direction(Direction::Vertical)
305 .margin(margin)
306 .constraints(constraints)
307 .split(body)
308 .to_vec()
309}
310
311fn task_title(app: &App, id: u64) -> String {
312 app.data
313 .tasks
314 .iter()
315 .find(|t| t.id == id)
316 .map(|t| t.title.clone())
317 .unwrap_or_else(|| "Unknown task".into())
318}
319
320fn draw_add_subtask_popup(f: &mut Frame, app: &App, body: Rect, task_id: u64) {
321 let theme = &app.theme;
322 let chunks = popup_body_layout(body, PopupLayout::Subtask);
323 if chunks.len() < 3 {
324 return;
325 }
326 let parent = task_title(app, task_id);
327 let existing = app
328 .data
329 .tasks
330 .iter()
331 .find(|t| t.id == task_id)
332 .map(|t| t.subtasks.len())
333 .unwrap_or(0);
334 f.render_widget(
335 Paragraph::new(Line::from(vec![
336 Span::styled("Task ", Style::default().fg(theme.dim)),
337 Span::styled(
338 super::widgets::truncate(&parent, chunks[0].width.saturating_sub(8) as usize),
339 Style::default().fg(theme.text).add_modifier(Modifier::BOLD),
340 ),
341 Span::styled(
342 format!(
343 " ({existing} subtask{})",
344 if existing == 1 { "" } else { "s" }
345 ),
346 Style::default().fg(theme.dim),
347 ),
348 ])),
349 chunks[0],
350 );
351 draw_singleline_editor(f, chunks[1], theme, &app.input_buffer);
352 draw_action_footer(
353 f,
354 chunks[2],
355 theme,
356 &[
357 ("Enter", "add", theme.success),
358 ("q", "done", theme.warning),
359 ],
360 );
361}
362
363fn draw_action_footer(
364 f: &mut Frame,
365 area: Rect,
366 theme: &crate::app::Theme,
367 actions: &[(&str, &str, ratatui::style::Color)],
368) {
369 if area.height == 0 || area.width == 0 {
370 return;
371 }
372 let sep = Block::default()
373 .borders(Borders::TOP)
374 .border_style(Style::default().fg(theme.panel_border))
375 .style(Style::default().bg(theme.bg));
376 let inner = sep.inner(area);
377 f.render_widget(sep, area);
378
379 let mut spans: Vec<Span> = Vec::new();
380 for (i, (key, label, color)) in actions.iter().enumerate() {
381 if i > 0 {
382 spans.push(Span::raw(" "));
383 }
384 spans.push(Span::styled(
385 format!(" {key} "),
386 Style::default().fg(*color).add_modifier(Modifier::BOLD),
387 ));
388 spans.push(Span::styled(
389 format!(" {label}"),
390 Style::default().fg(theme.dim),
391 ));
392 }
393 f.render_widget(Paragraph::new(Line::from(spans)), inner);
394}
395
396fn draw_singleline_editor(f: &mut Frame, area: Rect, theme: &crate::app::Theme, text: &str) {
397 if !rect_ok(area) {
398 return;
399 }
400 let input_block = Block::default()
401 .title(Span::styled(" Title ", Style::default().fg(theme.dim)))
402 .borders(Borders::ALL)
403 .border_type(BorderType::Rounded)
404 .border_style(Style::default().fg(theme.accent))
405 .style(Style::default().bg(theme.panel).fg(theme.text));
406 let inner = input_block.inner(area);
407 f.render_widget(input_block, area);
408 if !rect_ok(inner) {
409 return;
410 }
411 let max_w = inner.width.saturating_sub(2) as usize;
412 let content = if text.is_empty() {
413 Line::from(vec![
414 Span::styled("Subtask title…", Style::default().fg(theme.dim)),
415 Span::styled("|", Style::default().fg(theme.accent)),
416 ])
417 } else {
418 Line::from(Span::styled(
419 format_input_line(text, max_w),
420 Style::default().fg(theme.text),
421 ))
422 };
423 f.render_widget(Paragraph::new(content).alignment(Alignment::Left), inner);
424}
425
426fn draw_popup_hint(f: &mut Frame, area: Rect, theme: &crate::app::Theme, hint: &str) {
427 if area.height == 0 {
428 return;
429 }
430 let max = area.width.saturating_sub(2) as usize;
431 f.render_widget(
432 Paragraph::new(Span::styled(
433 super::widgets::truncate(hint, max),
434 Style::default().fg(theme.dim),
435 )),
436 area,
437 );
438}
439
440fn format_input_line(text: &str, max_w: usize) -> String {
441 if text.is_empty() {
442 return "|".to_string();
443 }
444 let max_text = max_w.saturating_sub(1);
445 format!("{}|", super::widgets::truncate(text, max_text))
446}
447
448fn truncate_field(s: &str, max: usize) -> String {
449 super::widgets::truncate(s, max)
450}
451
452pub(crate) fn popup_field_line(
453 theme: &crate::app::Theme,
454 label: &str,
455 value: String,
456 active: bool,
457 value_max: usize,
458) -> Line<'static> {
459 let label_style = if active {
460 Style::default()
461 .fg(theme.accent)
462 .add_modifier(Modifier::BOLD)
463 } else {
464 Style::default().fg(theme.dim)
465 };
466 let value_style = if active {
467 Style::default().fg(theme.text).add_modifier(Modifier::BOLD)
468 } else {
469 Style::default().fg(theme.text)
470 };
471 Line::from(vec![
472 Span::styled(format!("{:<20} ", label), label_style),
473 Span::styled(truncate_field(&value, value_max), value_style),
474 ])
475}
476
477pub(crate) fn draw_input(f: &mut Frame, app: &App, area: Rect) {
478 let theme = &app.theme;
479 let chunks = Layout::default()
480 .direction(Direction::Vertical)
481 .margin(1)
482 .constraints([Constraint::Length(3), Constraint::Min(1)])
483 .split(area);
484 let block = Block::default()
485 .borders(Borders::ALL)
486 .border_type(BorderType::Rounded)
487 .border_style(Style::default().fg(theme.accent))
488 .title(Span::styled(" Input ", Style::default().fg(theme.accent)));
489 let p = Paragraph::new(format!("{}|", app.input_buffer))
490 .style(Style::default().fg(theme.text))
491 .block(block);
492 f.render_widget(p, chunks[0]);
493}