Skip to main content

mach/
ui.rs

1//! All drawing: a Categories panel on the left, a Tasks panel on the
2//! right, and a status line along the bottom. Each panel is a rounded
3//! block whose border lights up when it holds focus.
4
5use ratatui::Frame;
6use ratatui::layout::{Constraint, Layout, Margin, Rect};
7use ratatui::style::{Color, Modifier, Style};
8use ratatui::text::Text;
9use ratatui::text::{Line, Span};
10use ratatui::widgets::{
11    Block, BorderType, Cell, Clear, Gauge, List, ListItem, Padding, Paragraph, Row, Scrollbar,
12    ScrollbarOrientation, ScrollbarState, Table,
13};
14use ratatui_image::{Resize, StatefulImage};
15use unicode_segmentation::UnicodeSegmentation;
16use unicode_width::UnicodeWidthStr;
17
18use crate::app::{
19    App, Focus, HoverPaint, HoverTarget, MessageKind, Mode, SETTINGS_ITEMS, UpdateActivity,
20};
21use crate::banner;
22use crate::due;
23use crate::form::Field;
24use crate::model::{LabelColor, labels_for_task};
25use crate::theme::Theme;
26
27/// Outer width of the sidebar, borders and padding included.
28pub const SIDEBAR_WIDTH: u16 = 26;
29/// `[ ]` / `[✓]` in the task list and description subtasks.
30pub const DONE_MARK_WIDTH: u16 = 3;
31/// Right column shorter than this → no bottom preview (list only).
32const PREVIEW_SPLIT_MIN: u16 = 16;
33/// Minimum height of the list half when the preview is below.
34const LIST_MIN: u16 = 6;
35/// Minimum height of the preview / docked editor half (bottom layout).
36const PREVIEW_MIN: u16 = 8;
37/// Minimum list width when the preview sits to the right.
38const LIST_WIDTH_MIN: u16 = 24;
39/// Minimum preview width when docked on the right.
40const PREVIEW_WIDTH_MIN: u16 = 28;
41/// Whole right column narrower than this → no side preview.
42const PREVIEW_SIDE_MIN: u16 = LIST_WIDTH_MIN + PREVIEW_WIDTH_MIN + 1;
43/// One full color cycle plus the Manage labels action before scrolling.
44/// Category and label dropdowns share this bounded viewport height.
45const TASK_FORM_PICKER_MAX_ROWS: usize = LabelColor::ALL.len() + 1;
46const LABEL_EDITOR_GAP_ROWS: u16 = 1;
47pub const MIN_TERMINAL_WIDTH: u16 = 60;
48pub const MIN_TERMINAL_HEIGHT: u16 = 16;
49
50pub fn draw(f: &mut Frame, app: &mut App) {
51    let area = f.area();
52    // Every frame owns its hit targets. Hidden overlays and undersized
53    // terminals must never retain clickable geometry from an older frame.
54    app.areas.reset();
55    if let Some(form) = &mut app.form {
56        form.areas = crate::form::FieldAreas::default();
57        form.form_area = Rect::ZERO;
58        form.description_menu_area = None;
59        form.image_hits.clear();
60        form.set_category_picker_layout(Rect::ZERO, 0);
61        form.set_label_picker_layout(Rect::ZERO, 0);
62        if let Some(picker) = &mut form.picker {
63            picker.layout = crate::duepicker::PickerLayout::default();
64        }
65    }
66    if let Some(form) = &mut app.category_form {
67        form.form_area = Rect::ZERO;
68        form.name_area = Rect::ZERO;
69        form.description_area = Rect::ZERO;
70        form.description_menu_area = None;
71    }
72
73    if area.width < MIN_TERMINAL_WIDTH || area.height < MIN_TERMINAL_HEIGHT {
74        let p = Paragraph::new(format!(
75            "too small · need {MIN_TERMINAL_WIDTH}×{MIN_TERMINAL_HEIGHT}"
76        ))
77        .centered();
78        f.render_widget(p, area);
79        app.finish_hover_frame();
80        return;
81    }
82
83    let theme = app.theme();
84    let labels_layout = (app.mode == Mode::Labels).then(|| label_manager_layout(app, area));
85    let preview_image_occlusion = labels_layout.as_ref().map(|layout| layout.rect);
86    let [content, status] =
87        Layout::vertical([Constraint::Min(3), Constraint::Length(3)]).areas(area);
88    // The panels sit against each other: two borders is already a
89    // divider, a gap on top of that is just slack.
90    // A column of air between the panels keeps each one's focus colour
91    // unambiguous.
92    let [sidebar, right] =
93        Layout::horizontal([Constraint::Length(SIDEBAR_WIDTH), Constraint::Min(20)])
94            .spacing(1)
95            .areas(content);
96
97    let mut modal_task_form = false;
98    draw_sidebar(f, app, &theme, sidebar);
99    if let Some((list, preview_rect)) =
100        split_tasks_and_preview(right, &app.settings.preview_position)
101    {
102        app.areas.preview = preview_rect;
103        draw_tasks(f, app, &theme, list);
104        match app.mode {
105            Mode::TaskForm => match docked_task_form_layout(preview_rect) {
106                Some(layout) => draw_task_form(f, app, &theme, preview_rect, layout),
107                None => {
108                    draw_task_preview(f, app, &theme, preview_rect, preview_image_occlusion);
109                    modal_task_form = true;
110                }
111            },
112            _ => draw_task_preview(f, app, &theme, preview_rect, preview_image_occlusion),
113        }
114    } else {
115        app.areas.preview = Rect::ZERO;
116        draw_tasks(f, app, &theme, right);
117        if app.mode == Mode::TaskForm {
118            modal_task_form = true;
119        }
120    }
121    draw_status(f, app, &theme, status);
122    // Palette floats above the status bar.
123    if app.mode == Mode::Slash {
124        draw_slash_palette(f, app, &theme, status);
125    }
126
127    match app.mode {
128        Mode::Help => draw_help(f, app, &theme, area),
129        Mode::Settings => draw_settings(f, app, &theme, area),
130        Mode::Labels => draw_labels(
131            f,
132            app,
133            &theme,
134            labels_layout.as_ref().expect("labels mode owns its layout"),
135        ),
136        Mode::Welcome => draw_welcome(f, app, &theme, area),
137        Mode::WhatsNew => draw_whats_new(f, &theme, area),
138        Mode::CategoryForm => draw_category_form(f, app, &theme, area),
139        Mode::TaskForm if modal_task_form => {
140            // Draw the fallback last, over the intact panels and task preview.
141            draw_task_form(f, app, &theme, area, TaskFormLayout::Modal);
142        }
143        Mode::TaskForm => {} // Already drawn in the task preview pane.
144        _ => {}
145    }
146    draw_hover(f, app, &theme);
147}
148
149/// Paint only the topmost semantic row under the pointer. Hit geometry is
150/// rebuilt from the final clipped layout on every frame, so hidden controls
151/// cannot retain hover state and modal chrome can occlude rows beneath it.
152fn draw_hover(f: &mut Frame, app: &mut App, theme: &Theme) {
153    let hit = app
154        .mouse_position()
155        .and_then(|position| app.areas.hover_hit_at(position));
156    if let Some(hit) = hit {
157        match hit.paint {
158            HoverPaint::Fill(rect) => paint_hover_background(f, rect, theme.hover()),
159            HoverPaint::Badge => f.buffer_mut().set_style(hit.hit, theme.label_hover()),
160            HoverPaint::Control => f.buffer_mut().set_style(hit.hit, theme.control_hover()),
161            HoverPaint::None => {}
162        }
163    }
164    app.finish_hover_frame();
165}
166
167fn paint_hover_background(f: &mut Frame, rect: Rect, style: Style) {
168    let buffer = f.buffer_mut();
169    let rect = buffer.area.intersection(rect);
170    for y in rect.top()..rect.bottom() {
171        for x in rect.left()..rect.right() {
172            let cell = &mut buffer[(x, y)];
173            if cell.bg == Color::Reset && !cell.modifier.contains(Modifier::REVERSED) {
174                cell.set_style(style);
175            }
176        }
177    }
178}
179
180/// Split the right column into task list + preview when there is room.
181/// `position` is `"bottom"` (default) or `"right"`. Falls back to bottom
182/// when a side-by-side split will not fit, then to no preview.
183fn split_tasks_and_preview(right: Rect, position: &str) -> Option<(Rect, Rect)> {
184    if position == "right"
185        && let Some(pair) = split_preview_right(right)
186    {
187        return Some(pair);
188    }
189    split_preview_bottom(right)
190}
191
192fn split_preview_bottom(right: Rect) -> Option<(Rect, Rect)> {
193    if right.height < PREVIEW_SPLIT_MIN {
194        return None;
195    }
196    let [list, preview] = Layout::vertical([
197        Constraint::Min(LIST_MIN),
198        Constraint::Length((right.height / 2).max(PREVIEW_MIN)),
199    ])
200    .spacing(0)
201    .areas(right);
202    if list.height < LIST_MIN || preview.height < PREVIEW_MIN {
203        return None;
204    }
205    Some((list, preview))
206}
207
208fn split_preview_right(right: Rect) -> Option<(Rect, Rect)> {
209    if right.width < PREVIEW_SIDE_MIN || right.height < PREVIEW_MIN {
210        return None;
211    }
212    let preview_w = (right.width / 2).max(PREVIEW_WIDTH_MIN);
213    let [list, preview] = Layout::horizontal([
214        Constraint::Min(LIST_WIDTH_MIN),
215        Constraint::Length(preview_w),
216    ])
217    .spacing(1)
218    .areas(right);
219    if list.width < LIST_WIDTH_MIN || preview.width < PREVIEW_WIDTH_MIN {
220        return None;
221    }
222    Some((list, preview))
223}
224
225// --------------------------------------------------------- task dialog
226
227const TASK_FORM_WIDE_CHROME: u16 = 9;
228const TASK_FORM_COMPACT_CHROME: u16 = 18;
229const TASK_FORM_MIN_DESCRIPTION_HEIGHT: u16 = 3;
230const TASK_FORM_WIDE_MIN_WIDTH: u16 = 56;
231
232#[derive(Clone, Copy, Debug, PartialEq, Eq)]
233enum TaskFormLayout {
234    DockedWide,
235    DockedCompact,
236    Modal,
237}
238
239impl TaskFormLayout {
240    fn is_docked(self) -> bool {
241        !matches!(self, Self::Modal)
242    }
243
244    fn is_compact(self) -> bool {
245        matches!(self, Self::DockedCompact)
246    }
247}
248
249fn docked_task_form_layout(area: Rect) -> Option<TaskFormLayout> {
250    if area.width >= TASK_FORM_WIDE_MIN_WIDTH
251        && area.height >= TASK_FORM_WIDE_CHROME + TASK_FORM_MIN_DESCRIPTION_HEIGHT
252    {
253        Some(TaskFormLayout::DockedWide)
254    } else if area.width >= PREVIEW_WIDTH_MIN
255        && area.height >= TASK_FORM_COMPACT_CHROME + TASK_FORM_MIN_DESCRIPTION_HEIGHT
256    {
257        Some(TaskFormLayout::DockedCompact)
258    } else {
259        None
260    }
261}
262
263/// Title, category/labels/due/flags metadata, then the description: a free stack of prose,
264/// to-dos and pictures with a `/` menu for making new ones.
265///
266/// Docked layouts fill the permanent task preview pane. The modal layout is
267/// centered over `area` when that pane cannot expose every field honestly.
268fn draw_task_form(f: &mut Frame, app: &mut App, theme: &Theme, area: Rect, layout: TaskFormLayout) {
269    let show_passive_hints = app.settings.show_passive_hints();
270    // Disjoint borrows: the form owns the fields, the store owns the
271    // decoded images.
272    let App {
273        form,
274        images: store,
275        areas,
276        ..
277    } = app;
278    let Some(form) = form.as_mut() else { return };
279
280    let rect = if layout.is_docked() {
281        area
282    } else {
283        let width = 92.min(area.width.saturating_sub(4));
284        let description_height = area
285            .height
286            .saturating_sub(TASK_FORM_WIDE_CHROME)
287            .clamp(TASK_FORM_MIN_DESCRIPTION_HEIGHT, 22);
288        centered(
289            area,
290            width,
291            (TASK_FORM_WIDE_CHROME + description_height).min(area.height),
292        )
293    };
294    form.form_area = rect;
295    areas.occlude_hover(rect);
296    let h_pad = if layout.is_docked() { 1 } else { 2 };
297    let block = Block::bordered()
298        .border_type(BorderType::Thick)
299        .border_style(theme.accent_text())
300        .title(Span::styled(
301            format!(" {} ", form.title_text()),
302            theme.accent_text().bold(),
303        ))
304        .padding(Padding::new(h_pad, h_pad, 0, 0));
305    let inner = block.inner(rect);
306    f.render_widget(Clear, rect);
307    f.render_widget(block, rect);
308
309    let (title_box, category_box, labels_box, due_box, importance_box, description_box, hint) =
310        if layout.is_compact() {
311            let [title, category, labels, due, importance, description, hint] = Layout::vertical([
312                Constraint::Length(3),
313                Constraint::Length(3),
314                Constraint::Length(3),
315                Constraint::Length(3),
316                Constraint::Length(3),
317                Constraint::Min(TASK_FORM_MIN_DESCRIPTION_HEIGHT),
318                Constraint::Length(1),
319            ])
320            .areas(inner);
321            (title, category, labels, due, importance, description, hint)
322        } else {
323            let [title, metadata, description, hint] = Layout::vertical([
324                Constraint::Length(3),
325                Constraint::Length(3),
326                Constraint::Min(TASK_FORM_MIN_DESCRIPTION_HEIGHT),
327                Constraint::Length(1),
328            ])
329            .areas(inner);
330            // Category and labels share the flexible space; Due fits a
331            // formatted date+time and Flags fits ⚑⚑⚑.
332            let [category, labels, due, importance] = Layout::horizontal([
333                Constraint::Fill(1),
334                Constraint::Fill(1),
335                Constraint::Length(20),
336                Constraint::Length(9),
337            ])
338            .spacing(1)
339            .areas(metadata);
340            (title, category, labels, due, importance, description, hint)
341        };
342
343    // --- title ----------------------------------------------------------
344    let focused = form.field == Field::Title;
345    let box_inner = render_field_box(f, field_block("Title", focused, None, theme), title_box);
346    form.areas.title = box_inner;
347    draw_text_input(
348        f,
349        &mut form.title,
350        box_inner,
351        "what needs doing?",
352        focused,
353        theme,
354    );
355
356    // --- category -------------------------------------------------------
357    let focused = form.field == Field::Category;
358    let box_inner = render_field_box(
359        f,
360        field_block("Category", focused, None, theme),
361        category_box,
362    );
363    // Store the outer box so the dropdown matches the field width and its
364    // border remains clickable, like the other picker-backed fields.
365    form.areas.category = category_box;
366    let row_width = box_inner.width as usize;
367    let category = truncate(form.category_label(), row_width.saturating_sub(2));
368    let padding = " ".repeat(row_width.saturating_sub(category.width()).saturating_sub(1));
369    let indicator = if form.category_picker_open() {
370        "▼"
371    } else {
372        "▶"
373    };
374    f.render_widget(
375        Paragraph::new(Line::from(vec![
376            Span::raw(category),
377            Span::raw(padding),
378            Span::raw(indicator),
379        ])),
380        box_inner,
381    );
382
383    // --- labels ---------------------------------------------------------
384    // The field is a summary; Enter opens the complete bounded picker.
385    let focused = form.field == Field::Labels;
386    let box_inner = render_field_box(f, field_block("Labels", focused, None, theme), labels_box);
387    form.areas.labels = labels_box;
388    let labels = form
389        .selected_labels()
390        .into_iter()
391        .map(|(name, color)| LabelToken::new(name, color))
392        .collect::<Vec<_>>();
393    if labels.is_empty() {
394        render_or_placeholder(f, box_inner, "", "↵ choose", theme);
395    } else {
396        let shown = compact_badge_tokens(&labels, box_inner.width as usize);
397        f.render_widget(
398            Paragraph::new(label_badges_line(&shown, theme, false)),
399            box_inner,
400        );
401    }
402
403    // --- due -------------------------------------------------------------
404    // Picker-only: show the value, no text cursor (Enter / click opens UI).
405    // Store the outer box so the calendar left-aligns with the Due border.
406    let focused = form.field == Field::Due;
407    let box_inner = render_field_box(f, field_block("Due", focused, None, theme), due_box);
408    form.areas.due = due_box;
409    let view = form.due.visible(box_inner.width as usize);
410    render_or_placeholder(f, box_inner, &view.text, "↵ Enter", theme);
411
412    // --- importance ---------------------------------------------------------
413    let focused = form.field == Field::Importance;
414    let box_inner = render_field_box(
415        f,
416        field_block("Flags", focused, None, theme),
417        importance_box,
418    );
419    form.areas.importance = box_inner;
420    let marks = crate::model::importance_marks(form.importance);
421    if marks.is_empty() {
422        render_or_placeholder(f, box_inner, "", "→", theme);
423    } else {
424        f.render_widget(
425            Paragraph::new(Line::styled(marks, Style::new().fg(theme.error_color()))),
426            box_inner,
427        );
428    }
429
430    // --- description --------------------------------------------------------------
431    let focused = form.field == Field::Description;
432    let (done, total) = form.description.progress();
433    let progress = (total > 0).then(|| format!("{done}/{total}"));
434    let box_inner = render_field_box(
435        f,
436        field_block("Description", focused, progress, theme),
437        description_box,
438    );
439    form.areas.description = box_inner;
440    let overlay = f.area();
441    let image_occlusion = task_form_image_occlusion(form, overlay);
442    let description_bottom = draw_description(
443        f,
444        form,
445        store,
446        theme,
447        box_inner,
448        DescriptionRenderOptions {
449            focused,
450            show_empty_hint: show_passive_hints,
451            external_occlusion: image_occlusion,
452        },
453    );
454    form.areas.description_bottom = description_bottom.unwrap_or_default();
455    areas.hover_control(
456        HoverTarget::TaskDescriptionBottom,
457        form.areas.description_bottom,
458    );
459    register_task_description_hover(areas, form);
460    scrollbar(
461        f,
462        theme,
463        description_box,
464        form.description.content_height(),
465        box_inner.height as usize,
466        form.description.scroll(),
467        focused,
468    );
469
470    // --- error or key hints ---------------------------------------------
471    let footer = match &form.error {
472        Some(error) => Line::styled(
473            truncate(error, hint.width as usize),
474            Style::new()
475                .fg(theme.error_color())
476                .add_modifier(Modifier::BOLD),
477        ),
478        None => Line::styled(
479            match (show_passive_hints, layout) {
480                (true, TaskFormLayout::DockedWide) => {
481                    "/ commands · Ctrl+Z undo · Ctrl+S save · Esc list"
482                }
483                (true, TaskFormLayout::DockedCompact) => "/ · Ctrl+S save · Esc list",
484                (true, TaskFormLayout::Modal) => {
485                    "/ commands · Ctrl+Z undo · Ctrl+S save · Esc cancel"
486                }
487                (false, TaskFormLayout::DockedWide | TaskFormLayout::DockedCompact) => {
488                    "Ctrl+S save · Esc list"
489                }
490                (false, TaskFormLayout::Modal) => "Ctrl+S save · Esc cancel",
491            },
492            Style::new().fg(theme.muted_color()),
493        ),
494    };
495    f.render_widget(Paragraph::new(footer), hint);
496
497    // Drawn last so it sits over the description box below it.
498    // Picker/image lightbox use the full frame so they are not clipped.
499    if let Some(picker) = form.picker.as_mut() {
500        draw_due_picker(f, theme, picker, form.areas.due, overlay, areas);
501    }
502    if form.category_picker_open() {
503        draw_category_picker(f, theme, form, form.areas.category, overlay, areas);
504    }
505    if form.label_picker_open() {
506        draw_label_picker(f, theme, form, form.areas.labels, overlay, areas);
507    }
508
509    // Preview the picture the cursor is on, or the first one otherwise.
510    if form.preview
511        && let Some(path) = form
512            .description
513            .selected_image()
514            .or_else(|| form.description.images().first().cloned())
515    {
516        areas.occlude_hover(overlay);
517        draw_image_preview(f, store, form, theme, &path, overlay);
518    }
519}
520
521/// Read-only view of the selected task in the permanent preview pane.
522fn draw_task_preview(
523    f: &mut Frame,
524    app: &mut App,
525    theme: &Theme,
526    area: Rect,
527    image_occlusion: Option<Rect>,
528) {
529    let focused = false;
530    let show_passive_hints = app.settings.show_passive_hints();
531    let block = panel("Task preview", focused, theme);
532    let inner = block.inner(area);
533    f.render_widget(block, area);
534    if inner.height == 0 || inner.width == 0 {
535        return;
536    }
537
538    let Some(task) = app.selected_task() else {
539        app.invalidate_preview();
540        let style = Style::new().fg(theme.muted_color());
541        draw_box(f, inner, "Select a task · Enter to edit", style);
542        return;
543    };
544
545    let todo = crate::model::todo_progress(task);
546    let labels = labels_for_task(task, &app.labels)
547        .map(LabelToken::from)
548        .collect::<Vec<_>>();
549    let title = task.title.clone();
550    let done = task.done;
551    let due_s = due::display(&task.due, &app.settings.date_format);
552    let importance = task.importance;
553    let description_empty = task.description.is_empty();
554
555    if !description_empty {
556        app.ensure_preview();
557    }
558
559    let flags = crate::model::importance_marks(importance);
560    let mut meta = String::new();
561    if !due_s.is_empty() {
562        meta.push_str(&due_s);
563    }
564    if !flags.is_empty() {
565        if !meta.is_empty() {
566            meta.push_str("  ");
567        }
568        meta.push_str(&flags);
569    }
570    if let Some((d, t)) = todo {
571        if !meta.is_empty() {
572            meta.push_str("  ");
573        }
574        meta.push_str(&format!("{d}/{t}"));
575    }
576
577    let title_style = if done {
578        Style::new()
579            .fg(theme.muted_color())
580            .add_modifier(Modifier::CROSSED_OUT | Modifier::BOLD)
581    } else {
582        Style::new().add_modifier(Modifier::BOLD)
583    };
584
585    let label_lines = wrapped_label_badges(&labels, inner.width as usize, theme, done);
586    let label_height = u16::try_from(label_lines.len()).unwrap_or(u16::MAX);
587    let meta_height = u16::from(!meta.is_empty());
588    let [title_row, meta_row, labels_area, description_area] = Layout::vertical([
589        Constraint::Length(1),
590        Constraint::Length(meta_height),
591        Constraint::Length(label_height),
592        Constraint::Min(0),
593    ])
594    .areas(inner);
595    app.areas.preview_description = description_area;
596
597    f.render_widget(
598        Paragraph::new(Line::styled(
599            truncate(&title, title_row.width as usize),
600            title_style,
601        )),
602        title_row,
603    );
604    if meta_height > 0 {
605        f.render_widget(
606            Paragraph::new(Line::styled(
607                truncate(&meta, meta_row.width as usize),
608                Style::new().fg(theme.muted_color()),
609            )),
610            meta_row,
611        );
612    }
613    if label_height > 0 {
614        f.render_widget(Paragraph::new(label_lines), labels_area);
615    }
616
617    if description_area.height == 0 {
618        return;
619    }
620    if description_empty {
621        if show_passive_hints {
622            f.render_widget(
623                Paragraph::new(Line::styled(
624                    "Enter to edit",
625                    Style::new().fg(theme.muted_color()),
626                )),
627                description_area,
628            );
629        }
630        return;
631    }
632
633    let App {
634        images: store,
635        preview_form,
636        areas,
637        ..
638    } = app;
639    if let Some(paint) = preview_form.as_mut() {
640        areas.preview_bottom = draw_description(
641            f,
642            paint,
643            store,
644            theme,
645            description_area,
646            DescriptionRenderOptions {
647                focused: false,
648                show_empty_hint: false,
649                external_occlusion: image_occlusion,
650            },
651        )
652        .unwrap_or_default();
653        areas.hover_control(HoverTarget::PreviewBottom, areas.preview_bottom);
654    }
655}
656
657/// One field of a dialog: a rounded box with its name on the border.
658fn field_block<'a>(
659    label: &'a str,
660    focused: bool,
661    note: Option<String>,
662    theme: &Theme,
663) -> Block<'a> {
664    // Thick glyphs (┃/━) — terminal bold barely changes box lines.
665    let (border, label_style) = if focused {
666        (theme.accent_text(), theme.accent_text().bold())
667    } else {
668        (
669            Style::new().fg(theme.muted_color()),
670            Style::new()
671                .fg(theme.muted_color())
672                .add_modifier(Modifier::BOLD),
673        )
674    };
675    let mut block = Block::bordered()
676        .border_type(BorderType::Thick)
677        .border_style(border)
678        .title(Span::styled(format!(" {label} "), label_style))
679        .padding(Padding::horizontal(1));
680    if let Some(note) = note {
681        block = block.title_top(
682            Line::styled(format!(" {note} "), Style::new().fg(theme.muted_color())).right_aligned(),
683        );
684    }
685    block
686}
687
688/// The category dialog: the same shape as a task's, with a name and a
689/// note about what the category is for.
690fn draw_category_form(f: &mut Frame, app: &mut App, theme: &Theme, area: Rect) {
691    let show_passive_hints = app.settings.show_passive_hints();
692    let App {
693        category_form,
694        areas,
695        ..
696    } = app;
697    let Some(form) = category_form else {
698        return;
699    };
700    // Borders (2), name box (3), hint (1).
701    const CHROME: u16 = 6;
702    let text_height = area.height.saturating_sub(CHROME).clamp(3, 12);
703    let width = 72.min(area.width.saturating_sub(4));
704    let rect = centered(area, width, (CHROME + text_height).min(area.height));
705    form.form_area = rect;
706    areas.occlude_hover(rect);
707
708    let block = Block::bordered()
709        .border_type(BorderType::Thick)
710        .border_style(theme.accent_text())
711        .title(Span::styled(
712            format!(" {} ", form.title_text()),
713            theme.accent_text().bold(),
714        ))
715        .padding(Padding::horizontal(1));
716    let inner = block.inner(rect);
717    f.render_widget(Clear, rect);
718    f.render_widget(block, rect);
719
720    let [name_box, text_box, hint] = Layout::vertical([
721        Constraint::Length(3),
722        Constraint::Length(text_height),
723        Constraint::Length(1),
724    ])
725    .areas(inner);
726
727    let focused = !form.on_description;
728    let box_inner = render_field_box(f, field_block("Name", focused, None, theme), name_box);
729    form.name_area = box_inner;
730    draw_text_input(
731        f,
732        &mut form.name,
733        box_inner,
734        "What to call it",
735        focused,
736        theme,
737    );
738
739    let focused = form.on_description;
740    let box_inner = render_field_box(
741        f,
742        field_block("Description", focused, None, theme),
743        text_box,
744    );
745    form.description_area = box_inner;
746    let (lines, cursor) = form
747        .description
748        .layout(box_inner.width as usize, box_inner.height);
749    if show_passive_hints && form.description.is_empty() && form.description.menu.is_none() {
750        render_or_placeholder(f, box_inner, "", "Press / for commands", theme);
751    }
752    for placed in lines {
753        if matches!(placed.block, crate::description::Painted::Text { .. }) {
754            draw_placed_text(f, theme, box_inner, &placed);
755        }
756    }
757    if let (true, Some((row, col))) = (focused, cursor) {
758        f.set_cursor_position((
759            box_inner.x.saturating_add(col),
760            box_inner.y.saturating_add(row),
761        ));
762    }
763    if focused {
764        form.description_menu_area = slash_menu_rect(&form.description, box_inner, cursor);
765        draw_slash_menu(f, &form.description, theme, box_inner, cursor);
766        if let Some(menu) = form.description_menu_area {
767            register_description_menu_hover(
768                areas,
769                menu,
770                form.description.menu_commands().len(),
771                HoverTarget::CategoryDescriptionCommand,
772            );
773        }
774    }
775    scrollbar(
776        f,
777        theme,
778        text_box,
779        form.description.content_height(),
780        box_inner.height as usize,
781        form.description.scroll(),
782        focused,
783    );
784
785    let footer = match &form.error {
786        Some(error) => Line::styled(
787            truncate(error, hint.width as usize),
788            Style::new()
789                .fg(theme.error_color())
790                .add_modifier(Modifier::BOLD),
791        ),
792        None => Line::styled(
793            if show_passive_hints {
794                "/ commands · Ctrl+Z undo · Ctrl+S save · Esc cancel"
795            } else {
796                "Ctrl+S save · Esc cancel"
797            },
798            Style::new().fg(theme.muted_color()),
799        ),
800    };
801    f.render_widget(Paragraph::new(footer), hint);
802}
803
804#[derive(Clone, Copy)]
805struct DescriptionRenderOptions {
806    focused: bool,
807    show_empty_hint: bool,
808    external_occlusion: Option<Rect>,
809}
810
811/// The stack of blocks, plus the `/` menu when it is open.
812fn draw_description(
813    f: &mut Frame,
814    form: &mut crate::form::TaskForm,
815    store: &mut crate::image::ImageStore,
816    theme: &Theme,
817    area: Rect,
818    options: DescriptionRenderOptions,
819) -> Option<Rect> {
820    let crate::form::TaskForm {
821        description,
822        description_scroll,
823        description_menu_area,
824        image_hits,
825        image_occlusions,
826        image_layout,
827        ..
828    } = form;
829    draw_block_editor(
830        f,
831        description,
832        store,
833        theme,
834        area,
835        options,
836        description_scroll,
837        description_menu_area,
838        image_hits,
839        image_occlusions,
840        image_layout,
841    )
842}
843
844fn register_task_description_hover(areas: &mut crate::app::Areas, form: &crate::form::TaskForm) {
845    if let Some(menu) = form.description_menu_area {
846        register_description_menu_hover(
847            areas,
848            menu,
849            form.description.menu_commands().len(),
850            HoverTarget::TaskDescriptionCommand,
851        );
852    }
853}
854
855fn register_description_menu_hover(
856    areas: &mut crate::app::Areas,
857    menu: Rect,
858    count: usize,
859    target: impl Fn(usize) -> HoverTarget,
860) {
861    areas.occlude_hover(menu);
862    let inner = menu.inner(Margin {
863        horizontal: 1,
864        vertical: 1,
865    });
866    for index in 0..count.min(inner.height as usize) {
867        areas.hover_fill(
868            target(index),
869            Rect {
870                y: inner
871                    .y
872                    .saturating_add(u16::try_from(index).unwrap_or(u16::MAX)),
873                height: 1,
874                ..inner
875            },
876        );
877    }
878}
879
880#[allow(clippy::too_many_arguments)]
881fn draw_block_editor(
882    f: &mut Frame,
883    editor: &mut crate::description::DescriptionEditor,
884    store: &mut crate::image::ImageStore,
885    theme: &Theme,
886    area: Rect,
887    options: DescriptionRenderOptions,
888    previous_scroll: &mut usize,
889    menu_area: &mut Option<Rect>,
890    image_hits: &mut Vec<(usize, Rect)>,
891    previous_image_occlusions: &mut Vec<Rect>,
892    previous_image_layout: &mut Vec<(std::path::PathBuf, u16, u16)>,
893) -> Option<Rect> {
894    if options.show_empty_hint && editor.is_empty() && editor.menu.is_none() {
895        render_or_placeholder(f, area, "", "Press / for commands", theme);
896    }
897    let (blocks, cursor) = editor.layout(area.width as usize, area.height);
898    let scroll = editor.scroll();
899    let image_layout: Vec<_> = blocks
900        .iter()
901        .filter_map(|placed| match &placed.block {
902            crate::description::Painted::Image(path) => Some((path.clone(), placed.y, placed.rows)),
903            crate::description::Painted::Text { .. } => None,
904        })
905        .collect();
906    let menu_rect = slash_menu_rect(editor, area, cursor);
907    *menu_area = menu_rect;
908    let bottom_control = (editor.menu.is_none())
909        .then(|| bottom_control_rect(editor, area))
910        .flatten();
911    let image_occlusions = [menu_rect, options.external_occlusion, bottom_control]
912        .into_iter()
913        .flatten()
914        .filter(|rect| rect.width > 0 && rect.height > 0 && rects_overlap(*rect, area))
915        .collect::<Vec<_>>();
916    // Graphics protocols ignore cell Clear. Drop placements when overlays,
917    // scrolling, or image geometry changes so the next get re-emits cleanly.
918    // Decoded pixels stay in RAM; only the terminal encoding is rebuilt.
919    if *previous_image_occlusions != image_occlusions
920        || *previous_scroll != scroll
921        || *previous_image_layout != image_layout
922    {
923        store.clear_cache();
924        f.render_widget(Clear, area);
925    }
926    *previous_image_occlusions = image_occlusions.clone();
927    *previous_scroll = scroll;
928    *previous_image_layout = image_layout;
929    // Only hide images an overlay actually covers. Graphics protocols cannot
930    // be "punched" cleanly, so an overlapping image becomes a compact marker.
931    image_hits.clear();
932    for placed in blocks {
933        match &placed.block {
934            crate::description::Painted::Image(path) => {
935                let row = Rect {
936                    y: area.y.saturating_add(placed.y),
937                    height: placed.rows,
938                    ..area
939                };
940                let covered = image_occlusions
941                    .iter()
942                    .any(|occlusion| rects_overlap(*occlusion, row));
943                // Frame + type label only while the description field owns focus
944                // and the cursor is on this picture — not when the dialog
945                // opens on Title with the cursor still sitting on line 0.
946                let show_frame = options.focused && placed.selected;
947                if covered {
948                    f.render_widget(Clear, row);
949                    let hit = letterbox_rect(row, 4, 3);
950                    draw_image_placeholder(f, theme, hit, show_frame);
951                    image_hits.push((placed.line, hit));
952                } else if let Some(hit) = draw_image(f, store, theme, path, row, show_frame) {
953                    image_hits.push((placed.line, hit));
954                }
955            }
956            crate::description::Painted::Text { .. } => {
957                draw_placed_text(f, theme, area, &placed);
958            }
959        }
960    }
961    if options.focused
962        && let Some((row, col)) = cursor
963    {
964        f.set_cursor_position((area.x.saturating_add(col), area.y.saturating_add(row)));
965    }
966
967    if let Some(button) = bottom_control {
968        draw_bottom_control(f, theme, button);
969    }
970    draw_slash_menu(f, editor, theme, area, cursor);
971    bottom_control
972}
973
974const BOTTOM_CONTROL_LABEL: &str = " Bottom ↓ ";
975
976fn bottom_control_rect(editor: &crate::description::DescriptionEditor, area: Rect) -> Option<Rect> {
977    let visible = usize::from(area.height);
978    let max_scroll = editor.content_height().saturating_sub(visible);
979    if area.height == 0 || editor.scroll() >= max_scroll {
980        return None;
981    }
982    let width = u16::try_from(BOTTOM_CONTROL_LABEL.width()).unwrap_or(u16::MAX);
983    if area.width < width {
984        return None;
985    }
986    Some(centered(
987        Rect {
988            y: area.bottom() - 1,
989            height: 1,
990            ..area
991        },
992        width,
993        1,
994    ))
995}
996
997fn draw_bottom_control(f: &mut Frame, theme: &Theme, area: Rect) {
998    f.render_widget(
999        Paragraph::new(Line::styled(BOTTOM_CONTROL_LABEL, theme.control())),
1000        area,
1001    );
1002}
1003
1004fn rects_overlap(a: Rect, b: Rect) -> bool {
1005    a.x < b.right() && b.x < a.right() && a.y < b.bottom() && b.y < a.bottom()
1006}
1007
1008/// Screen rect of the open `/` dropdown, if any.
1009fn slash_menu_rect(
1010    description: &crate::description::DescriptionEditor,
1011    area: Rect,
1012    cursor: Option<(u16, u16)>,
1013) -> Option<Rect> {
1014    description.menu.as_ref()?;
1015    let commands = description.menu_commands();
1016    if commands.is_empty() {
1017        return None;
1018    }
1019    let width = 48.min(area.width);
1020    let height = u16::try_from(commands.len())
1021        .unwrap_or(u16::MAX)
1022        .saturating_add(2);
1023    let cursor_row = cursor.map(|(row, _)| row).unwrap_or(0);
1024    let below = area.y.saturating_add(cursor_row).saturating_add(1);
1025    let y = if area.bottom().saturating_sub(below) >= height {
1026        below
1027    } else {
1028        area.y.saturating_add(cursor_row).saturating_sub(height)
1029    };
1030    Some(Rect {
1031        x: area.x.saturating_add(
1032            cursor
1033                .map(|(_, col)| col)
1034                .unwrap_or(0)
1035                .min(area.width.saturating_sub(width)),
1036        ),
1037        y,
1038        width,
1039        height,
1040    })
1041}
1042
1043/// Soft-wrapped text / list / link block (description and category description).
1044fn draw_placed_text(f: &mut Frame, theme: &Theme, area: Rect, placed: &crate::description::Placed) {
1045    let crate::description::Painted::Text { rows, kind } = &placed.block else {
1046        return;
1047    };
1048    let indent = kind.indent();
1049    let max_rows = placed.rows as usize;
1050    for (i, wr) in rows.iter().enumerate().take(max_rows) {
1051        let y = area
1052            .y
1053            .saturating_add(placed.y)
1054            .saturating_add(u16::try_from(i).unwrap_or(u16::MAX));
1055        if y >= area.bottom() {
1056            break;
1057        }
1058        let row = Rect {
1059            x: area.x,
1060            y,
1061            width: area.width,
1062            height: 1,
1063        };
1064        let base = match kind {
1065            crate::description::TextKind::Link => Style::new()
1066                .fg(theme.accent)
1067                .add_modifier(Modifier::UNDERLINED),
1068            crate::description::TextKind::Todo { done: true } => Style::new()
1069                .fg(theme.muted_color())
1070                .add_modifier(Modifier::CROSSED_OUT),
1071            _ => Style::new(),
1072        };
1073        let description = line_with_selection(&wr.text, wr.sel, base, theme);
1074        let line = if i == 0 {
1075            match kind {
1076                crate::description::TextKind::Todo { done: true } => Line::from(
1077                    [
1078                        vec![Span::styled("[✓] ", Style::new().fg(theme.success_color()))],
1079                        description.spans,
1080                    ]
1081                    .concat(),
1082                ),
1083                crate::description::TextKind::Todo { done: false } => Line::from(
1084                    [
1085                        vec![Span::styled("[ ] ", Style::new().fg(theme.muted_color()))],
1086                        description.spans,
1087                    ]
1088                    .concat(),
1089                ),
1090                crate::description::TextKind::Bullet => Line::from(
1091                    [
1092                        vec![Span::styled("• ", Style::new().fg(theme.muted_color()))],
1093                        description.spans,
1094                    ]
1095                    .concat(),
1096                ),
1097                crate::description::TextKind::Number(n) => Line::from(
1098                    [
1099                        vec![Span::styled(
1100                            format!("{n}. "),
1101                            Style::new().fg(theme.muted_color()),
1102                        )],
1103                        description.spans,
1104                    ]
1105                    .concat(),
1106                ),
1107                crate::description::TextKind::Link => Line::from(
1108                    [
1109                        vec![Span::styled("↗ ", Style::new().fg(theme.muted_color()))],
1110                        description.spans,
1111                    ]
1112                    .concat(),
1113                ),
1114                crate::description::TextKind::Plain => description,
1115            }
1116        } else if indent > 0 {
1117            // Continuation rows line up under the text, past the prefix.
1118            Line::from([vec![Span::raw(" ".repeat(indent))], description.spans].concat())
1119        } else {
1120            description
1121        };
1122        f.render_widget(Paragraph::new(line), row);
1123    }
1124}
1125
1126/// Compact cell stand-in when the `/` menu covers an image slot.
1127fn draw_image_placeholder(f: &mut Frame, theme: &Theme, area: Rect, selected: bool) {
1128    if area.width == 0 || area.height == 0 {
1129        return;
1130    }
1131    let rect = Rect { height: 1, ..area };
1132    let style = if selected {
1133        theme.accent_text()
1134    } else {
1135        Style::new().fg(theme.muted_color())
1136    };
1137    f.render_widget(Clear, rect);
1138    f.render_widget(Paragraph::new(Line::styled(" [image] ", style)), rect);
1139}
1140
1141/// Full-size stand-in while a description/preview image is loading or failed.
1142enum ImageSlotKind<'a> {
1143    Loading,
1144    Broken { detail: &'a str },
1145}
1146
1147/// Letterbox a content box into `area` (after a 1-cell frame margin),
1148/// matching how real pictures are laid out. `aspect_w` / `aspect_h` are
1149/// relative; unknown images use 4×3.
1150fn letterbox_rect(area: Rect, aspect_w: u16, aspect_h: u16) -> Rect {
1151    if area.width < 3 || area.height < 3 {
1152        return area;
1153    }
1154    let inner = area.inner(Margin {
1155        horizontal: 1,
1156        vertical: 1,
1157    });
1158    let aw = u32::from(aspect_w.max(1));
1159    let ah = u32::from(aspect_h.max(1));
1160    let iw = u32::from(inner.width);
1161    let ih = u32::from(inner.height);
1162    let (pw, ph) = if iw * ah <= ih * aw {
1163        let pw = iw;
1164        let ph = (iw * ah / aw).clamp(1, ih);
1165        (pw as u16, ph as u16)
1166    } else {
1167        let ph = ih;
1168        let pw = (ih * aw / ah).clamp(1, iw);
1169        (pw as u16, ph as u16)
1170    };
1171    centered(inner, pw, ph)
1172}
1173
1174/// Outer area used by preview stand-ins (inner content + 1-cell frame).
1175fn preview_slot_area(inner: Rect) -> Rect {
1176    Rect {
1177        x: inner.x.saturating_sub(1),
1178        y: inner.y.saturating_sub(1),
1179        width: inner.width.saturating_add(2),
1180        height: inner.height.saturating_add(2),
1181    }
1182}
1183
1184fn draw_image_slot(
1185    f: &mut Frame,
1186    theme: &Theme,
1187    area: Rect,
1188    kind: ImageSlotKind<'_>,
1189    selected: bool,
1190) {
1191    if area.width < 3 || area.height < 2 {
1192        return;
1193    }
1194    let border = if selected {
1195        theme.accent_text()
1196    } else {
1197        Style::new().fg(theme.muted_color())
1198    };
1199    let (icon, title, title_style, detail) = match kind {
1200        ImageSlotKind::Loading => ("▢", "loading", Style::new().fg(theme.muted_color()), None),
1201        ImageSlotKind::Broken { detail } => (
1202            "✕",
1203            "broken image",
1204            Style::new().fg(theme.error_color()),
1205            Some(detail),
1206        ),
1207    };
1208    let block = Block::bordered()
1209        .border_type(BorderType::Rounded)
1210        .border_style(border);
1211    let inner = block.inner(area);
1212    f.render_widget(Clear, area);
1213    f.render_widget(block, area);
1214    if inner.width == 0 || inner.height == 0 {
1215        return;
1216    }
1217
1218    let mut lines: Vec<Line> = Vec::new();
1219    // Vertical centre: pad, icon, title, optional detail.
1220    let content_rows: u16 = if detail.is_some() { 3 } else { 2 };
1221    let pad = inner.height.saturating_sub(content_rows) / 2;
1222    for _ in 0..pad {
1223        lines.push(Line::raw(""));
1224    }
1225    lines.push(
1226        Line::from(Span::styled(
1227            truncate(icon, inner.width as usize),
1228            title_style,
1229        ))
1230        .centered(),
1231    );
1232    lines.push(
1233        Line::from(Span::styled(
1234            truncate(title, inner.width as usize),
1235            title_style,
1236        ))
1237        .centered(),
1238    );
1239    if let Some(d) = detail {
1240        let d = d.trim();
1241        if !d.is_empty() {
1242            lines.push(
1243                Line::from(Span::styled(
1244                    truncate(d, inner.width as usize),
1245                    Style::new().fg(theme.muted_color()),
1246                ))
1247                .centered(),
1248            );
1249        }
1250    }
1251    f.render_widget(Paragraph::new(lines), inner);
1252}
1253
1254/// The `/` menu floats under the line being typed on.
1255fn draw_slash_menu(
1256    f: &mut Frame,
1257    description: &crate::description::DescriptionEditor,
1258    theme: &Theme,
1259    area: Rect,
1260    cursor: Option<(u16, u16)>,
1261) {
1262    let Some(menu) = &description.menu else {
1263        return;
1264    };
1265    let Some(rect) = slash_menu_rect(description, area, cursor) else {
1266        return;
1267    };
1268    let commands = description.menu_commands();
1269    // Inner width of a bordered block (no horizontal padding).
1270    let row_width = rect.width.saturating_sub(2) as usize;
1271    let lines: Vec<Line> = commands
1272        .iter()
1273        .enumerate()
1274        .map(|(i, command)| {
1275            let selected = i == menu.index.min(commands.len() - 1);
1276            dropdown_row(
1277                theme,
1278                selected,
1279                &format!("{:<14}", command.label()),
1280                description.command_hint(*command),
1281                row_width,
1282            )
1283        })
1284        .collect();
1285    let block = Block::bordered()
1286        .border_type(BorderType::Thick)
1287        .border_style(theme.accent_text())
1288        .title(Span::styled(
1289            format!(" /{} ", menu.query),
1290            Style::new().fg(theme.muted_color()),
1291        ));
1292    f.render_widget(Clear, rect);
1293    f.render_widget(Paragraph::new(lines).block(block), rect);
1294}
1295
1296fn task_form_image_occlusion(form: &crate::form::TaskForm, area: Rect) -> Option<Rect> {
1297    if form.picker.is_some() {
1298        Some(due_picker_rect(form.areas.due, area))
1299    } else if form.category_picker_open() {
1300        let total_rows = form.category_choices().count();
1301        Some(list_picker_rect(total_rows, form.areas.category, area))
1302    } else if form.label_picker_open() {
1303        let total_rows = form.label_choices().count().saturating_add(1);
1304        Some(list_picker_rect(total_rows, form.areas.labels, area))
1305    } else {
1306        None
1307    }
1308}
1309
1310const DUE_PICKER_CAL_COLS: u16 = 21;
1311const DUE_PICKER_TRAILING_COLS: u16 = 1;
1312const DUE_PICKER_HEIGHT: u16 = 13;
1313
1314fn due_picker_rect(field: Rect, area: Rect) -> Rect {
1315    let width = (DUE_PICKER_CAL_COLS + DUE_PICKER_TRAILING_COLS + 2)
1316        .max(field.width)
1317        .min(area.width);
1318    let below = field.bottom();
1319    Rect {
1320        x: field.x.min(area.right().saturating_sub(width)),
1321        y: if area.bottom().saturating_sub(below) >= DUE_PICKER_HEIGHT {
1322            below
1323        } else {
1324            field.y.saturating_sub(DUE_PICKER_HEIGHT)
1325        },
1326        width,
1327        height: DUE_PICKER_HEIGHT,
1328    }
1329}
1330
1331/// Calendar + clock, dropped under the due field. Date and time are both
1332/// set here — the Due field itself is not typed into.
1333fn draw_due_picker(
1334    f: &mut Frame,
1335    theme: &Theme,
1336    picker: &mut crate::duepicker::DuePicker,
1337    field: Rect,
1338    area: Rect,
1339    areas: &mut crate::app::Areas,
1340) {
1341    use crate::duepicker::{PickerFocus, PickerLayout};
1342
1343    let Some(day) = crate::duepicker::to_time_date(picker.day) else {
1344        return;
1345    };
1346    let mut events = ratatui::widgets::calendar::CalendarEventStore::today(
1347        Style::new().fg(theme.success_color()),
1348    );
1349    // Underlined rather than filled, to match the task list.
1350    events.add(day, theme.selection().add_modifier(Modifier::UNDERLINED));
1351
1352    // Monthly needs 21 columns (` Su Mo …` / 7×3-wide day cells). It includes
1353    // a leading gutter but no trailing one, so retain one column before the
1354    // right border even when the Due field itself is narrower.
1355    let rect = due_picker_rect(field, area);
1356    areas.occlude_hover(rect);
1357    let block = Block::bordered()
1358        .border_type(BorderType::Thick)
1359        .border_style(theme.accent_text())
1360        .title_bottom(
1361            Line::styled(" Tab · clear(x) ", Style::new().fg(theme.muted_color())).left_aligned(),
1362        );
1363    f.render_widget(Clear, rect);
1364    let inner = block.inner(rect);
1365    f.render_widget(block, rect);
1366
1367    // Calendar (8) + blank gap (1) + clock (1).
1368    let [cal_area, _gap, time_area] = Layout::vertical([
1369        Constraint::Length(8),
1370        Constraint::Length(1),
1371        Constraint::Length(1),
1372    ])
1373    .areas(inner);
1374    let cal_area = Rect {
1375        width: DUE_PICKER_CAL_COLS.min(cal_area.width),
1376        ..cal_area
1377    };
1378    let time_area = Rect {
1379        width: DUE_PICKER_CAL_COLS.min(time_area.width),
1380        ..time_area
1381    };
1382
1383    // Month header (1) + weekdays (1) + day grid — matches Monthly's layout.
1384    let days = Rect {
1385        x: cal_area.x,
1386        y: cal_area.y.saturating_add(2),
1387        width: cal_area.width,
1388        height: cal_area.height.saturating_sub(2),
1389    };
1390
1391    let calendar = ratatui::widgets::calendar::Monthly::new(day, events)
1392        .show_month_header(theme.accent_text().add_modifier(Modifier::BOLD))
1393        .show_weekdays_header(Style::new().fg(theme.muted_color()))
1394        .show_surrounding(
1395            Style::new()
1396                .fg(theme.muted_color())
1397                .add_modifier(Modifier::DIM),
1398        );
1399    let day_rows = calendar.height().saturating_sub(2).min(days.height);
1400    f.render_widget(calendar, cal_area);
1401
1402    // Clock only — no "Time" label — centered under the calendar.
1403    let hour = format!("{:02}", picker.hour);
1404    let minute = format!("{:02}", picker.minute);
1405    let unit = |label: &str, on: bool| {
1406        if on {
1407            Span::styled(
1408                label.to_string(),
1409                theme.selection().add_modifier(Modifier::UNDERLINED),
1410            )
1411        } else {
1412            Span::styled(label.to_string(), Style::new())
1413        }
1414    };
1415    let time_line = Line::from(vec![
1416        unit(&hour, picker.focus == PickerFocus::Hour),
1417        Span::styled(":", Style::new().fg(theme.muted_color())),
1418        unit(&minute, picker.focus == PickerFocus::Minute),
1419    ])
1420    .centered();
1421    f.render_widget(Paragraph::new(time_line), time_area);
1422
1423    // Hit targets for "HH" and "MM" within the centered "HH:MM" (5 cells).
1424    let clock_w = 5u16;
1425    let clock_x = time_area
1426        .x
1427        .saturating_add(time_area.width.saturating_sub(clock_w) / 2);
1428    picker.layout = PickerLayout {
1429        frame: rect,
1430        days,
1431        hour: Rect {
1432            x: clock_x,
1433            y: time_area.y,
1434            width: 2,
1435            height: 1,
1436        },
1437        minute: Rect {
1438            x: clock_x.saturating_add(3),
1439            y: time_area.y,
1440            width: 2,
1441            height: 1,
1442        },
1443        time_row: time_area,
1444    };
1445    for row in 0..day_rows {
1446        for column in 0..7u16 {
1447            let x = days.x.saturating_add(column.saturating_mul(3));
1448            let cell = Rect {
1449                x,
1450                y: days.y.saturating_add(row),
1451                width: 3.min(days.right().saturating_sub(x)),
1452                height: 1,
1453            };
1454            let Some(date) = picker.day_at(cell.x, cell.y) else {
1455                continue;
1456            };
1457            if date == picker.day {
1458                areas.hover_no_paint(HoverTarget::DueDay(date), cell);
1459            } else {
1460                let day_text = Rect {
1461                    x: cell.x.saturating_add(1),
1462                    width: cell.width.saturating_sub(1),
1463                    ..cell
1464                };
1465                areas.hover_fill_with_paint(HoverTarget::DueDay(date), cell, day_text);
1466            }
1467        }
1468    }
1469}
1470
1471fn list_picker_rect(total_rows: usize, field: Rect, area: Rect) -> Rect {
1472    let desired_rows = total_rows.clamp(1, TASK_FORM_PICKER_MAX_ROWS) as u16;
1473    let desired_height = desired_rows.saturating_add(2).min(area.height);
1474    let width = field.width.min(area.width);
1475    let below = field.bottom();
1476    let below_space = area.bottom().saturating_sub(below);
1477    let above_space = field.y.saturating_sub(area.y);
1478    let place_below = below_space >= 3 || below_space >= above_space;
1479    let available_height = if place_below {
1480        below_space
1481    } else {
1482        above_space
1483    };
1484    let height = desired_height.min(available_height);
1485    Rect {
1486        x: field.x.min(area.right().saturating_sub(width)),
1487        y: if place_below {
1488            below
1489        } else {
1490            field.y.saturating_sub(height)
1491        },
1492        width,
1493        height,
1494    }
1495}
1496
1497/// Bounded, scrolling task-category selector. The highlighted row remains
1498/// pending until the user chooses it or saves the task form.
1499fn draw_category_picker(
1500    f: &mut Frame,
1501    theme: &Theme,
1502    form: &mut crate::form::TaskForm,
1503    field: Rect,
1504    area: Rect,
1505    areas: &mut crate::app::Areas,
1506) {
1507    let choices = form
1508        .category_choices()
1509        .map(|(name, current)| (name.to_string(), current))
1510        .collect::<Vec<_>>();
1511    let total_rows = choices.len();
1512    let selected = form
1513        .category_picker
1514        .as_ref()
1515        .map(|picker| picker.index)
1516        .unwrap_or_default()
1517        .min(total_rows.saturating_sub(1));
1518    let rect = list_picker_rect(total_rows, field, area);
1519    areas.occlude_hover(rect);
1520    let block = Block::bordered()
1521        .border_type(BorderType::Thick)
1522        .border_style(theme.accent_text());
1523    let inner = block.inner(rect);
1524    let visible = inner.height as usize;
1525    let start = selected
1526        .saturating_add(1)
1527        .saturating_sub(visible)
1528        .min(total_rows.saturating_sub(visible));
1529    form.set_category_picker_layout(rect, start);
1530    f.render_widget(Clear, rect);
1531    f.render_widget(block, rect);
1532    if inner.width == 0 || inner.height == 0 {
1533        return;
1534    }
1535
1536    let row_width = inner.width as usize;
1537    let lines = choices
1538        .iter()
1539        .enumerate()
1540        .skip(start)
1541        .take(visible)
1542        .map(|(index, (name, current))| {
1543            let marker = if *current { "✓" } else { " " };
1544            let name = truncate(name, row_width.saturating_sub(3));
1545            let padding = " ".repeat(row_width.saturating_sub(3 + name.width()));
1546            let mut line = Line::from(vec![
1547                Span::raw(" "),
1548                Span::raw(marker),
1549                Span::raw(" "),
1550                Span::raw(name),
1551                Span::raw(padding),
1552            ]);
1553            if index == selected {
1554                line = line.style(theme.selection());
1555            }
1556            line
1557        })
1558        .collect::<Vec<_>>();
1559    f.render_widget(Paragraph::new(lines), inner);
1560    for index in start..total_rows.min(start.saturating_add(visible)) {
1561        areas.hover_fill(
1562            HoverTarget::TaskCategory(index),
1563            Rect {
1564                y: inner
1565                    .y
1566                    .saturating_add(u16::try_from(index - start).unwrap_or(u16::MAX)),
1567                height: 1,
1568                ..inner
1569            },
1570        );
1571    }
1572    paint_scrollbar(f, theme, rect, total_rows, visible, start, true, 1);
1573}
1574
1575/// Bounded, scrolling task-label selector. Selection changes remain in the
1576/// task draft; dismissing the overlay does not save the form.
1577fn draw_label_picker(
1578    f: &mut Frame,
1579    theme: &Theme,
1580    form: &mut crate::form::TaskForm,
1581    field: Rect,
1582    area: Rect,
1583    areas: &mut crate::app::Areas,
1584) {
1585    let choices = form
1586        .label_choices()
1587        .map(|(_, name, color, selected)| (name.to_string(), color, selected))
1588        .collect::<Vec<_>>();
1589    let total_rows = choices.len().saturating_add(1);
1590    let selected = form
1591        .label_picker
1592        .as_ref()
1593        .map(|picker| picker.index)
1594        .unwrap_or_default()
1595        .min(total_rows.saturating_sub(1));
1596    let rect = list_picker_rect(total_rows, field, area);
1597    areas.occlude_hover(rect);
1598    let width = rect.width;
1599    let footer = if let Some(error) = &form.error {
1600        Line::styled(
1601            format!(" {} ", truncate(error, width.saturating_sub(4) as usize)),
1602            Style::new()
1603                .fg(theme.error_color())
1604                .add_modifier(Modifier::BOLD),
1605        )
1606    } else {
1607        Line::styled(" Space toggle ", Style::new().fg(theme.muted_color()))
1608    };
1609    let block = Block::bordered()
1610        .border_type(BorderType::Thick)
1611        .border_style(theme.accent_text())
1612        .title_bottom(footer.right_aligned());
1613    let inner = block.inner(rect);
1614    let visible = inner.height as usize;
1615    let start = selected
1616        .saturating_add(1)
1617        .saturating_sub(visible)
1618        .min(total_rows.saturating_sub(visible));
1619    form.set_label_picker_layout(rect, start);
1620    f.render_widget(Clear, rect);
1621    f.render_widget(block, rect);
1622    if inner.width == 0 || inner.height == 0 {
1623        return;
1624    }
1625
1626    let row_width = inner.width as usize;
1627    let lines = (start..total_rows)
1628        .take(visible)
1629        .map(|index| {
1630            let mut line = if let Some((name, color, checked)) = choices.get(index) {
1631                let marker = if *checked { "[✓]" } else { "[ ]" };
1632                let name = truncate(name, row_width.saturating_sub(6));
1633                let used = marker.width().saturating_add(3 + name.width());
1634                Line::from(vec![
1635                    Span::raw(format!("{marker} ")),
1636                    Span::styled("■", theme.label_swatch(*color)),
1637                    Span::raw(" "),
1638                    Span::raw(name),
1639                    Span::raw(" ".repeat(row_width.saturating_sub(used))),
1640                ])
1641            } else {
1642                let available = row_width.saturating_sub(6);
1643                let label = if "Manage labels ↵".width() <= available {
1644                    "Manage labels ↵"
1645                } else {
1646                    "Manage ↵"
1647                };
1648                let content = truncate(label, available);
1649                let padding = " ".repeat(row_width.saturating_sub(6 + content.width()));
1650                Line::from(vec![
1651                    Span::raw("      "),
1652                    Span::raw(content),
1653                    Span::raw(padding),
1654                ])
1655            };
1656            if index == selected {
1657                line = line.style(theme.selection());
1658            }
1659            line
1660        })
1661        .collect::<Vec<_>>();
1662    f.render_widget(Paragraph::new(lines), inner);
1663    for index in start..total_rows.min(start.saturating_add(visible)) {
1664        areas.hover_fill(
1665            HoverTarget::TaskLabel(index),
1666            Rect {
1667                y: inner
1668                    .y
1669                    .saturating_add(u16::try_from(index - start).unwrap_or(u16::MAX)),
1670                height: 1,
1671                ..inner
1672            },
1673        );
1674    }
1675    paint_scrollbar(f, theme, rect, total_rows, visible, start, true, 1);
1676}
1677
1678/// A description image at whatever size the screen allows.
1679fn draw_image_preview(
1680    f: &mut Frame,
1681    store: &mut crate::image::ImageStore,
1682    form: &crate::form::TaskForm,
1683    theme: &Theme,
1684    path: &std::path::Path,
1685    area: Rect,
1686) {
1687    let rect = centered(
1688        area,
1689        (u32::from(area.width) * 9 / 10) as u16,
1690        (u32::from(area.height) * 9 / 10) as u16,
1691    );
1692    let title = truncate(
1693        &path.file_name().unwrap_or_default().to_string_lossy(),
1694        rect.width.saturating_sub(10) as usize,
1695    );
1696    let kind = crate::image::type_label(path);
1697    let anim_note = form
1698        .gif
1699        .as_ref()
1700        .map(|(_, g)| g)
1701        .filter(|g| g.is_animated())
1702        .map(|g| format!(" · {}/{}", g.frame_number(), g.frame_count()))
1703        .unwrap_or_default();
1704    let block = Block::bordered()
1705        .border_type(BorderType::Thick)
1706        .border_style(theme.accent_text())
1707        .title(Span::styled(
1708            format!(" {title} "),
1709            theme.accent_text().bold(),
1710        ))
1711        .title_top(
1712            Line::styled(
1713                format!(" {kind}{anim_note} "),
1714                Style::new().fg(theme.muted_color()),
1715            )
1716            .right_aligned(),
1717        )
1718        .title_bottom(
1719            Line::styled(
1720                match form.gif.as_ref().map(|(_, g)| g) {
1721                    Some(g) if g.is_animated() && g.is_paused() => {
1722                        " Esc closes · click/space resume "
1723                    }
1724                    Some(g) if g.is_animated() => " Esc closes · click/space pause ",
1725                    _ => " Esc closes ",
1726                },
1727                Style::new().fg(theme.muted_color()),
1728            )
1729            .right_aligned(),
1730        );
1731    let inner = block.inner(rect);
1732    f.render_widget(Clear, rect);
1733    f.render_widget(block, rect);
1734
1735    // Preview has its own chrome; no selection frame margin.
1736    if let Some((_, gif)) = form.gif.as_ref() {
1737        match store.preview_frame(gif) {
1738            Ok(protocol) => {
1739                let _ = render_protocol(f, protocol, inner, theme, None);
1740            }
1741            Err(err) => {
1742                let slot = letterbox_rect(preview_slot_area(inner), 4, 3);
1743                draw_image_slot(
1744                    f,
1745                    theme,
1746                    slot,
1747                    ImageSlotKind::Broken { detail: &err },
1748                    false,
1749                );
1750            }
1751        }
1752    } else {
1753        match store.get_preview(path) {
1754            crate::image::ImageReady::Ready(protocol) => {
1755                let _ = render_protocol(f, protocol, inner, theme, None);
1756            }
1757            crate::image::ImageReady::Loading => {
1758                // Loading means not cached yet — aspect unknown.
1759                let slot = letterbox_rect(preview_slot_area(inner), 4, 3);
1760                draw_image_slot(f, theme, slot, ImageSlotKind::Loading, false);
1761            }
1762            crate::image::ImageReady::Failed(err) => {
1763                let slot = letterbox_rect(preview_slot_area(inner), 4, 3);
1764                draw_image_slot(
1765                    f,
1766                    theme,
1767                    slot,
1768                    ImageSlotKind::Broken { detail: &err },
1769                    false,
1770                );
1771            }
1772        }
1773    }
1774}
1775
1776/// Draws a decoded image, or a loading / broken stand-in sized like the
1777/// picture. Returns the screen rect of the picture (hit target).
1778fn draw_image(
1779    f: &mut Frame,
1780    store: &mut crate::image::ImageStore,
1781    theme: &Theme,
1782    path: &std::path::Path,
1783    area: Rect,
1784    selected: bool,
1785) -> Option<Rect> {
1786    if area.width < 3 || area.height < 3 {
1787        return None;
1788    }
1789    // Room for the frame is always left, so selecting a picture does not
1790    // change its size.
1791    let inner = area.inner(Margin {
1792        horizontal: 1,
1793        vertical: 1,
1794    });
1795    match store.get(path) {
1796        crate::image::ImageReady::Ready(protocol) => Some(render_protocol(
1797            f,
1798            protocol,
1799            inner,
1800            theme,
1801            selected.then_some(path),
1802        )),
1803        crate::image::ImageReady::Loading => {
1804            // Not in cache yet — aspect unknown until decode finishes.
1805            let slot = letterbox_rect(area, 4, 3);
1806            draw_image_slot(f, theme, slot, ImageSlotKind::Loading, selected);
1807            Some(slot)
1808        }
1809        crate::image::ImageReady::Failed(err) => {
1810            let name = path
1811                .file_name()
1812                .and_then(|n| n.to_str())
1813                .unwrap_or(err.as_str());
1814            let slot = letterbox_rect(area, 4, 3);
1815            draw_image_slot(
1816                f,
1817                theme,
1818                slot,
1819                ImageSlotKind::Broken { detail: name },
1820                selected,
1821            );
1822            Some(slot)
1823        }
1824    }
1825}
1826
1827/// Paints the protocol and returns the interactive rect (frame when
1828/// selected, otherwise the picture itself).
1829fn render_protocol(
1830    f: &mut Frame,
1831    protocol: &mut ratatui_image::protocol::StatefulProtocol,
1832    inner: Rect,
1833    theme: &Theme,
1834    frame: Option<&std::path::Path>,
1835) -> Rect {
1836    // Scale (not Fit): Fit never grows past the source pixel size, so a
1837    // 1920px image on a large terminal only fills part of the preview.
1838    // Scale keeps aspect ratio and uses the full cell area.
1839    let size = protocol.size_for(Resize::Scale(None), inner.as_size());
1840    let picture = centered(
1841        inner,
1842        size.width.min(inner.width),
1843        size.height.min(inner.height),
1844    );
1845    f.render_stateful_widget(
1846        StatefulImage::default().resize(Resize::Scale(None)),
1847        picture,
1848        protocol,
1849    );
1850    let hit = if let Some(path) = frame {
1851        let border = Rect {
1852            x: picture.x.saturating_sub(1),
1853            y: picture.y.saturating_sub(1),
1854            width: picture.width.saturating_add(2),
1855            height: picture.height.saturating_add(2),
1856        };
1857        let kind = crate::image::type_label(path);
1858        f.render_widget(
1859            Block::bordered()
1860                .border_type(BorderType::Thick)
1861                .border_style(theme.accent_text())
1862                .title_top(
1863                    Line::styled(format!(" {kind} "), Style::new().fg(theme.muted_color()))
1864                        .right_aligned(),
1865                ),
1866            border,
1867        );
1868        border
1869    } else {
1870        picture
1871    };
1872    if let Some(Err(err)) = protocol.last_encoding_result() {
1873        let line = Line::styled(
1874            truncate(&format!("image: {err}"), inner.width as usize),
1875            Style::new().fg(theme.error_color()),
1876        );
1877        f.render_widget(Paragraph::new(line), inner);
1878    }
1879    hit
1880}
1881
1882fn render_field_box(f: &mut Frame, block: Block, area: Rect) -> Rect {
1883    let inner = block.inner(area);
1884    f.render_widget(block, area);
1885    inner
1886}
1887
1888/// Draws `text`, or a dim hint at what belongs there when it is empty.
1889/// Split `text` into spans, washing the selection with the theme accent.
1890fn line_with_selection(
1891    text: &str,
1892    sel: Option<(u16, u16)>,
1893    base: Style,
1894    theme: &Theme,
1895) -> Line<'static> {
1896    let Some((a, b)) = sel else {
1897        return Line::from(Span::styled(text.to_string(), base));
1898    };
1899    let a = a as usize;
1900    let b = b as usize;
1901    if a >= b {
1902        return Line::from(Span::styled(text.to_string(), base));
1903    }
1904    let sel_style = theme.selection();
1905    let mut spans = Vec::new();
1906    let mut col = 0usize;
1907    let mut chunk = String::new();
1908    let mut chunk_in_sel = false;
1909    let flush = |spans: &mut Vec<Span<'static>>, chunk: &mut String, in_sel: bool| {
1910        if chunk.is_empty() {
1911            return;
1912        }
1913        let style = if in_sel { sel_style } else { base };
1914        spans.push(Span::styled(std::mem::take(chunk), style));
1915    };
1916    for grapheme in text.graphemes(true) {
1917        let w = grapheme.width();
1918        let in_sel = col >= a && col < b;
1919        if !chunk.is_empty() && in_sel != chunk_in_sel {
1920            flush(&mut spans, &mut chunk, chunk_in_sel);
1921        }
1922        chunk_in_sel = in_sel;
1923        chunk.push_str(grapheme);
1924        col += w;
1925    }
1926    flush(&mut spans, &mut chunk, chunk_in_sel);
1927    Line::from(spans)
1928}
1929
1930fn render_or_placeholder(f: &mut Frame, area: Rect, text: &str, placeholder: &str, theme: &Theme) {
1931    let line = if text.is_empty() {
1932        Line::styled(
1933            truncate(placeholder, area.width as usize),
1934            Style::new()
1935                .fg(theme.muted_color())
1936                .add_modifier(Modifier::DIM),
1937        )
1938    } else {
1939        Line::raw(text.to_string())
1940    };
1941    f.render_widget(Paragraph::new(line), area);
1942}
1943
1944#[derive(Clone)]
1945struct LabelToken {
1946    name: String,
1947    color: Option<LabelColor>,
1948}
1949
1950impl LabelToken {
1951    fn new(name: &str, color: LabelColor) -> Self {
1952        Self {
1953            name: name.to_string(),
1954            color: Some(color),
1955        }
1956    }
1957
1958    fn remainder(hidden: usize) -> Self {
1959        Self {
1960            name: format!("+{hidden}"),
1961            color: None,
1962        }
1963    }
1964}
1965
1966impl From<&crate::model::Label> for LabelToken {
1967    fn from(label: &crate::model::Label) -> Self {
1968        Self::new(&label.name, label.color)
1969    }
1970}
1971
1972fn label_badges_width(labels: &[LabelToken]) -> usize {
1973    labels
1974        .iter()
1975        .map(|label| {
1976            label
1977                .name
1978                .width()
1979                .saturating_add(if label.color.is_some() { 2 } else { 0 })
1980        })
1981        .sum::<usize>()
1982        .saturating_add(labels.len().saturating_sub(1))
1983}
1984
1985fn label_badges_spans(labels: &[LabelToken], theme: &Theme, done: bool) -> Vec<Span<'static>> {
1986    let mut spans = Vec::with_capacity(labels.len().saturating_mul(2));
1987    for (index, label) in labels.iter().enumerate() {
1988        if index > 0 {
1989            spans.push(Span::raw(" "));
1990        }
1991        match label.color {
1992            Some(color) => spans.push(Span::styled(
1993                format!(" {} ", label.name),
1994                theme.label_badge(color, done),
1995            )),
1996            None => spans.push(Span::styled(
1997                label.name.clone(),
1998                Style::new().fg(theme.muted_color()),
1999            )),
2000        }
2001    }
2002    spans
2003}
2004
2005fn label_badges_line(labels: &[LabelToken], theme: &Theme, done: bool) -> Line<'static> {
2006    Line::from(label_badges_spans(labels, theme, done))
2007}
2008
2009fn wrapped_label_badges(
2010    labels: &[LabelToken],
2011    width: usize,
2012    theme: &Theme,
2013    done: bool,
2014) -> Vec<Line<'static>> {
2015    if labels.is_empty() || width == 0 {
2016        return Vec::new();
2017    }
2018    let mut lines = Vec::new();
2019    let mut row = Vec::new();
2020    let mut row_width = 0usize;
2021    for label in labels {
2022        let name = truncate(&label.name, width.saturating_sub(2));
2023        let badge_width = name.width().saturating_add(2);
2024        let gap = usize::from(!row.is_empty());
2025        if !row.is_empty() && row_width.saturating_add(gap + badge_width) > width {
2026            lines.push(label_badges_line(&row, theme, done));
2027            row.clear();
2028            row_width = 0;
2029        }
2030        row_width = row_width
2031            .saturating_add(usize::from(!row.is_empty()))
2032            .saturating_add(badge_width);
2033        row.push(LabelToken {
2034            name,
2035            color: label.color,
2036        });
2037    }
2038    if !row.is_empty() {
2039        lines.push(label_badges_line(&row, theme, done));
2040    }
2041    lines
2042}
2043
2044fn draw_text_input(
2045    f: &mut Frame,
2046    input: &mut crate::text_input::TextInput,
2047    area: Rect,
2048    placeholder: &str,
2049    focused: bool,
2050    theme: &Theme,
2051) {
2052    let view = input.visible(area.width as usize);
2053    if view.text.is_empty() {
2054        render_or_placeholder(f, area, "", placeholder, theme);
2055    } else {
2056        f.render_widget(
2057            Paragraph::new(line_with_selection(
2058                &view.text,
2059                view.sel_cols,
2060                Style::new(),
2061                theme,
2062            )),
2063            area,
2064        );
2065    }
2066    if focused {
2067        f.set_cursor_position((area.x.saturating_add(view.cursor_col), area.y));
2068    }
2069}
2070
2071/// A panel: thick border glyphs, title in the top-left, accent colour
2072/// while focused. (Terminal bold barely thickens box-drawing chars.)
2073fn panel<'a>(title: &'a str, focused: bool, theme: &Theme) -> Block<'a> {
2074    field_block(title, focused, None, theme)
2075}
2076
2077/// Panel scrollbar (right border). Accent when focused, grey otherwise.
2078fn scrollbar(
2079    f: &mut Frame,
2080    theme: &Theme,
2081    area: Rect,
2082    total: usize,
2083    visible: usize,
2084    offset: usize,
2085    focused: bool,
2086) {
2087    paint_scrollbar(f, theme, area, total, visible, offset, focused, 1);
2088}
2089
2090#[allow(clippy::too_many_arguments)]
2091fn paint_scrollbar(
2092    f: &mut Frame,
2093    theme: &Theme,
2094    area: Rect,
2095    total: usize,
2096    visible: usize,
2097    offset: usize,
2098    focused: bool,
2099    vertical_margin: u16,
2100) {
2101    // Ratatui's thumb hits the end only when `position == content_length - 1`.
2102    // List/table `offset` runs 0..=(total - visible), so content_length must
2103    // be that range's size (max_offset + 1), not the raw row count — otherwise
2104    // the thumb stops short when you are already on the last row.
2105    let max_offset = total.saturating_sub(visible);
2106    if max_offset == 0 || area.height <= vertical_margin.saturating_mul(2) {
2107        return;
2108    }
2109    let mut state = ScrollbarState::new(max_offset + 1).position(offset.min(max_offset));
2110    let style = if focused {
2111        theme.accent_text()
2112    } else {
2113        Style::new().fg(theme.muted_color())
2114    };
2115    f.render_stateful_widget(
2116        Scrollbar::new(ScrollbarOrientation::VerticalRight)
2117            .symbols(ratatui::symbols::scrollbar::VERTICAL)
2118            .begin_symbol(None)
2119            .end_symbol(None)
2120            .thumb_style(style)
2121            .track_style(style),
2122        area.inner(Margin {
2123            horizontal: 0,
2124            vertical: vertical_margin,
2125        }),
2126        &mut state,
2127    );
2128}
2129
2130// --------------------------------------------------------------- sidebar
2131
2132fn draw_sidebar(f: &mut Frame, app: &mut App, theme: &Theme, area: Rect) {
2133    let focused = app.focus == Focus::Sidebar;
2134    let chrome_focus = focused && !app.mode.command_bar_focused();
2135    let block = panel("Categories", chrome_focus, theme);
2136    let inner = block.inner(area);
2137    if inner.height == 0 || inner.width == 0 {
2138        f.render_widget(block, area);
2139        return;
2140    }
2141
2142    let (list_area, hint_area) =
2143        if chrome_focus && app.settings.show_passive_hints() && inner.height > 1 {
2144            let [list_area, hint_area] =
2145                Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(inner);
2146            (list_area, Some(hint_area))
2147        } else {
2148            (inner, None)
2149        };
2150    app.areas.sidebar = list_area;
2151
2152    let width = inner.width as usize;
2153    let scores: Vec<String> = app
2154        .categories
2155        .iter()
2156        .enumerate()
2157        .map(|(index, _)| {
2158            let (done, total) = app.category_progress_at(index);
2159            if app.settings.hide_done {
2160                (total - done).to_string()
2161            } else {
2162                format!("{done}/{total}")
2163            }
2164        })
2165        .collect();
2166    let count_width = scores.iter().map(|s| s.width()).max().unwrap_or(3).max(3);
2167    let name_field = width.saturating_sub(count_width + 1);
2168    let items: Vec<ListItem> = app
2169        .categories
2170        .iter()
2171        .zip(scores.iter())
2172        .map(|(cat, score)| {
2173            let count = format!("{score:>count_width$}");
2174            let name = truncate(&cat.name, name_field);
2175            let pad = " ".repeat(width.saturating_sub(name.width() + count.width()));
2176            ListItem::new(Line::from(vec![
2177                Span::raw(name),
2178                Span::raw(pad),
2179                Span::styled(count, Style::new().fg(theme.muted_color())),
2180            ]))
2181        })
2182        .collect();
2183    let rows = items.len();
2184
2185    app.cat_state.select(Some(app.cat_index));
2186    let list = List::new(items).highlight_style(if focused {
2187        theme.selection()
2188    } else {
2189        theme.selection_unfocused()
2190    });
2191    f.render_widget(block, area);
2192    f.render_stateful_widget(list, list_area, &mut app.cat_state);
2193    if panels_accept_mouse(app) && !app.searching {
2194        let start = app.cat_state.offset();
2195        for index in start..app.categories.len() {
2196            let y = list_area
2197                .y
2198                .saturating_add(u16::try_from(index - start).unwrap_or(u16::MAX));
2199            if y >= list_area.bottom() {
2200                break;
2201            }
2202            app.areas.hover_fill(
2203                HoverTarget::Sidebar(index),
2204                Rect {
2205                    y,
2206                    height: 1,
2207                    ..list_area
2208                },
2209            );
2210        }
2211    }
2212    if let Some(hint_area) = hint_area {
2213        f.render_widget(
2214            Paragraph::new(Line::from(Span::styled(
2215                "⌥↑↓ reorder",
2216                Style::new().fg(theme.muted_color()),
2217            )))
2218            .right_aligned(),
2219            hint_area,
2220        );
2221    }
2222
2223    let scrollbar_area = Rect {
2224        height: list_area.height.saturating_add(2).min(area.height),
2225        ..area
2226    };
2227    scrollbar(
2228        f,
2229        theme,
2230        scrollbar_area,
2231        rows,
2232        list_area.height as usize,
2233        app.cat_state.offset(),
2234        chrome_focus,
2235    );
2236}
2237
2238// ----------------------------------------------------------------- tasks
2239
2240fn draw_tasks(f: &mut Frame, app: &mut App, theme: &Theme, area: Rect) {
2241    let focused = app.focus == Focus::Tasks;
2242    // While the task editor is open, dim panel chrome (border / scrollbar)
2243    // but keep the selected-row wash so the edited task stays visible.
2244    let chrome_focus = focused && app.mode != Mode::TaskForm && !app.mode.command_bar_focused();
2245    // The sidebar already says which category is showing; only a search
2246    // needs spelling out up here.
2247    let mut block = panel("Tasks", chrome_focus, theme);
2248    // Search spells out what matched; category notes stay in the editor.
2249    if app.searching {
2250        let context = format!(" search: {} · {} found ", app.search_query, app.view.len());
2251        block = block
2252            .title_top(Line::styled(context, Style::new().fg(theme.muted_color())).right_aligned());
2253    }
2254    let inner = block.inner(area);
2255    app.areas.tasks = inner;
2256    if inner.height == 0 || inner.width == 0 {
2257        f.render_widget(block, area);
2258        return;
2259    }
2260
2261    if app.view.is_empty() {
2262        f.render_widget(block, area);
2263        let text = if app.searching {
2264            banner::NO_SEARCH_RESULTS
2265        } else {
2266            banner::EMPTY_TASKS
2267        };
2268        let style = if chrome_focus {
2269            theme.accent_text()
2270        } else {
2271            Style::new().fg(theme.muted_color())
2272        };
2273        draw_box(f, inner, text, style);
2274        return;
2275    }
2276
2277    // Category is shown as a section header row in All Tasks / search, not a
2278    // per-task suffix. Flags keep a fixed right edge; due dates and description
2279    // markers live inside each task's content cell so metadata on one task
2280    // cannot shorten every other title in the list.
2281    let flags_width = crate::model::MAX_IMPORTANCE as usize;
2282    let today = chrono::Local::now().date_naive();
2283
2284    // Preserve a useful title at narrow widths. Flags stay aligned when the
2285    // panel can afford them; row-local metadata decides independently whether
2286    // its complete value fits beside that task's title.
2287    const TITLE_MIN: usize = 8;
2288    let available = inner.width as usize;
2289    let flags_visible = DONE_MARK_WIDTH as usize + 1 + TITLE_MIN + 1 + flags_width <= available;
2290    let mut widths = vec![
2291        Constraint::Length(DONE_MARK_WIDTH), // [ ] / [✓]
2292        Constraint::Fill(1),                 // title + this task's metadata
2293    ];
2294    if flags_visible {
2295        widths.push(Constraint::Length(flags_width as u16));
2296    }
2297    let column_gaps = widths.len().saturating_sub(1);
2298    let content_width = available
2299        .saturating_sub(DONE_MARK_WIDTH as usize)
2300        .saturating_sub(column_gaps)
2301        .saturating_sub(if flags_visible { flags_width } else { 0 });
2302    let rows: Vec<Row> = app
2303        .list_rows
2304        .iter()
2305        .map(|row| match row {
2306            // Placeholder — the full-width rule is painted after the table
2307            // so column gaps cannot break the line or the title.
2308            crate::app::TaskListRow::Separator { .. } => {
2309                Row::new(std::iter::repeat_n(Cell::new(""), widths.len()))
2310            }
2311            crate::app::TaskListRow::Task(view_idx) => {
2312                let task = &app.tasks[app.view[*view_idx]];
2313                task_row(
2314                    TaskPresentation::new(task, &app.labels, &app.settings.date_format, today),
2315                    theme,
2316                    (*view_idx == app.task_index).then(|| {
2317                        if chrome_focus {
2318                            theme.selection()
2319                        } else {
2320                            theme.selection_unfocused()
2321                        }
2322                    }),
2323                    content_width,
2324                    flags_visible,
2325                )
2326            }
2327        })
2328        .collect();
2329    // Selected-row wash stays on during edit; bold only when the list has chrome focus.
2330    let table = Table::new(rows, widths).block(block).column_spacing(1);
2331
2332    // Remember where the markers ended up, so a click can find them. The
2333    // flags sit at the right edge, the tick at the left.
2334    app.areas.done_x = Some(inner.x);
2335    app.areas.flag_x = flags_visible.then_some(inner.right().saturating_sub(flags_width as u16));
2336
2337    let vis = app.selected_visual_row();
2338    app.task_state.select(vis);
2339    // Table does `start = offset.min(selected)`, so scrolling up to the first
2340    // task of a group lands on that task and hides the section header above
2341    // it. Pull offset back onto the header first so the header stays in view.
2342    if let Some(vis) = vis {
2343        pin_section_header(app, vis);
2344    }
2345    f.render_stateful_widget(table, area, &mut app.task_state);
2346
2347    // Full-width category rules on top of separator placeholder rows.
2348    let offset = app.task_state.offset();
2349    let rule_style = Style::new().fg(theme.muted_color());
2350    for (vis_i, row) in app.list_rows.iter().enumerate().skip(offset) {
2351        let y = inner
2352            .y
2353            .saturating_add(u16::try_from(vis_i - offset).unwrap_or(u16::MAX));
2354        if y >= inner.bottom() {
2355            break;
2356        }
2357        match row {
2358            crate::app::TaskListRow::Separator { title } => {
2359                // Align the name with task titles (after `[ ]` + column gap).
2360                let title_x = (DONE_MARK_WIDTH + 1) as usize;
2361                let line = category_rule(title, inner.width as usize, title_x);
2362                f.render_widget(
2363                    Paragraph::new(Span::styled(line, rule_style)),
2364                    Rect {
2365                        x: inner.x,
2366                        y,
2367                        width: inner.width,
2368                        height: 1,
2369                    },
2370                );
2371            }
2372            crate::app::TaskListRow::Task(view_index) if panels_accept_mouse(app) => {
2373                app.areas.hover_fill(
2374                    HoverTarget::Task(*view_index),
2375                    Rect {
2376                        x: inner.x,
2377                        y,
2378                        width: inner.width,
2379                        height: 1,
2380                    },
2381                );
2382            }
2383            crate::app::TaskListRow::Task(_) => {}
2384        }
2385    }
2386
2387    scrollbar(
2388        f,
2389        theme,
2390        area,
2391        app.list_rows.len(),
2392        inner.height as usize,
2393        app.task_state.offset(),
2394        chrome_focus,
2395    );
2396}
2397
2398fn panels_accept_mouse(app: &App) -> bool {
2399    matches!(
2400        app.mode,
2401        Mode::Normal | Mode::Search | Mode::TaskForm | Mode::CategoryForm
2402    ) && !app.form.as_ref().is_some_and(|form| form.preview)
2403}
2404
2405/// If `vis` is the first task under a section header, do not let the table
2406/// scroll that header off the top of the viewport.
2407fn pin_section_header(app: &mut App, vis: usize) {
2408    if vis == 0 {
2409        return;
2410    }
2411    let header = vis - 1;
2412    if !matches!(
2413        app.list_rows.get(header),
2414        Some(crate::app::TaskListRow::Separator { .. })
2415    ) {
2416        return;
2417    }
2418    if app.task_state.offset() > header {
2419        *app.task_state.offset_mut() = header;
2420    }
2421}
2422
2423/// The markers shown between a task's title and its due date.
2424fn extras(task: &crate::model::Task) -> String {
2425    let mut has_prose_or_image = false;
2426    let mut done = 0usize;
2427    let mut total = 0usize;
2428    for block in &task.description {
2429        match block {
2430            crate::model::Block::Todo { done: is_done, .. } => {
2431                total += 1;
2432                done += usize::from(*is_done);
2433            }
2434            block if !block.is_empty() => has_prose_or_image = true,
2435            _ => {}
2436        }
2437    }
2438    match (has_prose_or_image, total) {
2439        (true, 0) => "≡".to_string(),
2440        (true, _) => format!("≡ {done}/{total}"),
2441        (false, 0) => String::new(),
2442        (false, _) => format!("{done}/{total}"),
2443    }
2444}
2445
2446/// Owned display data derived once for one task during a frame.
2447struct TaskPresentation<'a> {
2448    title: &'a str,
2449    labels: Vec<LabelToken>,
2450    extras: String,
2451    due: String,
2452    flags: String,
2453    done: bool,
2454}
2455
2456impl<'a> TaskPresentation<'a> {
2457    fn new(
2458        task: &'a crate::model::Task,
2459        labels: &[crate::model::Label],
2460        date_format: &str,
2461        today: chrono::NaiveDate,
2462    ) -> Self {
2463        Self {
2464            title: &task.title,
2465            labels: labels_for_task(task, labels)
2466                .map(LabelToken::from)
2467                .collect(),
2468            extras: extras(task),
2469            due: due::display_compact_at(&task.due, date_format, today),
2470            flags: crate::model::importance_marks(task.importance),
2471            done: task.done,
2472        }
2473    }
2474}
2475
2476/// Full-width rule with the category name aligned to the title column:
2477/// `─── Mach ────────────────` (space before the name, same column as titles).
2478fn category_rule(title: &str, width: usize, title_x: usize) -> String {
2479    if width == 0 {
2480        return String::new();
2481    }
2482    // One space before the name so it does not touch the rule; the name
2483    // still starts at `title_x` like task titles after `[ ] `.
2484    let label = format!(" {title} ");
2485    let label_w = label.width();
2486    let pad = title_x.saturating_sub(1).min(width);
2487    if pad + label_w >= width {
2488        let head = "─".repeat(pad);
2489        return truncate(&format!("{head}{label}"), width);
2490    }
2491    format!(
2492        "{}{label}{}",
2493        "─".repeat(pad),
2494        "─".repeat(width - pad - label_w)
2495    )
2496}
2497
2498fn task_row(
2499    presentation: TaskPresentation<'_>,
2500    theme: &Theme,
2501    selection: Option<Style>,
2502    content_width: usize,
2503    flags_visible: bool,
2504) -> Row<'static> {
2505    let done = presentation.done;
2506    let selected = selection.is_some();
2507    // A finished task is muted — but not on the selected row (even when
2508    // Categories has focus), where the tick and strikethrough say enough.
2509    // Due colour belongs to the due label rather than tinting the whole title.
2510    let title_style = if done && !selected {
2511        Style::new().fg(theme.muted_color())
2512    } else {
2513        theme.plain()
2514    };
2515    let title_style = if done {
2516        title_style.add_modifier(Modifier::CROSSED_OUT)
2517    } else {
2518        title_style
2519    };
2520
2521    let mut cells = Vec::with_capacity(5);
2522    let (mark, mark_style) = if done {
2523        ("[✓]", Style::new().fg(theme.success_color()))
2524    } else {
2525        ("[ ]", Style::new().fg(theme.muted_color()))
2526    };
2527    cells.push(Cell::new(mark).style(mark_style));
2528    let metadata_style = if done {
2529        Style::new()
2530            .fg(theme.muted_color())
2531            .add_modifier(Modifier::CROSSED_OUT)
2532    } else {
2533        Style::new().fg(theme.muted_color())
2534    };
2535    let due_style = if done {
2536        title_style
2537    } else {
2538        Style::new().fg(theme.accent)
2539    };
2540    cells.push(Cell::new(task_content_line(
2541        &presentation,
2542        TaskContentStyles {
2543            title: title_style,
2544            extras: metadata_style,
2545            due: due_style,
2546        },
2547        theme,
2548        content_width,
2549    )));
2550    if flags_visible {
2551        let flag_style = if done {
2552            metadata_style
2553        } else {
2554            Style::new().fg(theme.error_color())
2555        };
2556        cells.push(Cell::new(Text::from(
2557            Line::from(Span::styled(presentation.flags, flag_style)).right_aligned(),
2558        )));
2559    }
2560    let row = Row::new(cells);
2561    if let Some(style) = selection {
2562        row.style(style)
2563    } else {
2564        row
2565    }
2566}
2567
2568/// Build one task's content cell with row-local metadata at its right edge.
2569/// Due is the highest-priority suffix. Labels then use whole tokens and a
2570/// `+N` remainder; description/progress joins only when every value fits
2571/// while retaining a recognisable title.
2572#[derive(Clone, Copy)]
2573struct TaskContentStyles {
2574    title: Style,
2575    extras: Style,
2576    due: Style,
2577}
2578
2579fn task_content_line(
2580    presentation: &TaskPresentation<'_>,
2581    styles: TaskContentStyles,
2582    theme: &Theme,
2583    width: usize,
2584) -> Line<'static> {
2585    const TITLE_MIN: usize = 8;
2586    const META_GAP: usize = 1;
2587
2588    let title_floor = presentation.title.width().min(TITLE_MIN);
2589    let mut show_due = false;
2590    let mut show_extras = false;
2591    let mut shown_labels = Vec::new();
2592    let mut metadata_width = 0;
2593
2594    if !presentation.due.is_empty() && title_floor + META_GAP + presentation.due.width() <= width {
2595        show_due = true;
2596        metadata_width = presentation.due.width();
2597    }
2598
2599    if !presentation.labels.is_empty() {
2600        let reserved = title_floor
2601            .saturating_add(META_GAP)
2602            .saturating_add(metadata_width)
2603            .saturating_add(usize::from(metadata_width > 0));
2604        shown_labels = compact_badge_tokens(&presentation.labels, width.saturating_sub(reserved));
2605        if !shown_labels.is_empty() {
2606            metadata_width = metadata_width
2607                .saturating_add(usize::from(metadata_width > 0))
2608                .saturating_add(label_badges_width(&shown_labels));
2609        }
2610    }
2611    if !presentation.extras.is_empty() {
2612        let joined_width = if metadata_width == 0 {
2613            presentation.extras.width()
2614        } else {
2615            presentation.extras.width() + META_GAP + metadata_width
2616        };
2617        if title_floor + META_GAP + joined_width <= width {
2618            show_extras = true;
2619            metadata_width = joined_width;
2620        }
2621    }
2622
2623    if metadata_width == 0 {
2624        return Line::from(Span::styled(
2625            truncate(presentation.title, width),
2626            styles.title,
2627        ));
2628    }
2629
2630    let title_width = width.saturating_sub(META_GAP + metadata_width);
2631    let title = truncate(presentation.title, title_width);
2632    let padding = width.saturating_sub(title.width() + metadata_width);
2633    let mut spans = vec![
2634        Span::styled(title, styles.title),
2635        Span::raw(" ".repeat(padding)),
2636    ];
2637    if !shown_labels.is_empty() {
2638        spans.extend(label_badges_spans(&shown_labels, theme, presentation.done));
2639        if show_extras || show_due {
2640            spans.push(Span::raw(" "));
2641        }
2642    }
2643    if show_extras {
2644        spans.push(Span::styled(presentation.extras.clone(), styles.extras));
2645        if show_due {
2646            spans.push(Span::raw(" "));
2647        }
2648    }
2649    if show_due {
2650        spans.push(Span::styled(presentation.due.clone(), styles.due));
2651    }
2652    Line::from(spans)
2653}
2654
2655fn compact_badge_tokens(labels: &[LabelToken], width: usize) -> Vec<LabelToken> {
2656    for shown in (0..=labels.len()).rev() {
2657        let hidden = labels.len() - shown;
2658        let mut parts = labels[..shown].to_vec();
2659        if hidden > 0 {
2660            parts.push(LabelToken::remainder(hidden));
2661        }
2662        if label_badges_width(&parts) <= width {
2663            return parts;
2664        }
2665    }
2666    Vec::new()
2667}
2668
2669// ------------------------------------------------------------ status bar
2670
2671fn draw_status(f: &mut Frame, app: &mut App, theme: &Theme, area: Rect) {
2672    // The bar is a panel like the others, minus the name: while a
2673    // command or a search is being typed it is what has focus.
2674    let typing = matches!(app.mode, Mode::Slash | Mode::Search);
2675    let update_activity = (!typing).then(|| app.update_activity()).flatten();
2676    let archive_activity = (!typing && app.message.is_none())
2677        .then(|| app.archive_activity_text())
2678        .flatten();
2679    let downloading = matches!(update_activity, Some(UpdateActivity::Downloading(_)));
2680    let block = Block::bordered()
2681        .border_type(BorderType::Thick)
2682        .border_style(if typing {
2683            theme.accent_text()
2684        } else {
2685            Style::new().fg(theme.muted_color())
2686        })
2687        .padding(if downloading {
2688            Padding::ZERO
2689        } else {
2690            Padding::horizontal(1)
2691        });
2692    let inner = block.inner(area);
2693    f.render_widget(block, area);
2694    let area = inner;
2695
2696    if let Some(UpdateActivity::Downloading(progress)) = update_activity {
2697        draw_download_progress(f, progress, theme, area);
2698        return;
2699    }
2700
2701    let right = Line::from(Span::styled(
2702        due::now_string(&app.settings.date_format),
2703        Style::new().fg(theme.muted_color()),
2704    ));
2705    // A transient message owns the status row. Its result or recovery detail
2706    // is more useful for these few seconds than a clock that is always there
2707    // otherwise. Persistent update notices still share the row with the time.
2708    let show_clock = typing || update_activity.is_some() || app.message.is_none();
2709    let right_width = if show_clock { right.width() as u16 } else { 0 };
2710    let [left_area, right_area] = Layout::horizontal([
2711        Constraint::Min(0),
2712        Constraint::Length(right_width.min(area.width)),
2713    ])
2714    .areas(area);
2715    // The clock is display-only, but it is still part of the command bar's
2716    // mouse target. A click there focuses the input and lands at its end.
2717    app.areas.command_bar = area;
2718    if show_clock {
2719        f.render_widget(Paragraph::new(right), right_area);
2720    }
2721
2722    let field = left_area.width.saturating_sub(2) as usize;
2723    let left = match app.mode {
2724        Mode::Slash | Mode::Search => {
2725            let view = app.input.visible(field);
2726            f.set_cursor_position((
2727                left_area
2728                    .x
2729                    .saturating_add(1)
2730                    .saturating_add(view.cursor_col),
2731                left_area.y,
2732            ));
2733            let description = line_with_selection(&view.text, view.sel_cols, Style::new(), theme);
2734            Line::from(
2735                [
2736                    vec![Span::styled("/", theme.accent_text())],
2737                    description.spans,
2738                ]
2739                .concat(),
2740            )
2741        }
2742        _ => {
2743            if let Some(text) = archive_activity.as_deref() {
2744                Line::from(Span::styled(truncate(text, field), theme.accent_text()))
2745            } else if update_activity == Some(UpdateActivity::Checking) {
2746                Line::from(Span::styled("Checking for updates…", theme.accent_text()))
2747            } else {
2748                match app.status_message() {
2749                    Some((text, kind)) => {
2750                        let style = match kind {
2751                            MessageKind::Error => Style::new()
2752                                .fg(theme.error_color())
2753                                .add_modifier(Modifier::BOLD),
2754                            MessageKind::Info => theme.plain(),
2755                        };
2756                        Line::from(Span::styled(truncate(text, field), style))
2757                    }
2758                    None => {
2759                        let hint = if app.searching {
2760                            format!("search: {} · Esc clears", app.search_query)
2761                        } else if app.settings.show_passive_hints() {
2762                            "/ commands".to_string()
2763                        } else {
2764                            "/".to_string()
2765                        };
2766                        if (left_area.width as usize) >= hint.width() + 2 {
2767                            Line::from(Span::styled(hint, Style::new().fg(theme.muted_color())))
2768                        } else {
2769                            Line::raw("")
2770                        }
2771                    }
2772                }
2773            }
2774        }
2775    };
2776    f.render_widget(Paragraph::new(left), left_area);
2777}
2778
2779fn draw_download_progress(
2780    f: &mut Frame,
2781    progress: crate::update::DownloadProgress,
2782    theme: &Theme,
2783    area: Rect,
2784) {
2785    let Some(total) = progress.total.filter(|total| *total > 0) else {
2786        f.render_widget(
2787            Paragraph::new(Line::from(Span::styled(
2788                format!(
2789                    "Downloading update… {}",
2790                    readable_bytes(progress.downloaded)
2791                ),
2792                theme.accent_text(),
2793            )))
2794            .centered(),
2795            area,
2796        );
2797        return;
2798    };
2799    let ratio = progress.downloaded.min(total) as f64 / total as f64;
2800    let percent = (ratio * 100.0).round() as u64;
2801    let label = format!("Downloading update {percent}%");
2802    f.render_widget(
2803        Gauge::default()
2804            .ratio(ratio)
2805            .label(label)
2806            .use_unicode(true)
2807            .style(Style::new().fg(theme.muted_color()))
2808            .gauge_style(theme.accent_text().add_modifier(Modifier::BOLD)),
2809        area,
2810    );
2811}
2812
2813fn readable_bytes(bytes: u64) -> String {
2814    const MIB: u64 = 1024 * 1024;
2815    const KIB: u64 = 1024;
2816    if bytes >= MIB {
2817        format!("{:.1} MiB", bytes as f64 / MIB as f64)
2818    } else if bytes >= KIB {
2819        format!("{:.1} KiB", bytes as f64 / KIB as f64)
2820    } else {
2821        format!("{bytes} B")
2822    }
2823}
2824
2825/// Dropdown of `/` commands, drawn upward from the status bar.
2826fn draw_slash_palette(f: &mut Frame, app: &mut App, theme: &Theme, status: Rect) {
2827    let query = app.input.value();
2828    let commands = crate::slash::matching(&query);
2829    if commands.is_empty() {
2830        return;
2831    }
2832    let desired_width = commands
2833        .iter()
2834        .map(|command| format!(" /{:<15}{} ", command.usage(), command.hint()).width() as u16)
2835        .max()
2836        .unwrap_or(22)
2837        .saturating_add(3);
2838    let width = desired_width.min(status.width.saturating_sub(2)).max(24);
2839    let height = u16::try_from(commands.len())
2840        .unwrap_or(u16::MAX)
2841        .saturating_add(2)
2842        .min(status.y.max(3));
2843    let rect = Rect {
2844        x: status.x,
2845        y: status.y.saturating_sub(height),
2846        width,
2847        height,
2848    };
2849    app.areas.slash_menu = rect;
2850    let visible_rows = usize::from(height.saturating_sub(2));
2851    let selected_index = app.slash_index.min(commands.len() - 1);
2852    let start = selected_index
2853        .saturating_add(1)
2854        .saturating_sub(visible_rows)
2855        .min(commands.len().saturating_sub(visible_rows));
2856    app.areas.slash_menu_start = start;
2857    let row_width = width.saturating_sub(2) as usize;
2858    let lines: Vec<Line> = commands
2859        .iter()
2860        .enumerate()
2861        .skip(start)
2862        .take(visible_rows)
2863        .map(|(index, cmd)| {
2864            let selected = index == selected_index;
2865            dropdown_row(
2866                theme,
2867                selected,
2868                &format!("/{:<15}", cmd.usage()),
2869                cmd.hint(),
2870                row_width,
2871            )
2872        })
2873        .collect();
2874    let block = Block::bordered()
2875        .border_type(BorderType::Thick)
2876        .border_style(theme.accent_text())
2877        .title(Span::styled(
2878            format!(" /{} ", query),
2879            Style::new().fg(theme.muted_color()),
2880        ));
2881    f.render_widget(Clear, rect);
2882    f.render_widget(Paragraph::new(lines).block(block), rect);
2883    app.areas.occlude_hover(rect);
2884    let inner = rect.inner(Margin {
2885        horizontal: 1,
2886        vertical: 1,
2887    });
2888    for index in start..commands.len().min(start.saturating_add(visible_rows)) {
2889        app.areas.hover_fill(
2890            HoverTarget::SlashCommand(index),
2891            Rect {
2892                y: inner
2893                    .y
2894                    .saturating_add(u16::try_from(index - start).unwrap_or(u16::MAX)),
2895                height: 1,
2896                ..inner
2897            },
2898        );
2899    }
2900}
2901
2902/// One row of a small dropdown: no leading arrow; selection wash runs
2903/// the full inner width so the bar reaches the right border.
2904fn dropdown_row(
2905    theme: &Theme,
2906    selected: bool,
2907    label: &str,
2908    hint: &str,
2909    row_width: usize,
2910) -> Line<'static> {
2911    let label_part = truncate(&format!(" {label} "), row_width);
2912    let hint_space = row_width.saturating_sub(label_part.width());
2913    let hint_part = if hint_space == 0 {
2914        String::new()
2915    } else {
2916        format!("{} ", truncate(hint, hint_space - 1))
2917    };
2918    let used = label_part.width() + hint_part.width();
2919    let pad = " ".repeat(row_width.saturating_sub(used));
2920
2921    let (label_style, hint_style, pad_style) = if selected {
2922        let selection = theme.selection();
2923        (selection, selection, selection)
2924    } else {
2925        (
2926            Style::new(),
2927            Style::new().fg(theme.muted_color()),
2928            Style::new(),
2929        )
2930    };
2931    Line::from(vec![
2932        Span::styled(label_part, label_style),
2933        Span::styled(hint_part, hint_style),
2934        Span::styled(pad, pad_style),
2935    ])
2936}
2937
2938// -------------------------------------------------------------- overlays
2939
2940fn draw_help(f: &mut Frame, app: &mut App, theme: &Theme, area: Rect) {
2941    const COLUMN_WIDTH: usize = 40;
2942    const WIDE_WIDTH: u16 = COLUMN_WIDTH as u16 * 2 + 7;
2943    const NARROW_WIDTH: u16 = 58;
2944
2945    let wide = area.width >= WIDE_WIDTH;
2946    let width = if wide {
2947        WIDE_WIDTH
2948    } else {
2949        NARROW_WIDTH.min(area.width)
2950    };
2951    let mut lines = wordmark_lines(theme, width);
2952    if !lines.is_empty() {
2953        lines.push(Line::raw(""));
2954    }
2955    let row_style = |heading| {
2956        if heading {
2957            theme.accent_text().add_modifier(Modifier::BOLD)
2958        } else {
2959            Style::new()
2960        }
2961    };
2962    if wide {
2963        for banner::HelpRow {
2964            left,
2965            right,
2966            heading,
2967        } in banner::HELP_COLUMNS
2968        {
2969            let style = row_style(heading);
2970            lines.push(Line::from(vec![
2971                Span::raw("  "),
2972                Span::styled(format!("{left:<COLUMN_WIDTH$}"), style),
2973                Span::styled(right, style),
2974            ]));
2975        }
2976        lines.push(Line::raw(""));
2977    } else {
2978        // Stack the paired sections only when two readable columns do not fit.
2979        for side in 0..2 {
2980            for banner::HelpRow {
2981                left,
2982                right,
2983                heading,
2984            } in banner::HELP_COLUMNS
2985            {
2986                let text = if side == 0 { left } else { right };
2987                if text.is_empty() {
2988                    lines.push(Line::raw(""));
2989                    continue;
2990                }
2991                let prefix = if heading { "" } else { "  " };
2992                lines.push(Line::styled(format!("{prefix}{text}"), row_style(heading)));
2993            }
2994            lines.push(Line::raw(""));
2995        }
2996    }
2997    let store = format!("Data store: {}", app.data_dir().display());
2998    lines.push(
2999        Line::styled(
3000            truncate(&store, width.saturating_sub(4) as usize),
3001            Style::new().fg(theme.muted_color()),
3002        )
3003        .centered(),
3004    );
3005    lines.push(Line::styled(banner::HELP_FOOTER, theme.accent_text()).centered());
3006
3007    let height = u16::try_from(lines.len())
3008        .unwrap_or(u16::MAX)
3009        .saturating_add(2)
3010        .min(area.height);
3011    let rect = centered(area, width, height);
3012    let viewport = rect.height.saturating_sub(2) as usize;
3013    let max_scroll = lines.len().saturating_sub(viewport);
3014    app.help_scroll = app.help_scroll.min(max_scroll);
3015    let title = Line::from(vec![
3016        Span::raw(" mach "),
3017        Span::styled(
3018            format!("v{} ", crate::VERSION),
3019            Style::new().fg(theme.muted_color()),
3020        ),
3021    ]);
3022    let block = Block::bordered()
3023        .border_type(BorderType::Thick)
3024        .title(title)
3025        .border_style(theme.accent_text())
3026        .padding(ratatui::widgets::Padding::horizontal(1));
3027    f.render_widget(Clear, rect);
3028    f.render_widget(
3029        Paragraph::new(lines)
3030            .block(block)
3031            .scroll((app.help_scroll.min(u16::MAX as usize) as u16, 0)),
3032        rect,
3033    );
3034}
3035
3036fn draw_settings(f: &mut Frame, app: &App, theme: &Theme, area: Rect) {
3037    let mut lines: Vec<Line> = Vec::new();
3038    for (i, item) in SETTINGS_ITEMS.iter().enumerate() {
3039        let selected = i == app.settings_index;
3040        let value = app.setting_value(i);
3041        let marker = if selected { "❯ " } else { "  " };
3042        let name_style = if selected {
3043            Style::new().add_modifier(Modifier::BOLD)
3044        } else {
3045            Style::new()
3046        };
3047        lines.push(Line::from(vec![
3048            Span::styled(marker, theme.accent_text()),
3049            Span::styled(format!("{item:<14}"), name_style),
3050            Span::styled(value, theme.accent_text()),
3051        ]));
3052    }
3053    lines.push(Line::raw(""));
3054    lines.push(Line::styled(
3055        "↑↓ select · ←→ change · Esc close",
3056        Style::new().fg(theme.muted_color()),
3057    ));
3058
3059    let width = 48.min(area.width);
3060    let height = u16::try_from(lines.len())
3061        .unwrap_or(u16::MAX)
3062        .saturating_add(2)
3063        .min(area.height);
3064    let rect = centered(area, width, height);
3065    let block = Block::bordered()
3066        .border_type(BorderType::Thick)
3067        .title(Line::from(" Settings "))
3068        .border_style(theme.accent_text())
3069        .padding(ratatui::widgets::Padding::horizontal(2));
3070    f.render_widget(Clear, rect);
3071    f.render_widget(Paragraph::new(lines).block(block), rect);
3072}
3073
3074struct LabelManagerLayout {
3075    rect: Rect,
3076    flow: Vec<(String, Rect)>,
3077    flow_rows: u16,
3078}
3079
3080fn label_manager_layout(app: &App, area: Rect) -> LabelManagerLayout {
3081    let editing = app.label_editor.is_some();
3082    let width = 48.min(area.width);
3083    let content_width = width.saturating_sub(4);
3084    let flow = label_flow_layout(&app.labels, content_width);
3085    let flow_rows = flow.last().map_or(1, |(_, rect)| rect.y.saturating_add(1));
3086    let desired_rows = flow_rows.clamp(3, 10);
3087    let height = desired_rows
3088        .saturating_add(if editing {
3089            10 + LABEL_EDITOR_GAP_ROWS
3090        } else {
3091            2
3092        })
3093        .min(area.height);
3094    let rect = centered(area, width, height);
3095    LabelManagerLayout {
3096        rect,
3097        flow,
3098        flow_rows,
3099    }
3100}
3101
3102fn draw_labels(f: &mut Frame, app: &mut App, theme: &Theme, layout: &LabelManagerLayout) {
3103    let editing = app.label_editor.is_some();
3104    let LabelManagerLayout {
3105        rect,
3106        flow,
3107        flow_rows,
3108    } = layout;
3109    let rect = *rect;
3110    app.areas.occlude_hover(rect);
3111    let width = rect.width;
3112    let hint = if let Some(error) = &app.label_error {
3113        Line::styled(
3114            format!(" {} ", truncate(error, width.saturating_sub(4) as usize)),
3115            Style::new()
3116                .fg(theme.error_color())
3117                .add_modifier(Modifier::BOLD),
3118        )
3119    } else if editing {
3120        Line::styled(" Ctrl+S save ", Style::new().fg(theme.muted_color()))
3121    } else {
3122        Line::styled(
3123            if app.settings.show_passive_hints() {
3124                " Ctrl+A new · Backspace delete "
3125            } else {
3126                " Ctrl+A new "
3127            },
3128            Style::new().fg(theme.muted_color()),
3129        )
3130    };
3131    let block = Block::bordered()
3132        .border_type(BorderType::Thick)
3133        .border_style(theme.accent_text())
3134        .title(Span::styled(" Labels ", theme.accent_text().bold()))
3135        .title_bottom(hint.right_aligned())
3136        .padding(Padding::horizontal(1));
3137    let inner = block.inner(rect);
3138    f.render_widget(Clear, rect);
3139    f.render_widget(block, rect);
3140    if inner.width == 0 || inner.height == 0 {
3141        return;
3142    }
3143
3144    let (list_area, input_area) = if editing {
3145        let [list, _, input] = Layout::vertical([
3146            Constraint::Min(1),
3147            Constraint::Length(LABEL_EDITOR_GAP_ROWS),
3148            Constraint::Length(8),
3149        ])
3150        .areas(inner);
3151        (list, Some(input))
3152    } else {
3153        (inner, None)
3154    };
3155
3156    if app.labels.is_empty() {
3157        f.render_widget(
3158            Paragraph::new(Line::styled(
3159                "No labels yet",
3160                Style::new().fg(theme.muted_color()),
3161            )),
3162            list_area,
3163        );
3164    } else {
3165        let selected = app.label_index.min(app.labels.len() - 1);
3166        let visible_rows = list_area.height;
3167        let selected_row = flow.get(selected).map_or(0, |(_, badge)| badge.y);
3168        let start_row = selected_row
3169            .saturating_add(1)
3170            .saturating_sub(visible_rows)
3171            .min(flow_rows.saturating_sub(visible_rows));
3172        for (index, (name, badge)) in flow.iter().enumerate() {
3173            if badge.y < start_row || badge.y >= start_row.saturating_add(visible_rows) {
3174                continue;
3175            }
3176            let screen = Rect {
3177                x: list_area.x.saturating_add(badge.x),
3178                y: list_area.y.saturating_add(badge.y - start_row),
3179                width: badge.width,
3180                height: 1,
3181            };
3182            app.areas.label_hits.push((index, screen));
3183            if index == selected {
3184                app.areas.hover_fill(HoverTarget::Label(index), screen);
3185            } else {
3186                app.areas.hover_badge(HoverTarget::Label(index), screen);
3187            }
3188            let style = if index == selected {
3189                theme.label_focus()
3190            } else {
3191                theme.label_badge(app.labels[index].color, false)
3192            };
3193            f.render_widget(
3194                Paragraph::new(Line::styled(format!(" {name} "), style)),
3195                screen,
3196            );
3197        }
3198        paint_scrollbar(
3199            f,
3200            theme,
3201            rect,
3202            *flow_rows as usize,
3203            visible_rows as usize,
3204            start_row as usize,
3205            true,
3206            1,
3207        );
3208    }
3209
3210    if let Some(input_area) = input_area
3211        && let Some(editor) = &mut app.label_editor
3212    {
3213        let label = if editor.editing_id.is_some() {
3214            "Edit label"
3215        } else {
3216            "New label"
3217        };
3218        let editor_inner = render_field_box(f, field_block(label, true, None, theme), input_area);
3219        let [name_box, color_box] =
3220            Layout::vertical([Constraint::Length(3), Constraint::Length(3)]).areas(editor_inner);
3221        let name_area = render_field_box(
3222            f,
3223            field_block("Name", !editor.color_focused, None, theme),
3224            name_box,
3225        );
3226        let color_area = render_field_box(
3227            f,
3228            field_block("Color", editor.color_focused, None, theme),
3229            color_box,
3230        );
3231        app.areas.label_name_input = name_area;
3232        draw_text_input(
3233            f,
3234            &mut editor.name,
3235            name_area,
3236            "",
3237            !editor.color_focused,
3238            theme,
3239        );
3240
3241        const SWATCH_SLOT_WIDTH: u16 = 3;
3242        let palette_width = SWATCH_SLOT_WIDTH * LabelColor::SWATCHES.len() as u16;
3243        if color_area.width >= palette_width {
3244            let ring_style = if editor.color_focused {
3245                theme.accent_text().bold()
3246            } else {
3247                Style::new().fg(theme.muted_color())
3248            };
3249            let mut x = color_area
3250                .x
3251                .saturating_add(color_area.width.saturating_sub(palette_width) / 2);
3252            for color in LabelColor::SWATCHES {
3253                let slot = Rect {
3254                    x,
3255                    y: color_area.y,
3256                    width: SWATCH_SLOT_WIDTH,
3257                    height: 1,
3258                };
3259                app.areas.label_color_hits.push((color, slot));
3260                let selected = color == editor.color;
3261                f.render_widget(
3262                    Paragraph::new(Line::from(vec![
3263                        Span::styled(if selected { "[" } else { " " }, ring_style),
3264                        Span::styled("■", theme.label_swatch(color)),
3265                        Span::styled(if selected { "]" } else { " " }, ring_style),
3266                    ])),
3267                    slot,
3268                );
3269                x = x.saturating_add(SWATCH_SLOT_WIDTH);
3270            }
3271        }
3272    }
3273}
3274
3275fn label_flow_layout(labels: &[crate::model::Label], width: u16) -> Vec<(String, Rect)> {
3276    if width == 0 {
3277        return Vec::new();
3278    }
3279    let mut x: u16 = 0;
3280    let mut y: u16 = 0;
3281    labels
3282        .iter()
3283        .map(|label| {
3284            let name = truncate(&label.name, width.saturating_sub(2) as usize);
3285            let badge_width = u16::try_from(name.width())
3286                .unwrap_or(u16::MAX)
3287                .saturating_add(2)
3288                .min(width);
3289            if x > 0 && x.saturating_add(1).saturating_add(badge_width) > width {
3290                x = 0;
3291                y = y.saturating_add(1);
3292            } else if x > 0 {
3293                x = x.saturating_add(1);
3294            }
3295            let rect = Rect {
3296                x,
3297                y,
3298                width: badge_width,
3299                height: 1,
3300            };
3301            x = x.saturating_add(badge_width);
3302            (name, rect)
3303        })
3304        .collect()
3305}
3306
3307fn draw_welcome(f: &mut Frame, app: &App, theme: &Theme, area: Rect) {
3308    let mut lines = wordmark_lines(theme, area.width);
3309    if !lines.is_empty() {
3310        lines.push(Line::raw(""));
3311    }
3312    lines.push(
3313        Line::styled(
3314            format!("Welcome to mach v{}", crate::VERSION),
3315            Style::new().add_modifier(Modifier::BOLD),
3316        )
3317        .centered(),
3318    );
3319    lines.push(Line::raw(""));
3320    lines.push(Line::raw("Written in Rust with ratatui.").centered());
3321    let storage = format!("Your tasks stay local in {}.", app.data_dir().display());
3322    lines.push(Line::raw(storage.clone()).centered());
3323    lines.push(Line::raw(""));
3324    lines.push(
3325        Line::styled(
3326            "Press Enter to start · /help for the key list",
3327            Style::new().fg(theme.muted_color()),
3328        )
3329        .centered(),
3330    );
3331
3332    let width = u16::try_from(storage.width())
3333        .unwrap_or(u16::MAX)
3334        .saturating_add(4)
3335        .max(50)
3336        .min(area.width);
3337    let height = u16::try_from(lines.len())
3338        .unwrap_or(u16::MAX)
3339        .saturating_add(2)
3340        .min(area.height);
3341    let rect = centered(area, width, height);
3342    let block = Block::bordered()
3343        .border_type(BorderType::Thick)
3344        .border_style(theme.accent_text());
3345    f.render_widget(Clear, rect);
3346    f.render_widget(Paragraph::new(lines).block(block), rect);
3347}
3348
3349fn wordmark_lines(theme: &Theme, available_width: u16) -> Vec<Line<'static>> {
3350    if available_width < banner::BANNER_WIDTH + 8 {
3351        return Vec::new();
3352    }
3353    banner::BANNER
3354        .iter()
3355        .map(|row| Line::styled(*row, theme.accent_text()).centered())
3356        .collect()
3357}
3358
3359fn draw_whats_new(f: &mut Frame, theme: &Theme, area: Rect) {
3360    const OVERLAY_WIDTH: u16 = 62;
3361    const BULLET_PREFIX: &str = "• ";
3362    const DESCRIPTION_PREFIX: &str = "  ";
3363    const RELEASE_NOTES_LABEL: &str = "Full release notes:";
3364    const CONTINUE_HINT: &str = "Press Enter or Esc to continue";
3365
3366    let heading = format!("What's new in mach v{}", crate::VERSION);
3367    let release_url = format!("github.com/Q1CHENL/mach/releases/tag/v{}", crate::VERSION);
3368    let width = OVERLAY_WIDTH.min(area.width);
3369    let block = Block::bordered()
3370        .border_type(BorderType::Thick)
3371        .border_style(theme.accent_text())
3372        .padding(Padding::horizontal(2));
3373    let content_width = usize::from(block.inner(Rect::new(0, 0, width, area.height)).width);
3374    let description_width = content_width.saturating_sub(DESCRIPTION_PREFIX.width());
3375
3376    let mut lines = vec![
3377        Line::styled(heading, Style::new().add_modifier(Modifier::BOLD)).centered(),
3378        Line::raw(""),
3379    ];
3380    for (index, (title, description)) in banner::WHATS_NEW.into_iter().enumerate() {
3381        lines.push(Line::from(vec![
3382            Span::styled(BULLET_PREFIX, theme.accent_text()),
3383            Span::styled(title, Style::new().add_modifier(Modifier::BOLD)),
3384        ]));
3385        let graphemes = description
3386            .graphemes(true)
3387            .map(str::to_owned)
3388            .collect::<Vec<_>>();
3389        lines.extend(
3390            crate::text_input::wrap_breaks(&graphemes, description_width)
3391                .into_iter()
3392                .map(|(start, end)| {
3393                    let text = graphemes[start..end].concat();
3394                    Line::raw(format!("{DESCRIPTION_PREFIX}{}", text.trim_end()))
3395                }),
3396        );
3397        if index + 1 < banner::WHATS_NEW.len() {
3398            lines.push(Line::raw(""));
3399        }
3400    }
3401    lines.push(Line::raw(""));
3402    lines.push(Line::styled(RELEASE_NOTES_LABEL, Style::new().fg(theme.muted_color())).centered());
3403    lines.push(Line::styled(release_url, Style::new().fg(theme.muted_color())).centered());
3404    lines.push(Line::styled(CONTINUE_HINT, Style::new().fg(theme.muted_color())).centered());
3405
3406    if lines.len().saturating_add(2) > usize::from(area.height) {
3407        lines.retain(|line| line.width() > 0);
3408    }
3409    let height = u16::try_from(lines.len())
3410        .unwrap_or(u16::MAX)
3411        .saturating_add(2)
3412        .min(area.height);
3413    let rect = centered(area, width, height);
3414    f.render_widget(Clear, rect);
3415    f.render_widget(Paragraph::new(lines).block(block), rect);
3416}
3417
3418// ----------------------------------------------------------------- utils
3419
3420fn draw_box(f: &mut Frame, area: Rect, text: &str, style: Style) {
3421    let width = u16::try_from(text.width())
3422        .unwrap_or(u16::MAX)
3423        .saturating_add(8)
3424        .min(area.width);
3425    let rect = centered(area, width, 3);
3426    let block = Block::bordered()
3427        .border_type(BorderType::Thick)
3428        .border_style(style);
3429    f.render_widget(Clear, rect);
3430    f.render_widget(
3431        Paragraph::new(Line::styled(text.to_string(), style))
3432            .centered()
3433            .block(block),
3434        rect,
3435    );
3436}
3437
3438pub fn centered(area: Rect, width: u16, height: u16) -> Rect {
3439    let width = width.min(area.width);
3440    let height = height.min(area.height);
3441    Rect {
3442        x: area.x.saturating_add((area.width - width) / 2),
3443        y: area.y.saturating_add((area.height - height) / 2),
3444        width,
3445        height,
3446    }
3447}
3448
3449/// Cut a string to a display width without splitting a grapheme cluster.
3450pub fn truncate(s: &str, width: usize) -> String {
3451    if s.width() <= width {
3452        return s.to_string();
3453    }
3454    let mut out = String::new();
3455    let mut used = 0;
3456    for grapheme in s.graphemes(true) {
3457        let w = grapheme.width();
3458        if used + w > width {
3459            break;
3460        }
3461        used += w;
3462        out.push_str(grapheme);
3463    }
3464    out
3465}