Skip to main content

void/ui/
popups.rs

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}