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::{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::{App, Focus, MessageKind, Mode, SETTINGS_ITEMS, UpdateActivity};
19use crate::banner;
20use crate::due;
21use crate::form::Field;
22use crate::theme::Theme;
23
24/// Outer width of the sidebar, borders and padding included.
25pub const SIDEBAR_WIDTH: u16 = 26;
26/// `[ ]` / `[✓]` in the task list and body subtasks.
27pub const DONE_MARK_WIDTH: u16 = 3;
28/// Right column shorter than this → no bottom preview (list only).
29const PREVIEW_SPLIT_MIN: u16 = 16;
30/// Minimum height of the list half when the preview is below.
31const LIST_MIN: u16 = 6;
32/// Minimum height of the preview / docked editor half (bottom layout).
33const PREVIEW_MIN: u16 = 8;
34/// Minimum list width when the preview sits to the right.
35const LIST_WIDTH_MIN: u16 = 24;
36/// Minimum preview width when docked on the right.
37const PREVIEW_WIDTH_MIN: u16 = 28;
38/// Whole right column narrower than this → no side preview.
39const PREVIEW_SIDE_MIN: u16 = LIST_WIDTH_MIN + PREVIEW_WIDTH_MIN + 1;
40pub const MIN_TERMINAL_WIDTH: u16 = 60;
41pub const MIN_TERMINAL_HEIGHT: u16 = 16;
42
43pub fn draw(f: &mut Frame, app: &mut App) {
44    let area = f.area();
45    // Every frame owns its hit targets. Hidden overlays and undersized
46    // terminals must never retain clickable geometry from an older frame.
47    app.areas = crate::app::Areas::default();
48    if let Some(form) = &mut app.form {
49        form.areas = crate::form::FieldAreas::default();
50        form.body_menu_area = None;
51        form.image_hits.clear();
52        if let Some(picker) = &mut form.picker {
53            picker.layout = crate::duepicker::PickerLayout::default();
54        }
55    }
56    if let Some(form) = &mut app.category_form {
57        form.name_area = Rect::ZERO;
58        form.description_area = Rect::ZERO;
59    }
60
61    if area.width < MIN_TERMINAL_WIDTH || area.height < MIN_TERMINAL_HEIGHT {
62        let p = Paragraph::new(format!(
63            "too small · need {MIN_TERMINAL_WIDTH}×{MIN_TERMINAL_HEIGHT}"
64        ))
65        .centered();
66        f.render_widget(p, area);
67        return;
68    }
69
70    let theme = app.theme();
71    let [content, status] =
72        Layout::vertical([Constraint::Min(3), Constraint::Length(3)]).areas(area);
73    // The panels sit against each other: two borders is already a
74    // divider, a gap on top of that is just slack.
75    // A column of air between the panels keeps each one's focus colour
76    // unambiguous.
77    let [sidebar, right] =
78        Layout::horizontal([Constraint::Length(SIDEBAR_WIDTH), Constraint::Min(20)])
79            .spacing(1)
80            .areas(content);
81
82    let mut modal_task_form = false;
83    draw_sidebar(f, app, &theme, sidebar);
84    if let Some((list, preview_rect)) =
85        split_tasks_and_preview(right, &app.settings.preview_position)
86    {
87        app.areas.preview = preview_rect;
88        draw_tasks(f, app, &theme, list);
89        match app.mode {
90            Mode::TaskForm => match docked_task_form_layout(preview_rect) {
91                Some(layout) => draw_task_form(f, app, &theme, preview_rect, layout),
92                None => {
93                    draw_task_preview(f, app, &theme, preview_rect);
94                    modal_task_form = true;
95                }
96            },
97            _ => draw_task_preview(f, app, &theme, preview_rect),
98        }
99    } else {
100        app.areas.preview = Rect::ZERO;
101        draw_tasks(f, app, &theme, right);
102        if app.mode == Mode::TaskForm {
103            modal_task_form = true;
104        }
105    }
106    draw_status(f, app, &theme, status);
107    // Palette floats above the status bar.
108    if app.mode == Mode::Slash {
109        draw_slash_palette(f, app, &theme, status);
110    }
111
112    match app.mode {
113        Mode::Help => draw_help(f, app, &theme, area),
114        Mode::Settings => draw_settings(f, app, &theme, area),
115        Mode::Welcome => draw_welcome(f, &theme, area),
116        Mode::WhatsNew => draw_whats_new(f, &theme, area),
117        Mode::CategoryForm => draw_category_form(f, app, &theme, area),
118        Mode::TaskForm if modal_task_form => {
119            // Draw the fallback last, over the intact panels and task preview.
120            draw_task_form(f, app, &theme, area, TaskFormLayout::Modal);
121        }
122        Mode::TaskForm => {} // Already drawn in the task preview pane.
123        _ => {}
124    }
125}
126
127/// Split the right column into task list + preview when there is room.
128/// `position` is `"bottom"` (default) or `"right"`. Falls back to bottom
129/// when a side-by-side split will not fit, then to no preview.
130fn split_tasks_and_preview(right: Rect, position: &str) -> Option<(Rect, Rect)> {
131    if position == "right"
132        && let Some(pair) = split_preview_right(right)
133    {
134        return Some(pair);
135    }
136    split_preview_bottom(right)
137}
138
139fn split_preview_bottom(right: Rect) -> Option<(Rect, Rect)> {
140    if right.height < PREVIEW_SPLIT_MIN {
141        return None;
142    }
143    let [list, preview] = Layout::vertical([
144        Constraint::Min(LIST_MIN),
145        Constraint::Length((right.height / 2).max(PREVIEW_MIN)),
146    ])
147    .spacing(0)
148    .areas(right);
149    if list.height < LIST_MIN || preview.height < PREVIEW_MIN {
150        return None;
151    }
152    Some((list, preview))
153}
154
155fn split_preview_right(right: Rect) -> Option<(Rect, Rect)> {
156    if right.width < PREVIEW_SIDE_MIN || right.height < PREVIEW_MIN {
157        return None;
158    }
159    let preview_w = (right.width / 2).max(PREVIEW_WIDTH_MIN);
160    let [list, preview] = Layout::horizontal([
161        Constraint::Min(LIST_WIDTH_MIN),
162        Constraint::Length(preview_w),
163    ])
164    .spacing(1)
165    .areas(right);
166    if list.width < LIST_WIDTH_MIN || preview.width < PREVIEW_WIDTH_MIN {
167        return None;
168    }
169    Some((list, preview))
170}
171
172// --------------------------------------------------------- task dialog
173
174const TASK_FORM_WIDE_CHROME: u16 = 9;
175const TASK_FORM_COMPACT_CHROME: u16 = 15;
176const TASK_FORM_MIN_BODY_HEIGHT: u16 = 3;
177const TASK_FORM_WIDE_MIN_WIDTH: u16 = 56;
178
179#[derive(Clone, Copy, Debug, PartialEq, Eq)]
180enum TaskFormLayout {
181    DockedWide,
182    DockedCompact,
183    Modal,
184}
185
186impl TaskFormLayout {
187    fn is_docked(self) -> bool {
188        !matches!(self, Self::Modal)
189    }
190
191    fn is_compact(self) -> bool {
192        matches!(self, Self::DockedCompact)
193    }
194}
195
196fn docked_task_form_layout(area: Rect) -> Option<TaskFormLayout> {
197    if area.width >= TASK_FORM_WIDE_MIN_WIDTH
198        && area.height >= TASK_FORM_WIDE_CHROME + TASK_FORM_MIN_BODY_HEIGHT
199    {
200        Some(TaskFormLayout::DockedWide)
201    } else if area.width >= PREVIEW_WIDTH_MIN
202        && area.height >= TASK_FORM_COMPACT_CHROME + TASK_FORM_MIN_BODY_HEIGHT
203    {
204        Some(TaskFormLayout::DockedCompact)
205    } else {
206        None
207    }
208}
209
210/// Title, category/due/flags metadata, then the body: a free stack of prose,
211/// to-dos and pictures with a `/` menu for making new ones.
212///
213/// Docked layouts fill the permanent task preview pane. The modal layout is
214/// centered over `area` when that pane cannot expose every field honestly.
215fn draw_task_form(f: &mut Frame, app: &mut App, theme: &Theme, area: Rect, layout: TaskFormLayout) {
216    // Disjoint borrows: the form owns the fields, the store owns the
217    // decoded images.
218    let App {
219        form,
220        images: store,
221        ..
222    } = app;
223    let Some(form) = form.as_mut() else { return };
224
225    let rect = if layout.is_docked() {
226        area
227    } else {
228        let width = 92.min(area.width.saturating_sub(4));
229        let body_height = area
230            .height
231            .saturating_sub(TASK_FORM_WIDE_CHROME)
232            .clamp(TASK_FORM_MIN_BODY_HEIGHT, 22);
233        centered(
234            area,
235            width,
236            (TASK_FORM_WIDE_CHROME + body_height).min(area.height),
237        )
238    };
239    let h_pad = if layout.is_docked() { 1 } else { 2 };
240    let block = Block::bordered()
241        .border_type(BorderType::Thick)
242        .border_style(theme.accent_text())
243        .title(Span::styled(
244            format!(" {} ", form.title_text()),
245            theme.accent_text().bold(),
246        ))
247        .padding(Padding::new(h_pad, h_pad, 0, 0));
248    let inner = block.inner(rect);
249    f.render_widget(Clear, rect);
250    f.render_widget(block, rect);
251
252    let (title_box, category_box, due_box, importance_box, body_box, hint) = if layout.is_compact()
253    {
254        let [title, category, due, importance, body, hint] = Layout::vertical([
255            Constraint::Length(3),
256            Constraint::Length(3),
257            Constraint::Length(3),
258            Constraint::Length(3),
259            Constraint::Min(TASK_FORM_MIN_BODY_HEIGHT),
260            Constraint::Length(1),
261        ])
262        .areas(inner);
263        (title, category, due, importance, body, hint)
264    } else {
265        let [title, metadata, body, hint] = Layout::vertical([
266            Constraint::Length(3),
267            Constraint::Length(3),
268            Constraint::Min(TASK_FORM_MIN_BODY_HEIGHT),
269            Constraint::Length(1),
270        ])
271        .areas(inner);
272        // Category takes the rest; Due fits a formatted date+time;
273        // Flags fits ⚑⚑⚑.
274        let [category, due, importance] = Layout::horizontal([
275            Constraint::Fill(1),
276            Constraint::Length(20),
277            Constraint::Length(9),
278        ])
279        .spacing(1)
280        .areas(metadata);
281        (title, category, due, importance, body, hint)
282    };
283
284    // --- title ----------------------------------------------------------
285    let focused = form.field == Field::Title;
286    let box_inner = render_field_box(f, field_block("Title", focused, None, theme), title_box);
287    form.areas.title = box_inner;
288    let view = form.title.visible(box_inner.width as usize);
289    if view.text.is_empty() {
290        render_or_placeholder(f, box_inner, "", "what needs doing?", theme);
291    } else {
292        f.render_widget(
293            Paragraph::new(line_with_selection(
294                &view.text,
295                view.sel_cols,
296                Style::new(),
297                theme,
298            )),
299            box_inner,
300        );
301    }
302    if focused {
303        f.set_cursor_position((box_inner.x.saturating_add(view.cursor_col), box_inner.y));
304    }
305
306    // --- category -------------------------------------------------------
307    let focused = form.field == Field::Category;
308    let box_inner = render_field_box(
309        f,
310        field_block("Category", focused, None, theme),
311        category_box,
312    );
313    form.areas.category = box_inner;
314    let category = format!("‹ {} ›", form.category_label());
315    f.render_widget(
316        Paragraph::new(truncate(&category, box_inner.width as usize)),
317        box_inner,
318    );
319
320    // --- due -------------------------------------------------------------
321    // Picker-only: show the value, no text cursor (Enter / click opens UI).
322    // Store the outer box so the calendar left-aligns with the Due border.
323    let focused = form.field == Field::Due;
324    let box_inner = render_field_box(f, field_block("Due", focused, None, theme), due_box);
325    form.areas.due = due_box;
326    let view = form.due.visible(box_inner.width as usize);
327    render_or_placeholder(f, box_inner, &view.text, "↵ Enter", theme);
328
329    // --- importance ---------------------------------------------------------
330    let focused = form.field == Field::Importance;
331    let box_inner = render_field_box(
332        f,
333        field_block("Flags", focused, None, theme),
334        importance_box,
335    );
336    form.areas.importance = box_inner;
337    let marks = crate::model::importance_marks(form.importance);
338    if marks.is_empty() {
339        render_or_placeholder(f, box_inner, "", "→", theme);
340    } else {
341        f.render_widget(
342            Paragraph::new(Line::styled(marks, Style::new().fg(theme.error_color()))),
343            box_inner,
344        );
345    }
346
347    // --- body --------------------------------------------------------------
348    let focused = form.field == Field::Body;
349    let (done, total) = form.body.progress();
350    let progress = (total > 0).then(|| format!("{done}/{total}"));
351    let box_inner = render_field_box(f, field_block("Body", focused, progress, theme), body_box);
352    form.areas.body = box_inner;
353    draw_body(f, form, store, theme, box_inner, focused);
354    scrollbar(
355        f,
356        theme,
357        body_box,
358        form.body.content_height(),
359        box_inner.height as usize,
360        form.body.scroll(),
361        focused,
362    );
363
364    // --- error or key hints ---------------------------------------------
365    let footer = match &form.error {
366        Some(error) => Line::styled(
367            truncate(error, hint.width as usize),
368            Style::new()
369                .fg(theme.error_color())
370                .add_modifier(Modifier::BOLD),
371        ),
372        None => Line::styled(
373            match layout {
374                TaskFormLayout::DockedWide => "/ commands · Ctrl+Z undo · Ctrl+S save · Esc list",
375                TaskFormLayout::DockedCompact => "/ · Ctrl+S save · Esc list",
376                TaskFormLayout::Modal => "/ commands · Ctrl+Z undo · Ctrl+S save · Esc cancel",
377            },
378            Style::new().fg(theme.muted_color()),
379        ),
380    };
381    f.render_widget(Paragraph::new(footer), hint);
382
383    // Drawn last so it sits over the body box below it.
384    // Picker/image lightbox use the full frame so they are not clipped.
385    let overlay = f.area();
386    if let Some(picker) = form.picker.as_mut() {
387        draw_due_picker(f, theme, picker, form.areas.due, overlay);
388    }
389
390    // Preview the picture the cursor is on, or the first one otherwise.
391    if form.preview
392        && let Some(path) = form
393            .body
394            .selected_image()
395            .or_else(|| form.body.images().first().cloned())
396    {
397        draw_image_preview(f, store, form, theme, &path, overlay);
398    }
399}
400
401/// Read-only view of the selected task in the permanent preview pane.
402fn draw_task_preview(f: &mut Frame, app: &mut App, theme: &Theme, area: Rect) {
403    let focused = false;
404    let block = panel("Task preview", focused, theme);
405    let inner = block.inner(area);
406    f.render_widget(block, area);
407    if inner.height == 0 || inner.width == 0 {
408        return;
409    }
410
411    let Some(task) = app.selected_task().cloned() else {
412        app.invalidate_preview();
413        let style = Style::new().fg(theme.muted_color());
414        draw_box(f, inner, "Select a task · Enter to edit", style);
415        return;
416    };
417
418    // One owned snapshot avoids a second selection lookup and lets the image
419    // cache and preview editor be borrowed independently below.
420    let image_paths: Vec<_> = task
421        .body
422        .iter()
423        .filter_map(|block| match block {
424            crate::model::Block::Image { attachment_id } => Some(app.images.resolve(attachment_id)),
425            _ => None,
426        })
427        .collect();
428    let todo = crate::model::todo_progress(&task);
429    let title = task.title;
430    let done = task.done;
431    let due_s = due::display(&task.due, &app.settings.date_format);
432    let importance = task.importance;
433    let body_empty = task.body.is_empty();
434
435    // Prefetch body pictures so they appear on the next frames.
436    app.images.prefetch(image_paths);
437
438    let flags = crate::model::importance_marks(importance);
439    let mut meta = String::new();
440    if !due_s.is_empty() {
441        meta.push_str(&due_s);
442    }
443    if !flags.is_empty() {
444        if !meta.is_empty() {
445            meta.push_str("  ");
446        }
447        meta.push_str(&flags);
448    }
449    if let Some((d, t)) = todo {
450        if !meta.is_empty() {
451            meta.push_str("  ");
452        }
453        meta.push_str(&format!("{d}/{t}"));
454    }
455
456    let title_style = if done {
457        Style::new()
458            .fg(theme.muted_color())
459            .add_modifier(Modifier::CROSSED_OUT | Modifier::BOLD)
460    } else {
461        Style::new().add_modifier(Modifier::BOLD)
462    };
463
464    let (title_row, meta_row, body_area) = if meta.is_empty() {
465        let [t, b] = Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).areas(inner);
466        (t, None, b)
467    } else {
468        let [t, m, b] = Layout::vertical([
469            Constraint::Length(1),
470            Constraint::Length(1),
471            Constraint::Min(1),
472        ])
473        .areas(inner);
474        (t, Some(m), b)
475    };
476
477    f.render_widget(
478        Paragraph::new(Line::styled(
479            truncate(&title, title_row.width as usize),
480            title_style,
481        )),
482        title_row,
483    );
484    if let Some(meta_row) = meta_row {
485        f.render_widget(
486            Paragraph::new(Line::styled(
487                truncate(&meta, meta_row.width as usize),
488                Style::new().fg(theme.muted_color()),
489            )),
490            meta_row,
491        );
492    }
493
494    if body_area.height == 0 {
495        return;
496    }
497    if body_empty {
498        f.render_widget(
499            Paragraph::new(Line::styled(
500                "Enter to edit",
501                Style::new().fg(theme.muted_color()),
502            )),
503            body_area,
504        );
505        return;
506    }
507
508    app.ensure_preview();
509    let App {
510        images: store,
511        preview_form,
512        ..
513    } = app;
514    if let Some(paint) = preview_form.as_mut() {
515        draw_body(f, paint, store, theme, body_area, false);
516        if paint.body.content_height() > usize::from(body_area.height) && body_area.height > 0 {
517            let indicator = Rect {
518                y: body_area.bottom() - 1,
519                height: 1,
520                ..body_area
521            };
522            f.render_widget(
523                Paragraph::new(Line::styled(
524                    "↓ more · Enter to edit",
525                    Style::new()
526                        .fg(theme.muted_color())
527                        .add_modifier(Modifier::BOLD),
528                )),
529                indicator,
530            );
531        }
532    }
533}
534
535/// One field of a dialog: a rounded box with its name on the border.
536fn field_block<'a>(
537    label: &'a str,
538    focused: bool,
539    note: Option<String>,
540    theme: &Theme,
541) -> Block<'a> {
542    // Thick glyphs (┃/━) — terminal bold barely changes box lines.
543    let (border, label_style) = if focused {
544        (theme.accent_text(), theme.accent_text().bold())
545    } else {
546        (
547            Style::new().fg(theme.muted_color()),
548            Style::new()
549                .fg(theme.muted_color())
550                .add_modifier(Modifier::BOLD),
551        )
552    };
553    let mut block = Block::bordered()
554        .border_type(BorderType::Thick)
555        .border_style(border)
556        .title(Span::styled(format!(" {label} "), label_style))
557        .padding(Padding::horizontal(1));
558    if let Some(note) = note {
559        block = block.title_top(
560            Line::styled(format!(" {note} "), Style::new().fg(theme.muted_color())).right_aligned(),
561        );
562    }
563    block
564}
565
566/// The category dialog: the same shape as a task's, with a name and a
567/// note about what the category is for.
568fn draw_category_form(f: &mut Frame, app: &mut App, theme: &Theme, area: Rect) {
569    let Some(form) = &mut app.category_form else {
570        return;
571    };
572    // Borders (2), name box (3), hint (1).
573    const CHROME: u16 = 6;
574    let text_height = area.height.saturating_sub(CHROME).clamp(3, 12);
575    let width = 72.min(area.width.saturating_sub(4));
576    let rect = centered(area, width, (CHROME + text_height).min(area.height));
577
578    let block = Block::bordered()
579        .border_type(BorderType::Thick)
580        .border_style(theme.accent_text())
581        .title(Span::styled(
582            format!(" {} ", form.title_text()),
583            theme.accent_text().bold(),
584        ))
585        .padding(Padding::horizontal(1));
586    let inner = block.inner(rect);
587    f.render_widget(Clear, rect);
588    f.render_widget(block, rect);
589
590    let [name_box, text_box, hint] = Layout::vertical([
591        Constraint::Length(3),
592        Constraint::Length(text_height),
593        Constraint::Length(1),
594    ])
595    .areas(inner);
596
597    let focused = !form.on_description;
598    let box_inner = render_field_box(f, field_block("Name", focused, None, theme), name_box);
599    form.name_area = box_inner;
600    let view = form.name.visible(box_inner.width as usize);
601    if view.text.is_empty() {
602        render_or_placeholder(f, box_inner, "", "What to call it", theme);
603    } else {
604        f.render_widget(
605            Paragraph::new(line_with_selection(
606                &view.text,
607                view.sel_cols,
608                Style::new(),
609                theme,
610            )),
611            box_inner,
612        );
613    }
614    if focused {
615        f.set_cursor_position((box_inner.x.saturating_add(view.cursor_col), box_inner.y));
616    }
617
618    let focused = form.on_description;
619    let box_inner = render_field_box(
620        f,
621        field_block("Description", focused, None, theme),
622        text_box,
623    );
624    form.description_area = box_inner;
625    let (lines, cursor) = form
626        .description
627        .layout(box_inner.width as usize, box_inner.height);
628    if form.description.is_empty() && form.description.menu.is_none() {
629        render_or_placeholder(f, box_inner, "", "Press / for commands", theme);
630    }
631    for placed in lines {
632        if matches!(placed.block, crate::body::Painted::Text { .. }) {
633            draw_placed_text(f, theme, box_inner, &placed);
634        }
635    }
636    if let (true, Some((row, col))) = (focused, cursor) {
637        f.set_cursor_position((
638            box_inner.x.saturating_add(col),
639            box_inner.y.saturating_add(row),
640        ));
641    }
642    if focused {
643        draw_slash_menu(f, &form.description, theme, box_inner, cursor);
644    }
645    scrollbar(
646        f,
647        theme,
648        text_box,
649        form.description.content_height(),
650        box_inner.height as usize,
651        form.description.scroll(),
652        focused,
653    );
654
655    let footer = match &form.error {
656        Some(error) => Line::styled(
657            truncate(error, hint.width as usize),
658            Style::new()
659                .fg(theme.error_color())
660                .add_modifier(Modifier::BOLD),
661        ),
662        None => Line::styled(
663            "/ commands · Ctrl+Z undo · Ctrl+S save · Esc cancel",
664            Style::new().fg(theme.muted_color()),
665        ),
666    };
667    f.render_widget(Paragraph::new(footer), hint);
668}
669
670/// The stack of blocks, plus the `/` menu when it is open.
671fn draw_body(
672    f: &mut Frame,
673    form: &mut crate::form::TaskForm,
674    store: &mut crate::image::ImageStore,
675    theme: &Theme,
676    area: Rect,
677    focused: bool,
678) {
679    let menu_open = form.body.menu.is_some();
680    if form.body.is_empty() && form.body.menu.is_none() {
681        render_or_placeholder(f, area, "", "Press / for commands", theme);
682    }
683    let (blocks, cursor) = form.body.layout(area.width as usize, area.height);
684    let scroll = form.body.scroll();
685    // Graphics protocols ignore cell Clear. Drop placements when the `/`
686    // menu closes or the body scrolls (pictures shrink/move) so the next
687    // get re-emits cleanly. Pixels stay in RAM — encode only.
688    if (form.menu_was_open && !menu_open) || form.body_scroll != scroll {
689        store.clear_cache();
690        f.render_widget(Clear, area);
691    }
692    form.menu_was_open = menu_open;
693    form.body_scroll = scroll;
694    // Only hide images the dropdown actually covers. Graphics protocols
695    // cannot be "punched" cleanly, so an overlapping image becomes a
696    // compact marker; anything the menu does not touch stays real.
697    let menu_rect = slash_menu_rect(&form.body, area, cursor);
698    form.body_menu_area = menu_rect;
699    form.image_hits.clear();
700    for placed in blocks {
701        match &placed.block {
702            crate::body::Painted::Image(path) => {
703                let row = Rect {
704                    y: area.y.saturating_add(placed.y),
705                    height: placed.rows,
706                    ..area
707                };
708                let covered = menu_rect.is_some_and(|m| rects_overlap(m, row));
709                // Frame + type label only while the body field owns focus
710                // and the cursor is on this picture — not when the dialog
711                // opens on Title with the cursor still sitting on line 0.
712                let show_frame = focused && placed.selected;
713                if covered {
714                    f.render_widget(Clear, row);
715                    let hit = letterbox_rect(row, 4, 3);
716                    draw_image_placeholder(f, theme, hit, show_frame);
717                    form.image_hits.push((placed.line, hit));
718                } else if let Some(hit) = draw_image(f, store, theme, path, row, show_frame) {
719                    form.image_hits.push((placed.line, hit));
720                }
721            }
722            crate::body::Painted::Text { .. } => {
723                draw_placed_text(f, theme, area, &placed);
724            }
725        }
726    }
727    if focused && let Some((row, col)) = cursor {
728        f.set_cursor_position((area.x.saturating_add(col), area.y.saturating_add(row)));
729    }
730
731    draw_slash_menu(f, &form.body, theme, area, cursor);
732}
733
734fn rects_overlap(a: Rect, b: Rect) -> bool {
735    a.x < b.right() && b.x < a.right() && a.y < b.bottom() && b.y < a.bottom()
736}
737
738/// Screen rect of the open `/` dropdown, if any.
739fn slash_menu_rect(
740    body: &crate::body::BodyEditor,
741    area: Rect,
742    cursor: Option<(u16, u16)>,
743) -> Option<Rect> {
744    body.menu.as_ref()?;
745    let commands = body.menu_commands();
746    if commands.is_empty() {
747        return None;
748    }
749    let width = 48.min(area.width);
750    let height = u16::try_from(commands.len())
751        .unwrap_or(u16::MAX)
752        .saturating_add(2);
753    let cursor_row = cursor.map(|(row, _)| row).unwrap_or(0);
754    let below = area.y.saturating_add(cursor_row).saturating_add(1);
755    let y = if area.bottom().saturating_sub(below) >= height {
756        below
757    } else {
758        area.y.saturating_add(cursor_row).saturating_sub(height)
759    };
760    Some(Rect {
761        x: area.x.saturating_add(
762            cursor
763                .map(|(_, col)| col)
764                .unwrap_or(0)
765                .min(area.width.saturating_sub(width)),
766        ),
767        y,
768        width,
769        height,
770    })
771}
772
773/// Soft-wrapped text / list / link block (body and category description).
774fn draw_placed_text(f: &mut Frame, theme: &Theme, area: Rect, placed: &crate::body::Placed) {
775    let crate::body::Painted::Text { rows, kind } = &placed.block else {
776        return;
777    };
778    let indent = kind.indent();
779    let max_rows = placed.rows as usize;
780    for (i, wr) in rows.iter().enumerate().take(max_rows) {
781        let y = area
782            .y
783            .saturating_add(placed.y)
784            .saturating_add(u16::try_from(i).unwrap_or(u16::MAX));
785        if y >= area.bottom() {
786            break;
787        }
788        let row = Rect {
789            x: area.x,
790            y,
791            width: area.width,
792            height: 1,
793        };
794        let base = match kind {
795            crate::body::TextKind::Link => Style::new()
796                .fg(theme.accent)
797                .add_modifier(Modifier::UNDERLINED),
798            crate::body::TextKind::Todo { done: true } => Style::new()
799                .fg(theme.muted_color())
800                .add_modifier(Modifier::CROSSED_OUT),
801            _ => Style::new(),
802        };
803        let body = line_with_selection(&wr.text, wr.sel, base, theme);
804        let line = if i == 0 {
805            match kind {
806                crate::body::TextKind::Todo { done: true } => Line::from(
807                    [
808                        vec![Span::styled("[✓] ", Style::new().fg(theme.success_color()))],
809                        body.spans,
810                    ]
811                    .concat(),
812                ),
813                crate::body::TextKind::Todo { done: false } => Line::from(
814                    [
815                        vec![Span::styled("[ ] ", Style::new().fg(theme.muted_color()))],
816                        body.spans,
817                    ]
818                    .concat(),
819                ),
820                crate::body::TextKind::Bullet => Line::from(
821                    [
822                        vec![Span::styled("• ", Style::new().fg(theme.muted_color()))],
823                        body.spans,
824                    ]
825                    .concat(),
826                ),
827                crate::body::TextKind::Number(n) => Line::from(
828                    [
829                        vec![Span::styled(
830                            format!("{n}. "),
831                            Style::new().fg(theme.muted_color()),
832                        )],
833                        body.spans,
834                    ]
835                    .concat(),
836                ),
837                crate::body::TextKind::Link => Line::from(
838                    [
839                        vec![Span::styled("↗ ", Style::new().fg(theme.muted_color()))],
840                        body.spans,
841                    ]
842                    .concat(),
843                ),
844                crate::body::TextKind::Plain => body,
845            }
846        } else if indent > 0 {
847            // Continuation rows line up under the text, past the prefix.
848            Line::from([vec![Span::raw(" ".repeat(indent))], body.spans].concat())
849        } else {
850            body
851        };
852        f.render_widget(Paragraph::new(line), row);
853    }
854}
855
856/// Compact cell stand-in when the `/` menu covers an image slot.
857fn draw_image_placeholder(f: &mut Frame, theme: &Theme, area: Rect, selected: bool) {
858    if area.width == 0 || area.height == 0 {
859        return;
860    }
861    let rect = Rect { height: 1, ..area };
862    let style = if selected {
863        theme.accent_text()
864    } else {
865        Style::new().fg(theme.muted_color())
866    };
867    f.render_widget(Clear, rect);
868    f.render_widget(Paragraph::new(Line::styled(" [image] ", style)), rect);
869}
870
871/// Full-size stand-in while a body/preview image is loading or failed.
872enum ImageSlotKind<'a> {
873    Loading,
874    Broken { detail: &'a str },
875}
876
877/// Letterbox a content box into `area` (after a 1-cell frame margin),
878/// matching how real pictures are laid out. `aspect_w` / `aspect_h` are
879/// relative; unknown images use 4×3.
880fn letterbox_rect(area: Rect, aspect_w: u16, aspect_h: u16) -> Rect {
881    if area.width < 3 || area.height < 3 {
882        return area;
883    }
884    let inner = area.inner(Margin {
885        horizontal: 1,
886        vertical: 1,
887    });
888    let aw = u32::from(aspect_w.max(1));
889    let ah = u32::from(aspect_h.max(1));
890    let iw = u32::from(inner.width);
891    let ih = u32::from(inner.height);
892    let (pw, ph) = if iw * ah <= ih * aw {
893        let pw = iw;
894        let ph = (iw * ah / aw).clamp(1, ih);
895        (pw as u16, ph as u16)
896    } else {
897        let ph = ih;
898        let pw = (ih * aw / ah).clamp(1, iw);
899        (pw as u16, ph as u16)
900    };
901    centered(inner, pw, ph)
902}
903
904/// Outer area used by preview stand-ins (inner content + 1-cell frame).
905fn preview_slot_area(inner: Rect) -> Rect {
906    Rect {
907        x: inner.x.saturating_sub(1),
908        y: inner.y.saturating_sub(1),
909        width: inner.width.saturating_add(2),
910        height: inner.height.saturating_add(2),
911    }
912}
913
914fn draw_image_slot(
915    f: &mut Frame,
916    theme: &Theme,
917    area: Rect,
918    kind: ImageSlotKind<'_>,
919    selected: bool,
920) {
921    if area.width < 3 || area.height < 2 {
922        return;
923    }
924    let border = if selected {
925        theme.accent_text()
926    } else {
927        Style::new().fg(theme.muted_color())
928    };
929    let (icon, title, title_style, detail) = match kind {
930        ImageSlotKind::Loading => ("▢", "loading", Style::new().fg(theme.muted_color()), None),
931        ImageSlotKind::Broken { detail } => (
932            "✕",
933            "broken image",
934            Style::new().fg(theme.error_color()),
935            Some(detail),
936        ),
937    };
938    let block = Block::bordered()
939        .border_type(BorderType::Rounded)
940        .border_style(border);
941    let inner = block.inner(area);
942    f.render_widget(Clear, area);
943    f.render_widget(block, area);
944    if inner.width == 0 || inner.height == 0 {
945        return;
946    }
947
948    let mut lines: Vec<Line> = Vec::new();
949    // Vertical centre: pad, icon, title, optional detail.
950    let content_rows: u16 = if detail.is_some() { 3 } else { 2 };
951    let pad = inner.height.saturating_sub(content_rows) / 2;
952    for _ in 0..pad {
953        lines.push(Line::raw(""));
954    }
955    lines.push(
956        Line::from(Span::styled(
957            truncate(icon, inner.width as usize),
958            title_style,
959        ))
960        .centered(),
961    );
962    lines.push(
963        Line::from(Span::styled(
964            truncate(title, inner.width as usize),
965            title_style,
966        ))
967        .centered(),
968    );
969    if let Some(d) = detail {
970        let d = d.trim();
971        if !d.is_empty() {
972            lines.push(
973                Line::from(Span::styled(
974                    truncate(d, inner.width as usize),
975                    Style::new().fg(theme.muted_color()),
976                ))
977                .centered(),
978            );
979        }
980    }
981    f.render_widget(Paragraph::new(lines), inner);
982}
983
984/// The `/` menu floats under the line being typed on.
985fn draw_slash_menu(
986    f: &mut Frame,
987    body: &crate::body::BodyEditor,
988    theme: &Theme,
989    area: Rect,
990    cursor: Option<(u16, u16)>,
991) {
992    let Some(menu) = &body.menu else { return };
993    let Some(rect) = slash_menu_rect(body, area, cursor) else {
994        return;
995    };
996    let commands = body.menu_commands();
997    // Inner width of a bordered block (no horizontal padding).
998    let row_width = rect.width.saturating_sub(2) as usize;
999    let lines: Vec<Line> = commands
1000        .iter()
1001        .enumerate()
1002        .map(|(i, command)| {
1003            let selected = i == menu.index.min(commands.len() - 1);
1004            dropdown_row(
1005                theme,
1006                selected,
1007                &format!("{:<14}", command.label()),
1008                command.hint(),
1009                row_width,
1010            )
1011        })
1012        .collect();
1013    let block = Block::bordered()
1014        .border_type(BorderType::Thick)
1015        .border_style(theme.accent_text())
1016        .title(Span::styled(
1017            format!(" /{} ", menu.query),
1018            Style::new().fg(theme.muted_color()),
1019        ));
1020    f.render_widget(Clear, rect);
1021    f.render_widget(Paragraph::new(lines).block(block), rect);
1022}
1023
1024/// Calendar + clock, dropped under the due field. Date and time are both
1025/// set here — the Due field itself is not typed into.
1026fn draw_due_picker(
1027    f: &mut Frame,
1028    theme: &Theme,
1029    picker: &mut crate::duepicker::DuePicker,
1030    field: Rect,
1031    area: Rect,
1032) {
1033    use crate::duepicker::{PickerFocus, PickerLayout};
1034
1035    let Some(day) = crate::duepicker::to_time_date(picker.day) else {
1036        return;
1037    };
1038    let mut events = ratatui::widgets::calendar::CalendarEventStore::today(
1039        Style::new().fg(theme.success_color()),
1040    );
1041    // Underlined rather than filled, to match the task list.
1042    events.add(day, theme.selection().add_modifier(Modifier::UNDERLINED));
1043
1044    // Monthly needs 21 columns (` Su Mo …` / 7×3-wide day cells). Borders
1045    // add 2; keep the panel at least that wide so headers and days line up,
1046    // even when the Due field itself is narrower.
1047    const CAL_COLS: u16 = 21;
1048    let width = (CAL_COLS + 2).max(field.width).min(area.width);
1049    // Borders (2) + calendar (8) + blank (1) + clock (1) + title_bottom row.
1050    let height = 13;
1051    let below = field.bottom(); // flush under Due — field already includes its border
1052    let rect = Rect {
1053        // Left-align with the Due field's outer box.
1054        x: field.x.min(area.right().saturating_sub(width)),
1055        y: if area.bottom().saturating_sub(below) >= height {
1056            below
1057        } else {
1058            field.y.saturating_sub(height)
1059        },
1060        width,
1061        height,
1062    };
1063    let block = Block::bordered()
1064        .border_type(BorderType::Thick)
1065        .border_style(theme.accent_text())
1066        .title_bottom(
1067            Line::styled(" Tab · clear(x) ", Style::new().fg(theme.muted_color())).left_aligned(),
1068        );
1069    f.render_widget(Clear, rect);
1070    let inner = block.inner(rect);
1071    f.render_widget(block, rect);
1072
1073    // Calendar (8) + blank gap (1) + clock (1).
1074    let [cal_area, _gap, time_area] = Layout::vertical([
1075        Constraint::Length(8),
1076        Constraint::Length(1),
1077        Constraint::Length(1),
1078    ])
1079    .areas(inner);
1080    let cal_area = Rect {
1081        width: CAL_COLS.min(cal_area.width),
1082        ..cal_area
1083    };
1084    let time_area = Rect {
1085        width: CAL_COLS.min(time_area.width),
1086        ..time_area
1087    };
1088
1089    // Month header (1) + weekdays (1) + day grid — matches Monthly's layout.
1090    let days = Rect {
1091        x: cal_area.x,
1092        y: cal_area.y.saturating_add(2),
1093        width: cal_area.width,
1094        height: cal_area.height.saturating_sub(2),
1095    };
1096
1097    let calendar = ratatui::widgets::calendar::Monthly::new(day, events)
1098        .show_month_header(theme.accent_text().add_modifier(Modifier::BOLD))
1099        .show_weekdays_header(Style::new().fg(theme.muted_color()))
1100        .show_surrounding(
1101            Style::new()
1102                .fg(theme.muted_color())
1103                .add_modifier(Modifier::DIM),
1104        );
1105    f.render_widget(calendar, cal_area);
1106
1107    // Clock only — no "Time" label — centered under the calendar.
1108    let hour = format!("{:02}", picker.hour);
1109    let minute = format!("{:02}", picker.minute);
1110    let unit = |label: &str, on: bool| {
1111        if on {
1112            Span::styled(
1113                label.to_string(),
1114                theme.selection().add_modifier(Modifier::UNDERLINED),
1115            )
1116        } else {
1117            Span::styled(label.to_string(), Style::new())
1118        }
1119    };
1120    let time_line = Line::from(vec![
1121        unit(&hour, picker.focus == PickerFocus::Hour),
1122        Span::styled(":", Style::new().fg(theme.muted_color())),
1123        unit(&minute, picker.focus == PickerFocus::Minute),
1124    ])
1125    .centered();
1126    f.render_widget(Paragraph::new(time_line), time_area);
1127
1128    // Hit targets for "HH" and "MM" within the centered "HH:MM" (5 cells).
1129    let clock_w = 5u16;
1130    let clock_x = time_area
1131        .x
1132        .saturating_add(time_area.width.saturating_sub(clock_w) / 2);
1133    picker.layout = PickerLayout {
1134        frame: rect,
1135        days,
1136        hour: Rect {
1137            x: clock_x,
1138            y: time_area.y,
1139            width: 2,
1140            height: 1,
1141        },
1142        minute: Rect {
1143            x: clock_x.saturating_add(3),
1144            y: time_area.y,
1145            width: 2,
1146            height: 1,
1147        },
1148        time_row: time_area,
1149    };
1150}
1151
1152/// A body image at whatever size the screen allows.
1153fn draw_image_preview(
1154    f: &mut Frame,
1155    store: &mut crate::image::ImageStore,
1156    form: &mut crate::form::TaskForm,
1157    theme: &Theme,
1158    path: &std::path::Path,
1159    area: Rect,
1160) {
1161    let rect = centered(
1162        area,
1163        (u32::from(area.width) * 9 / 10) as u16,
1164        (u32::from(area.height) * 9 / 10) as u16,
1165    );
1166    let title = truncate(
1167        &path.file_name().unwrap_or_default().to_string_lossy(),
1168        rect.width.saturating_sub(10) as usize,
1169    );
1170    let kind = crate::image::type_label(path);
1171    let anim_note = form
1172        .gif
1173        .as_ref()
1174        .map(|(_, g)| g)
1175        .filter(|g| g.is_animated())
1176        .map(|g| format!(" · {}/{}", g.frame_number(), g.frame_count()))
1177        .unwrap_or_default();
1178    let block = Block::bordered()
1179        .border_type(BorderType::Thick)
1180        .border_style(theme.accent_text())
1181        .title(Span::styled(
1182            format!(" {title} "),
1183            theme.accent_text().bold(),
1184        ))
1185        .title_top(
1186            Line::styled(
1187                format!(" {kind}{anim_note} "),
1188                Style::new().fg(theme.muted_color()),
1189            )
1190            .right_aligned(),
1191        )
1192        .title_bottom(
1193            Line::styled(
1194                match form.gif.as_ref().map(|(_, g)| g) {
1195                    Some(g) if g.is_animated() && g.is_paused() => {
1196                        " Esc closes · click/space resume "
1197                    }
1198                    Some(g) if g.is_animated() => " Esc closes · click/space pause ",
1199                    _ => " Esc closes ",
1200                },
1201                Style::new().fg(theme.muted_color()),
1202            )
1203            .right_aligned(),
1204        );
1205    let inner = block.inner(rect);
1206    f.render_widget(Clear, rect);
1207    f.render_widget(block, rect);
1208
1209    // Preview has its own chrome; no selection frame margin.
1210    if let Some((_, gif)) = form.gif.as_ref() {
1211        match store.preview_frame(gif) {
1212            Ok(protocol) => {
1213                let _ = render_protocol(f, protocol, inner, theme, None);
1214            }
1215            Err(err) => {
1216                let slot = letterbox_rect(preview_slot_area(inner), 4, 3);
1217                draw_image_slot(
1218                    f,
1219                    theme,
1220                    slot,
1221                    ImageSlotKind::Broken { detail: &err },
1222                    false,
1223                );
1224            }
1225        }
1226    } else {
1227        match store.get_preview(path) {
1228            crate::image::ImageReady::Ready(protocol) => {
1229                let _ = render_protocol(f, protocol, inner, theme, None);
1230            }
1231            crate::image::ImageReady::Loading => {
1232                // Loading means not cached yet — aspect unknown.
1233                let slot = letterbox_rect(preview_slot_area(inner), 4, 3);
1234                draw_image_slot(f, theme, slot, ImageSlotKind::Loading, false);
1235            }
1236            crate::image::ImageReady::Failed(err) => {
1237                let slot = letterbox_rect(preview_slot_area(inner), 4, 3);
1238                draw_image_slot(
1239                    f,
1240                    theme,
1241                    slot,
1242                    ImageSlotKind::Broken { detail: &err },
1243                    false,
1244                );
1245            }
1246        }
1247    }
1248}
1249
1250/// Draws a decoded image, or a loading / broken stand-in sized like the
1251/// picture. Returns the screen rect of the picture (hit target).
1252fn draw_image(
1253    f: &mut Frame,
1254    store: &mut crate::image::ImageStore,
1255    theme: &Theme,
1256    path: &std::path::Path,
1257    area: Rect,
1258    selected: bool,
1259) -> Option<Rect> {
1260    if area.width < 3 || area.height < 3 {
1261        return None;
1262    }
1263    // Room for the frame is always left, so selecting a picture does not
1264    // change its size.
1265    let inner = area.inner(Margin {
1266        horizontal: 1,
1267        vertical: 1,
1268    });
1269    match store.get(path) {
1270        crate::image::ImageReady::Ready(protocol) => Some(render_protocol(
1271            f,
1272            protocol,
1273            inner,
1274            theme,
1275            selected.then_some(path),
1276        )),
1277        crate::image::ImageReady::Loading => {
1278            // Not in cache yet — aspect unknown until decode finishes.
1279            let slot = letterbox_rect(area, 4, 3);
1280            draw_image_slot(f, theme, slot, ImageSlotKind::Loading, selected);
1281            Some(slot)
1282        }
1283        crate::image::ImageReady::Failed(err) => {
1284            let name = path
1285                .file_name()
1286                .and_then(|n| n.to_str())
1287                .unwrap_or(err.as_str());
1288            let slot = letterbox_rect(area, 4, 3);
1289            draw_image_slot(
1290                f,
1291                theme,
1292                slot,
1293                ImageSlotKind::Broken { detail: name },
1294                selected,
1295            );
1296            Some(slot)
1297        }
1298    }
1299}
1300
1301/// Paints the protocol and returns the interactive rect (frame when
1302/// selected, otherwise the picture itself).
1303fn render_protocol(
1304    f: &mut Frame,
1305    protocol: &mut ratatui_image::protocol::StatefulProtocol,
1306    inner: Rect,
1307    theme: &Theme,
1308    frame: Option<&std::path::Path>,
1309) -> Rect {
1310    // Scale (not Fit): Fit never grows past the source pixel size, so a
1311    // 1920px image on a large terminal only fills part of the preview.
1312    // Scale keeps aspect ratio and uses the full cell area.
1313    let size = protocol.size_for(Resize::Scale(None), inner.as_size());
1314    let picture = centered(
1315        inner,
1316        size.width.min(inner.width),
1317        size.height.min(inner.height),
1318    );
1319    f.render_stateful_widget(
1320        StatefulImage::default().resize(Resize::Scale(None)),
1321        picture,
1322        protocol,
1323    );
1324    let hit = if let Some(path) = frame {
1325        let border = Rect {
1326            x: picture.x.saturating_sub(1),
1327            y: picture.y.saturating_sub(1),
1328            width: picture.width.saturating_add(2),
1329            height: picture.height.saturating_add(2),
1330        };
1331        let kind = crate::image::type_label(path);
1332        f.render_widget(
1333            Block::bordered()
1334                .border_type(BorderType::Thick)
1335                .border_style(theme.accent_text())
1336                .title_top(
1337                    Line::styled(format!(" {kind} "), Style::new().fg(theme.muted_color()))
1338                        .right_aligned(),
1339                ),
1340            border,
1341        );
1342        border
1343    } else {
1344        picture
1345    };
1346    if let Some(Err(err)) = protocol.last_encoding_result() {
1347        let line = Line::styled(
1348            truncate(&format!("image: {err}"), inner.width as usize),
1349            Style::new().fg(theme.error_color()),
1350        );
1351        f.render_widget(Paragraph::new(line), inner);
1352    }
1353    hit
1354}
1355
1356fn render_field_box(f: &mut Frame, block: Block, area: Rect) -> Rect {
1357    let inner = block.inner(area);
1358    f.render_widget(block, area);
1359    inner
1360}
1361
1362/// Draws `text`, or a dim hint at what belongs there when it is empty.
1363/// Split `text` into spans, washing the selection with the theme accent.
1364fn line_with_selection(
1365    text: &str,
1366    sel: Option<(u16, u16)>,
1367    base: Style,
1368    theme: &Theme,
1369) -> Line<'static> {
1370    let Some((a, b)) = sel else {
1371        return Line::from(Span::styled(text.to_string(), base));
1372    };
1373    let a = a as usize;
1374    let b = b as usize;
1375    if a >= b {
1376        return Line::from(Span::styled(text.to_string(), base));
1377    }
1378    let sel_style = theme.selection();
1379    let mut spans = Vec::new();
1380    let mut col = 0usize;
1381    let mut chunk = String::new();
1382    let mut chunk_in_sel = false;
1383    let flush = |spans: &mut Vec<Span<'static>>, chunk: &mut String, in_sel: bool| {
1384        if chunk.is_empty() {
1385            return;
1386        }
1387        let style = if in_sel { sel_style } else { base };
1388        spans.push(Span::styled(std::mem::take(chunk), style));
1389    };
1390    for grapheme in text.graphemes(true) {
1391        let w = grapheme.width();
1392        let in_sel = col >= a && col < b;
1393        if !chunk.is_empty() && in_sel != chunk_in_sel {
1394            flush(&mut spans, &mut chunk, chunk_in_sel);
1395        }
1396        chunk_in_sel = in_sel;
1397        chunk.push_str(grapheme);
1398        col += w;
1399    }
1400    flush(&mut spans, &mut chunk, chunk_in_sel);
1401    Line::from(spans)
1402}
1403
1404fn render_or_placeholder(f: &mut Frame, area: Rect, text: &str, placeholder: &str, theme: &Theme) {
1405    let line = if text.is_empty() {
1406        Line::styled(
1407            truncate(placeholder, area.width as usize),
1408            Style::new()
1409                .fg(theme.muted_color())
1410                .add_modifier(Modifier::DIM),
1411        )
1412    } else {
1413        Line::raw(text.to_string())
1414    };
1415    f.render_widget(Paragraph::new(line), area);
1416}
1417
1418/// A panel: thick border glyphs, title in the top-left, accent colour
1419/// while focused. (Terminal bold barely thickens box-drawing chars.)
1420fn panel<'a>(title: &'a str, focused: bool, theme: &Theme) -> Block<'a> {
1421    let (border, title_style) = if focused {
1422        (theme.accent_text(), theme.accent_text().bold())
1423    } else {
1424        (
1425            Style::new().fg(theme.muted_color()),
1426            Style::new()
1427                .fg(theme.muted_color())
1428                .add_modifier(Modifier::BOLD),
1429        )
1430    };
1431    Block::bordered()
1432        .border_type(BorderType::Thick)
1433        .border_style(border)
1434        .title(Span::styled(format!(" {title} "), title_style))
1435        .padding(Padding::horizontal(1))
1436}
1437
1438/// Panel scrollbar (right border). Accent when focused, grey otherwise.
1439fn scrollbar(
1440    f: &mut Frame,
1441    theme: &Theme,
1442    area: Rect,
1443    total: usize,
1444    visible: usize,
1445    offset: usize,
1446    focused: bool,
1447) {
1448    paint_scrollbar(f, theme, area, total, visible, offset, focused, 1);
1449}
1450
1451#[allow(clippy::too_many_arguments)]
1452fn paint_scrollbar(
1453    f: &mut Frame,
1454    theme: &Theme,
1455    area: Rect,
1456    total: usize,
1457    visible: usize,
1458    offset: usize,
1459    focused: bool,
1460    vertical_margin: u16,
1461) {
1462    // Ratatui's thumb hits the end only when `position == content_length - 1`.
1463    // List/table `offset` runs 0..=(total - visible), so content_length must
1464    // be that range's size (max_offset + 1), not the raw row count — otherwise
1465    // the thumb stops short when you are already on the last row.
1466    let max_offset = total.saturating_sub(visible);
1467    if max_offset == 0 || area.height <= vertical_margin.saturating_mul(2) {
1468        return;
1469    }
1470    let mut state = ScrollbarState::new(max_offset + 1).position(offset.min(max_offset));
1471    let style = if focused {
1472        theme.accent_text()
1473    } else {
1474        Style::new().fg(theme.muted_color())
1475    };
1476    f.render_stateful_widget(
1477        Scrollbar::new(ScrollbarOrientation::VerticalRight)
1478            .symbols(ratatui::symbols::scrollbar::VERTICAL)
1479            .begin_symbol(None)
1480            .end_symbol(None)
1481            .thumb_style(style)
1482            .track_style(style),
1483        area.inner(Margin {
1484            horizontal: 0,
1485            vertical: vertical_margin,
1486        }),
1487        &mut state,
1488    );
1489}
1490
1491// --------------------------------------------------------------- sidebar
1492
1493fn draw_sidebar(f: &mut Frame, app: &mut App, theme: &Theme, area: Rect) {
1494    let focused = app.focus == Focus::Sidebar;
1495    let chrome_focus = focused && !app.mode.command_bar_focused();
1496    let block = panel("Categories", chrome_focus, theme);
1497    let inner = block.inner(area);
1498    app.areas.sidebar = inner;
1499    if inner.height == 0 || inner.width == 0 {
1500        f.render_widget(block, area);
1501        return;
1502    }
1503
1504    let width = inner.width as usize;
1505    // `done/total` per category, right-aligned to the widest score.
1506    let scores: Vec<String> = app
1507        .categories
1508        .iter()
1509        .map(|cat| {
1510            let (done, total) = app.category_progress(&cat.id);
1511            format!("{done}/{total}")
1512        })
1513        .collect();
1514    let count_width = scores.iter().map(|s| s.width()).max().unwrap_or(3).max(3);
1515    let name_field = width.saturating_sub(count_width + 1);
1516    let items: Vec<ListItem> = app
1517        .categories
1518        .iter()
1519        .zip(scores.iter())
1520        .map(|(cat, score)| {
1521            let count = format!("{score:>count_width$}");
1522            let name = truncate(&cat.name, name_field);
1523            let pad = " ".repeat(width.saturating_sub(name.width() + count.width()));
1524            ListItem::new(Line::from(vec![
1525                Span::raw(name),
1526                Span::raw(pad),
1527                Span::styled(count, Style::new().fg(theme.muted_color())),
1528            ]))
1529        })
1530        .collect();
1531    let rows = items.len();
1532
1533    app.cat_state.select(Some(app.cat_index));
1534    let list = List::new(items).block(block).highlight_style(if focused {
1535        theme.selection()
1536    } else {
1537        theme.selection_unfocused()
1538    });
1539    f.render_stateful_widget(list, area, &mut app.cat_state);
1540    scrollbar(
1541        f,
1542        theme,
1543        area,
1544        rows,
1545        inner.height as usize,
1546        app.cat_state.offset(),
1547        chrome_focus,
1548    );
1549}
1550
1551// ----------------------------------------------------------------- tasks
1552
1553fn draw_tasks(f: &mut Frame, app: &mut App, theme: &Theme, area: Rect) {
1554    let focused = app.focus == Focus::Tasks;
1555    // While the task editor is open, dim panel chrome (border / scrollbar)
1556    // but keep the selected-row wash so the edited task stays visible.
1557    let chrome_focus = focused && app.mode != Mode::TaskForm && !app.mode.command_bar_focused();
1558    // The sidebar already says which category is showing; only a search
1559    // needs spelling out up here.
1560    let mut block = panel("Tasks", chrome_focus, theme);
1561    // Search spells out what matched; category notes stay in the editor.
1562    if app.searching {
1563        let context = format!(" search: {} · {} found ", app.search_query, app.view.len());
1564        block = block
1565            .title_top(Line::styled(context, Style::new().fg(theme.muted_color())).right_aligned());
1566    }
1567    let inner = block.inner(area);
1568    app.areas.tasks = inner;
1569    if inner.height == 0 || inner.width == 0 {
1570        f.render_widget(block, area);
1571        return;
1572    }
1573
1574    if app.view.is_empty() {
1575        f.render_widget(block, area);
1576        let text = if app.searching {
1577            banner::NO_SEARCH_RESULTS
1578        } else {
1579            banner::EMPTY_TASKS
1580        };
1581        let style = if chrome_focus {
1582            theme.accent_text()
1583        } else {
1584            Style::new().fg(theme.muted_color())
1585        };
1586        draw_box(f, inner, text, style);
1587        return;
1588    }
1589
1590    // Category is shown as a section header row in All Tasks / search, not a
1591    // per-task suffix. Flags keep a fixed right edge; due dates and body
1592    // markers live inside each task's content cell so metadata on one task
1593    // cannot shorten every other title in the list.
1594    let flags_width = crate::model::MAX_IMPORTANCE as usize;
1595    let presentations: Vec<_> = app
1596        .view
1597        .iter()
1598        .map(|task_index| TaskPresentation::new(&app.tasks[*task_index], &app.settings.date_format))
1599        .collect();
1600
1601    // Preserve a useful title at narrow widths. Flags stay aligned when the
1602    // panel can afford them; row-local metadata decides independently whether
1603    // its complete value fits beside that task's title.
1604    const TITLE_MIN: usize = 8;
1605    let available = inner.width as usize;
1606    let flags_visible = DONE_MARK_WIDTH as usize + 1 + TITLE_MIN + 1 + flags_width <= available;
1607    let mut widths = vec![
1608        Constraint::Length(DONE_MARK_WIDTH), // [ ] / [✓]
1609        Constraint::Fill(1),                 // title + this task's metadata
1610    ];
1611    if flags_visible {
1612        widths.push(Constraint::Length(flags_width as u16));
1613    }
1614    let column_gaps = widths.len().saturating_sub(1);
1615    let content_width = available
1616        .saturating_sub(DONE_MARK_WIDTH as usize)
1617        .saturating_sub(column_gaps)
1618        .saturating_sub(if flags_visible { flags_width } else { 0 });
1619    let mut presentations = presentations.into_iter().enumerate();
1620    let rows: Vec<Row> = app
1621        .list_rows
1622        .iter()
1623        .map(|row| match row {
1624            // Placeholder — the full-width rule is painted after the table
1625            // so column gaps cannot break the line or the title.
1626            crate::app::TaskListRow::Separator { .. } => {
1627                Row::new(std::iter::repeat_n(Cell::new(""), widths.len()))
1628            }
1629            crate::app::TaskListRow::Task(view_idx) => {
1630                let (presentation_index, presentation) = presentations
1631                    .next()
1632                    .expect("task rows and task presentations must stay aligned");
1633                debug_assert_eq!(*view_idx, presentation_index);
1634                task_row(
1635                    presentation,
1636                    theme,
1637                    *view_idx == app.task_index,
1638                    content_width,
1639                    flags_visible,
1640                )
1641            }
1642        })
1643        .collect();
1644    debug_assert!(presentations.next().is_none());
1645    // Selected-row wash stays on during edit; bold only when the list has chrome focus.
1646    let table = Table::new(rows, widths)
1647        .block(block)
1648        .column_spacing(1)
1649        .row_highlight_style(if chrome_focus {
1650            theme.selection()
1651        } else {
1652            theme.selection_unfocused()
1653        });
1654
1655    // Remember where the markers ended up, so a click can find them. The
1656    // flags sit at the right edge, the tick at the left.
1657    app.areas.done_x = Some(inner.x);
1658    app.areas.flag_x = flags_visible.then_some(inner.right().saturating_sub(flags_width as u16));
1659
1660    let vis = app.selected_visual_row();
1661    app.task_state.select(vis);
1662    // Table does `start = offset.min(selected)`, so scrolling up to the first
1663    // task of a group lands on that task and hides the section header above
1664    // it. Pull offset back onto the header first so the header stays in view.
1665    if let Some(vis) = vis {
1666        pin_section_header(app, vis);
1667    }
1668    f.render_stateful_widget(table, area, &mut app.task_state);
1669
1670    // Full-width category rules on top of separator placeholder rows.
1671    let offset = app.task_state.offset();
1672    let rule_style = Style::new().fg(theme.muted_color());
1673    for (vis_i, row) in app.list_rows.iter().enumerate().skip(offset) {
1674        let y = inner
1675            .y
1676            .saturating_add(u16::try_from(vis_i - offset).unwrap_or(u16::MAX));
1677        if y >= inner.bottom() {
1678            break;
1679        }
1680        let crate::app::TaskListRow::Separator { title } = row else {
1681            continue;
1682        };
1683        // Align the name with task titles (after `[ ]` + column gap).
1684        let title_x = (DONE_MARK_WIDTH + 1) as usize;
1685        let line = category_rule(title, inner.width as usize, title_x);
1686        f.render_widget(
1687            Paragraph::new(Span::styled(line, rule_style)),
1688            Rect {
1689                x: inner.x,
1690                y,
1691                width: inner.width,
1692                height: 1,
1693            },
1694        );
1695    }
1696
1697    scrollbar(
1698        f,
1699        theme,
1700        area,
1701        app.list_rows.len(),
1702        inner.height as usize,
1703        app.task_state.offset(),
1704        chrome_focus,
1705    );
1706}
1707
1708/// If `vis` is the first task under a section header, do not let the table
1709/// scroll that header off the top of the viewport.
1710fn pin_section_header(app: &mut App, vis: usize) {
1711    if vis == 0 {
1712        return;
1713    }
1714    let header = vis - 1;
1715    if !matches!(
1716        app.list_rows.get(header),
1717        Some(crate::app::TaskListRow::Separator { .. })
1718    ) {
1719        return;
1720    }
1721    if app.task_state.offset() > header {
1722        *app.task_state.offset_mut() = header;
1723    }
1724}
1725
1726/// The markers shown between a task's title and its due date.
1727fn extras(task: &crate::model::Task) -> String {
1728    let notes = if crate::model::has_prose_or_image(task) {
1729        "≡"
1730    } else {
1731        ""
1732    };
1733    match crate::model::todo_progress(task) {
1734        Some((done, total)) => format!("{notes} {done}/{total}").trim_start().to_string(),
1735        None => notes.to_string(),
1736    }
1737}
1738
1739/// Owned display data derived once for one task during a frame.
1740struct TaskPresentation {
1741    title: String,
1742    extras: String,
1743    due: String,
1744    flags: String,
1745    done: bool,
1746}
1747
1748impl TaskPresentation {
1749    fn new(task: &crate::model::Task, date_format: &str) -> Self {
1750        Self {
1751            title: task.title.clone(),
1752            extras: extras(task),
1753            due: due::display_compact(&task.due, date_format),
1754            flags: crate::model::importance_marks(task.importance),
1755            done: task.done,
1756        }
1757    }
1758}
1759
1760/// Full-width rule with the category name aligned to the title column:
1761/// `─── Mach ────────────────` (space before the name, same column as titles).
1762fn category_rule(title: &str, width: usize, title_x: usize) -> String {
1763    if width == 0 {
1764        return String::new();
1765    }
1766    // One space before the name so it does not touch the rule; the name
1767    // still starts at `title_x` like task titles after `[ ] `.
1768    let label = format!(" {title} ");
1769    let label_w = label.width();
1770    let pad = title_x.saturating_sub(1).min(width);
1771    if pad + label_w >= width {
1772        let head = "─".repeat(pad);
1773        return truncate(&format!("{head}{label}"), width);
1774    }
1775    format!(
1776        "{}{label}{}",
1777        "─".repeat(pad),
1778        "─".repeat(width - pad - label_w)
1779    )
1780}
1781
1782fn task_row(
1783    presentation: TaskPresentation,
1784    theme: &Theme,
1785    selected: bool,
1786    content_width: usize,
1787    flags_visible: bool,
1788) -> Row<'static> {
1789    let TaskPresentation {
1790        title,
1791        extras,
1792        due,
1793        flags,
1794        done,
1795    } = presentation;
1796    // A finished task is muted — but not on the selected row (even when
1797    // Categories has focus), where the tick and strikethrough say enough.
1798    // Due colour belongs to the due label rather than tinting the whole title.
1799    let title_style = if done && !selected {
1800        Style::new().fg(theme.muted_color())
1801    } else {
1802        theme.plain()
1803    };
1804    let title_style = if done {
1805        title_style.add_modifier(Modifier::CROSSED_OUT)
1806    } else {
1807        title_style
1808    };
1809
1810    let mut cells = Vec::with_capacity(5);
1811    let (mark, mark_style) = if done {
1812        ("[✓]", Style::new().fg(theme.success_color()))
1813    } else {
1814        ("[ ]", Style::new().fg(theme.muted_color()))
1815    };
1816    cells.push(Cell::new(mark).style(mark_style));
1817    let metadata_style = if done {
1818        Style::new()
1819            .fg(theme.muted_color())
1820            .add_modifier(Modifier::CROSSED_OUT)
1821    } else {
1822        Style::new().fg(theme.muted_color())
1823    };
1824    let due_style = if done {
1825        title_style
1826    } else {
1827        Style::new().fg(theme.accent)
1828    };
1829    cells.push(Cell::new(task_content_line(
1830        title,
1831        title_style,
1832        extras,
1833        metadata_style,
1834        due,
1835        due_style,
1836        content_width,
1837    )));
1838    if flags_visible {
1839        let flag_style = if done {
1840            metadata_style
1841        } else {
1842            Style::new().fg(theme.error_color())
1843        };
1844        cells.push(Cell::new(Text::from(
1845            Line::from(Span::styled(flags, flag_style)).right_aligned(),
1846        )));
1847    }
1848    Row::new(cells)
1849}
1850
1851/// Build one task's content cell with row-local metadata at its right edge.
1852/// Due is the highest-priority suffix; body/progress markers join it only when
1853/// both complete values fit while retaining a recognisable title.
1854fn task_content_line(
1855    title: String,
1856    title_style: Style,
1857    extras: String,
1858    extras_style: Style,
1859    due: String,
1860    due_style: Style,
1861    width: usize,
1862) -> Line<'static> {
1863    const TITLE_MIN: usize = 8;
1864    const META_GAP: usize = 1;
1865
1866    let title_floor = title.width().min(TITLE_MIN);
1867    let mut show_due = false;
1868    let mut show_extras = false;
1869    let mut metadata_width = 0;
1870
1871    if !due.is_empty() && title_floor + META_GAP + due.width() <= width {
1872        show_due = true;
1873        metadata_width = due.width();
1874    }
1875    if !extras.is_empty() {
1876        let joined_width = if metadata_width == 0 {
1877            extras.width()
1878        } else {
1879            extras.width() + META_GAP + metadata_width
1880        };
1881        if title_floor + META_GAP + joined_width <= width {
1882            show_extras = true;
1883            metadata_width = joined_width;
1884        }
1885    }
1886
1887    if metadata_width == 0 {
1888        return Line::from(Span::styled(truncate(&title, width), title_style));
1889    }
1890
1891    let title_width = width.saturating_sub(META_GAP + metadata_width);
1892    let title = truncate(&title, title_width);
1893    let padding = width.saturating_sub(title.width() + metadata_width);
1894    let mut spans = vec![
1895        Span::styled(title, title_style),
1896        Span::raw(" ".repeat(padding)),
1897    ];
1898    if show_extras {
1899        spans.push(Span::styled(extras, extras_style));
1900        if show_due {
1901            spans.push(Span::raw(" "));
1902        }
1903    }
1904    if show_due {
1905        spans.push(Span::styled(due, due_style));
1906    }
1907    Line::from(spans)
1908}
1909
1910// ------------------------------------------------------------ status bar
1911
1912fn draw_status(f: &mut Frame, app: &mut App, theme: &Theme, area: Rect) {
1913    // The bar is a panel like the others, minus the name: while a
1914    // command or a search is being typed it is what has focus.
1915    let typing = matches!(app.mode, Mode::Slash | Mode::Search);
1916    let update_activity = (!typing).then(|| app.update_activity()).flatten();
1917    let downloading = matches!(update_activity, Some(UpdateActivity::Downloading(_)));
1918    let block = Block::bordered()
1919        .border_type(BorderType::Thick)
1920        .border_style(if typing {
1921            theme.accent_text()
1922        } else {
1923            Style::new().fg(theme.muted_color())
1924        })
1925        .padding(if downloading {
1926            Padding::ZERO
1927        } else {
1928            Padding::horizontal(1)
1929        });
1930    let inner = block.inner(area);
1931    f.render_widget(block, area);
1932    let area = inner;
1933
1934    if let Some(UpdateActivity::Downloading(progress)) = update_activity {
1935        draw_download_progress(f, progress, theme, area);
1936        return;
1937    }
1938
1939    let right = Line::from(Span::styled(
1940        due::now_string(&app.settings.date_format),
1941        Style::new().fg(theme.muted_color()),
1942    ));
1943    let right_width = right.width() as u16;
1944    let [left_area, right_area] = Layout::horizontal([
1945        Constraint::Min(0),
1946        Constraint::Length(right_width.min(area.width)),
1947    ])
1948    .areas(area);
1949    // The clock is display-only, but it is still part of the command bar's
1950    // mouse target. A click there focuses the input and lands at its end.
1951    app.areas.command_bar = area;
1952    f.render_widget(Paragraph::new(right), right_area);
1953
1954    let field = left_area.width.saturating_sub(2) as usize;
1955    let left = match app.mode {
1956        Mode::Slash | Mode::Search => {
1957            let view = app.input.visible(field);
1958            f.set_cursor_position((
1959                left_area
1960                    .x
1961                    .saturating_add(1)
1962                    .saturating_add(view.cursor_col),
1963                left_area.y,
1964            ));
1965            let body = line_with_selection(&view.text, view.sel_cols, Style::new(), theme);
1966            Line::from([vec![Span::styled("/", theme.accent_text())], body.spans].concat())
1967        }
1968        _ if update_activity == Some(UpdateActivity::Checking) => {
1969            Line::from(Span::styled("Checking for updates…", theme.accent_text()))
1970        }
1971        _ => match app.status_message() {
1972            Some((text, kind)) => {
1973                let style = match kind {
1974                    MessageKind::Error => Style::new()
1975                        .fg(theme.error_color())
1976                        .add_modifier(Modifier::BOLD),
1977                    MessageKind::Info => theme.accent_text(),
1978                };
1979                Line::from(Span::styled(truncate(text, field), style))
1980            }
1981            None => {
1982                let hint = if app.searching {
1983                    format!("search: {} · Esc clears", app.search_query)
1984                } else {
1985                    "/ commands".to_string()
1986                };
1987                if (left_area.width as usize) >= hint.width() + 2 {
1988                    Line::from(Span::styled(hint, Style::new().fg(theme.muted_color())))
1989                } else {
1990                    Line::raw("")
1991                }
1992            }
1993        },
1994    };
1995    f.render_widget(Paragraph::new(left), left_area);
1996}
1997
1998fn draw_download_progress(
1999    f: &mut Frame,
2000    progress: crate::update::DownloadProgress,
2001    theme: &Theme,
2002    area: Rect,
2003) {
2004    let Some(total) = progress.total.filter(|total| *total > 0) else {
2005        f.render_widget(
2006            Paragraph::new(Line::from(Span::styled(
2007                format!(
2008                    "Downloading update… {}",
2009                    readable_bytes(progress.downloaded)
2010                ),
2011                theme.accent_text(),
2012            )))
2013            .centered(),
2014            area,
2015        );
2016        return;
2017    };
2018    let ratio = progress.downloaded.min(total) as f64 / total as f64;
2019    let percent = (ratio * 100.0).round() as u64;
2020    let label = format!("Downloading update {percent}%");
2021    f.render_widget(
2022        Gauge::default()
2023            .ratio(ratio)
2024            .label(label)
2025            .use_unicode(true)
2026            .style(Style::new().fg(theme.muted_color()))
2027            .gauge_style(theme.accent_text().add_modifier(Modifier::BOLD)),
2028        area,
2029    );
2030}
2031
2032fn readable_bytes(bytes: u64) -> String {
2033    const MIB: u64 = 1024 * 1024;
2034    const KIB: u64 = 1024;
2035    if bytes >= MIB {
2036        format!("{:.1} MiB", bytes as f64 / MIB as f64)
2037    } else if bytes >= KIB {
2038        format!("{:.1} KiB", bytes as f64 / KIB as f64)
2039    } else {
2040        format!("{bytes} B")
2041    }
2042}
2043
2044/// Dropdown of `/` commands, drawn upward from the status bar.
2045fn draw_slash_palette(f: &mut Frame, app: &mut App, theme: &Theme, status: Rect) {
2046    let query = app.input.value();
2047    let commands = crate::slash::matching(&query);
2048    if commands.is_empty() {
2049        return;
2050    }
2051    let width = 53.min(status.width.saturating_sub(2)).max(24);
2052    let height = u16::try_from(commands.len())
2053        .unwrap_or(u16::MAX)
2054        .saturating_add(2)
2055        .min(status.y.max(3));
2056    let rect = Rect {
2057        x: status.x,
2058        y: status.y.saturating_sub(height),
2059        width,
2060        height,
2061    };
2062    app.areas.slash_menu = rect;
2063    let row_width = width.saturating_sub(2) as usize;
2064    let lines: Vec<Line> = commands
2065        .iter()
2066        .enumerate()
2067        .map(|(i, cmd)| {
2068            let selected = i == app.slash_index.min(commands.len() - 1);
2069            dropdown_row(
2070                theme,
2071                selected,
2072                &format!("/{:<12}", cmd.id()),
2073                cmd.hint(),
2074                row_width,
2075            )
2076        })
2077        .collect();
2078    let block = Block::bordered()
2079        .border_type(BorderType::Thick)
2080        .border_style(theme.accent_text())
2081        .title(Span::styled(
2082            format!(" /{} ", query),
2083            Style::new().fg(theme.muted_color()),
2084        ));
2085    f.render_widget(Clear, rect);
2086    f.render_widget(Paragraph::new(lines).block(block), rect);
2087}
2088
2089/// One row of a small dropdown: no leading arrow; selection wash runs
2090/// the full inner width so the bar reaches the right border.
2091fn dropdown_row(
2092    theme: &Theme,
2093    selected: bool,
2094    label: &str,
2095    hint: &str,
2096    row_width: usize,
2097) -> Line<'static> {
2098    let label_part = format!(" {label} ");
2099    let hint_part = format!("{hint} ");
2100    let used = label_part.width() + hint_part.width();
2101    let pad = " ".repeat(row_width.saturating_sub(used));
2102
2103    let (label_style, hint_style, pad_style) = if selected {
2104        let selection = theme.selection();
2105        (selection, selection, selection)
2106    } else {
2107        (
2108            Style::new(),
2109            Style::new().fg(theme.muted_color()),
2110            Style::new(),
2111        )
2112    };
2113    Line::from(vec![
2114        Span::styled(label_part, label_style),
2115        Span::styled(hint_part, hint_style),
2116        Span::styled(pad, pad_style),
2117    ])
2118}
2119
2120// -------------------------------------------------------------- overlays
2121
2122fn draw_help(f: &mut Frame, app: &mut App, theme: &Theme, area: Rect) {
2123    const COLUMN_WIDTH: usize = 40;
2124    const WIDE_WIDTH: u16 = COLUMN_WIDTH as u16 * 2 + 7;
2125    const NARROW_WIDTH: u16 = 58;
2126
2127    let wide = area.width >= WIDE_WIDTH;
2128    let width = if wide {
2129        WIDE_WIDTH
2130    } else {
2131        NARROW_WIDTH.min(area.width)
2132    };
2133    let mut lines = wordmark_lines(theme, width);
2134    if !lines.is_empty() {
2135        lines.push(Line::raw(""));
2136    }
2137    let row_style = |heading| {
2138        if heading {
2139            theme.accent_text().add_modifier(Modifier::BOLD)
2140        } else {
2141            Style::new()
2142        }
2143    };
2144    if wide {
2145        for banner::HelpRow {
2146            left,
2147            right,
2148            heading,
2149        } in banner::HELP_COLUMNS
2150        {
2151            let style = row_style(heading);
2152            lines.push(Line::from(vec![
2153                Span::raw("  "),
2154                Span::styled(format!("{left:<COLUMN_WIDTH$}"), style),
2155                Span::styled(right, style),
2156            ]));
2157        }
2158        lines.push(Line::raw(""));
2159    } else {
2160        // Stack the paired sections only when two readable columns do not fit.
2161        for side in 0..2 {
2162            for banner::HelpRow {
2163                left,
2164                right,
2165                heading,
2166            } in banner::HELP_COLUMNS
2167            {
2168                let text = if side == 0 { left } else { right };
2169                if text.is_empty() {
2170                    lines.push(Line::raw(""));
2171                    continue;
2172                }
2173                let prefix = if heading { "" } else { "  " };
2174                lines.push(Line::styled(format!("{prefix}{text}"), row_style(heading)));
2175            }
2176            lines.push(Line::raw(""));
2177        }
2178    }
2179    let store = format!("Data store: {}", app.data_dir().display());
2180    lines.push(
2181        Line::styled(
2182            truncate(&store, width.saturating_sub(4) as usize),
2183            Style::new().fg(theme.muted_color()),
2184        )
2185        .centered(),
2186    );
2187    lines.push(Line::styled(banner::HELP_FOOTER, theme.accent_text()).centered());
2188
2189    let height = u16::try_from(lines.len())
2190        .unwrap_or(u16::MAX)
2191        .saturating_add(2)
2192        .min(area.height);
2193    let rect = centered(area, width, height);
2194    let viewport = rect.height.saturating_sub(2) as usize;
2195    let max_scroll = lines.len().saturating_sub(viewport);
2196    app.help_scroll = app.help_scroll.min(max_scroll);
2197    let title = Line::from(vec![
2198        Span::raw(" mach "),
2199        Span::styled(
2200            format!("v{} ", crate::VERSION),
2201            Style::new().fg(theme.muted_color()),
2202        ),
2203    ]);
2204    let block = Block::bordered()
2205        .border_type(BorderType::Thick)
2206        .title(title)
2207        .border_style(theme.accent_text())
2208        .padding(ratatui::widgets::Padding::horizontal(1));
2209    f.render_widget(Clear, rect);
2210    f.render_widget(
2211        Paragraph::new(lines)
2212            .block(block)
2213            .scroll((app.help_scroll.min(u16::MAX as usize) as u16, 0)),
2214        rect,
2215    );
2216}
2217
2218fn draw_settings(f: &mut Frame, app: &App, theme: &Theme, area: Rect) {
2219    let mut lines: Vec<Line> = Vec::new();
2220    for (i, item) in SETTINGS_ITEMS.iter().enumerate() {
2221        let selected = i == app.settings_index;
2222        let value = app.setting_value(i);
2223        let marker = if selected { "❯ " } else { "  " };
2224        let name_style = if selected {
2225            Style::new().add_modifier(Modifier::BOLD)
2226        } else {
2227            Style::new()
2228        };
2229        lines.push(Line::from(vec![
2230            Span::styled(marker, theme.accent_text()),
2231            Span::styled(format!("{item:<14}"), name_style),
2232            Span::styled(value, theme.accent_text()),
2233        ]));
2234    }
2235    lines.push(Line::raw(""));
2236    lines.push(Line::styled(
2237        "↑↓ select · ←→ change · Esc close",
2238        Style::new().fg(theme.muted_color()),
2239    ));
2240
2241    let width = 48.min(area.width);
2242    let height = u16::try_from(lines.len())
2243        .unwrap_or(u16::MAX)
2244        .saturating_add(2)
2245        .min(area.height);
2246    let rect = centered(area, width, height);
2247    let block = Block::bordered()
2248        .border_type(BorderType::Thick)
2249        .title(Line::from(" Settings "))
2250        .border_style(theme.accent_text())
2251        .padding(ratatui::widgets::Padding::horizontal(2));
2252    f.render_widget(Clear, rect);
2253    f.render_widget(Paragraph::new(lines).block(block), rect);
2254}
2255
2256fn draw_welcome(f: &mut Frame, theme: &Theme, area: Rect) {
2257    let mut lines = wordmark_lines(theme, area.width);
2258    if !lines.is_empty() {
2259        lines.push(Line::raw(""));
2260    }
2261    lines.push(
2262        Line::styled(
2263            format!("Welcome to mach v{}", crate::VERSION),
2264            Style::new().add_modifier(Modifier::BOLD),
2265        )
2266        .centered(),
2267    );
2268    lines.push(Line::raw(""));
2269    lines.push(Line::raw("Written in Rust with ratatui.").centered());
2270    lines.push(Line::raw("Your tasks stay local in ~/.mach.").centered());
2271    lines.push(Line::raw(""));
2272    lines.push(
2273        Line::styled(
2274            "Press Enter to start · /help for the key list",
2275            Style::new().fg(theme.muted_color()),
2276        )
2277        .centered(),
2278    );
2279
2280    let width = 50.min(area.width);
2281    let height = u16::try_from(lines.len())
2282        .unwrap_or(u16::MAX)
2283        .saturating_add(2)
2284        .min(area.height);
2285    let rect = centered(area, width, height);
2286    let block = Block::bordered()
2287        .border_type(BorderType::Thick)
2288        .border_style(theme.accent_text());
2289    f.render_widget(Clear, rect);
2290    f.render_widget(Paragraph::new(lines).block(block), rect);
2291}
2292
2293fn wordmark_lines(theme: &Theme, available_width: u16) -> Vec<Line<'static>> {
2294    if available_width < banner::BANNER_WIDTH + 8 {
2295        return Vec::new();
2296    }
2297    banner::BANNER
2298        .iter()
2299        .map(|row| Line::styled(*row, theme.accent_text()).centered())
2300        .collect()
2301}
2302
2303fn draw_whats_new(f: &mut Frame, theme: &Theme, area: Rect) {
2304    let mut lines = vec![
2305        Line::styled(
2306            format!("What's new in mach v{}", crate::VERSION),
2307            Style::new().add_modifier(Modifier::BOLD),
2308        )
2309        .centered(),
2310        Line::raw(""),
2311    ];
2312    for (index, (title, description)) in banner::WHATS_NEW.into_iter().enumerate() {
2313        lines.push(Line::from(vec![
2314            Span::styled("• ", theme.accent_text()),
2315            Span::styled(title, Style::new().add_modifier(Modifier::BOLD)),
2316        ]));
2317        lines.push(Line::raw(format!("  {description}")));
2318        if index + 1 < banner::WHATS_NEW.len() {
2319            lines.push(Line::raw(""));
2320        }
2321    }
2322    lines.push(Line::raw(""));
2323    lines
2324        .push(Line::styled("Full release notes:", Style::new().fg(theme.muted_color())).centered());
2325    lines.push(
2326        Line::styled(
2327            format!("github.com/Q1CHENL/mach/releases/tag/v{}", crate::VERSION),
2328            Style::new().fg(theme.muted_color()),
2329        )
2330        .centered(),
2331    );
2332    lines.push(
2333        Line::styled(
2334            "Press Enter or Esc to continue",
2335            Style::new().fg(theme.muted_color()),
2336        )
2337        .centered(),
2338    );
2339
2340    let height = u16::try_from(lines.len())
2341        .unwrap_or(u16::MAX)
2342        .saturating_add(2)
2343        .min(area.height);
2344    let rect = centered(area, 62.min(area.width), height);
2345    let block = Block::bordered()
2346        .border_type(BorderType::Thick)
2347        .border_style(theme.accent_text())
2348        .padding(Padding::horizontal(2));
2349    f.render_widget(Clear, rect);
2350    f.render_widget(Paragraph::new(lines).block(block), rect);
2351}
2352
2353// ----------------------------------------------------------------- utils
2354
2355fn draw_box(f: &mut Frame, area: Rect, text: &str, style: Style) {
2356    let width = u16::try_from(text.width())
2357        .unwrap_or(u16::MAX)
2358        .saturating_add(8)
2359        .min(area.width);
2360    let rect = centered(area, width, 3);
2361    let block = Block::bordered()
2362        .border_type(BorderType::Thick)
2363        .border_style(style);
2364    f.render_widget(Clear, rect);
2365    f.render_widget(
2366        Paragraph::new(Line::styled(text.to_string(), style))
2367            .centered()
2368            .block(block),
2369        rect,
2370    );
2371}
2372
2373pub fn centered(area: Rect, width: u16, height: u16) -> Rect {
2374    let width = width.min(area.width);
2375    let height = height.min(area.height);
2376    Rect {
2377        x: area.x.saturating_add((area.width - width) / 2),
2378        y: area.y.saturating_add((area.height - height) / 2),
2379        width,
2380        height,
2381    }
2382}
2383
2384/// Cut a string to a display width without splitting a grapheme cluster.
2385pub fn truncate(s: &str, width: usize) -> String {
2386    if s.width() <= width {
2387        return s.to_string();
2388    }
2389    let mut out = String::new();
2390    let mut used = 0;
2391    for grapheme in s.graphemes(true) {
2392        let w = grapheme.width();
2393        if used + w > width {
2394            break;
2395        }
2396        used += w;
2397        out.push_str(grapheme);
2398    }
2399    out
2400}