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