Skip to main content

mach/
input.rs

1//! Keyboard and mouse handling.
2
3use ratatui::crossterm::event::{
4    Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
5};
6
7use std::time::{Duration, Instant};
8
9use crate::app::{App, ClickTarget, Confirm, Focus, Mode};
10use crate::form::Field;
11use crate::text_input::TextInput;
12use crate::undo::EditKind;
13
14/// Two clicks on the same task within this long open it.
15const DOUBLE_CLICK: Duration = Duration::from_millis(400);
16
17/// Handle one terminal event and report whether the screen may have changed.
18pub fn handle_event(app: &mut App, event: Event) -> bool {
19    match event {
20        Event::Key(key) if matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) => {
21            handle_key(app, key);
22            true
23        }
24        Event::Mouse(m)
25            if matches!(
26                m.kind,
27                MouseEventKind::Down(MouseButton::Left)
28                    | MouseEventKind::ScrollUp
29                    | MouseEventKind::ScrollDown
30            ) =>
31        {
32            handle_mouse(app, m);
33            true
34        }
35        // The terminal's own paste (Cmd+V / middle click), delivered in
36        // one piece because bracketed paste is on.
37        Event::Paste(text) if !text.is_empty() => {
38            paste_text(app, &text);
39            true
40        }
41        // Crossterm has already resized the terminal; the next draw picks up
42        // the new dimensions without any App mutation here.
43        Event::Resize(_, _) => true,
44        _ => false,
45    }
46}
47
48/// Puts pasted text into whatever is being typed into. Bracketed paste
49/// (the terminal's own Cmd/Ctrl+V) is enough — no separate key binding.
50fn paste_text(app: &mut App, text: &str) {
51    if text.is_empty() {
52        return;
53    }
54    app.cancel_pending();
55    match app.mode {
56        Mode::TaskForm => {
57            let Some(form) = &mut app.form else { return };
58            match form.field {
59                Field::Title => {
60                    form.before_edit(EditKind::Atomic);
61                    form.title.insert_str(text);
62                }
63                // Selectors are changed with arrows/clicks, not pasted text.
64                Field::Category | Field::Labels | Field::Due | Field::Importance => {}
65                Field::Description => {
66                    form.before_edit(EditKind::Atomic);
67                    form.description.insert_str(text);
68                }
69            }
70        }
71        Mode::CategoryForm => {
72            let Some(form) = &mut app.category_form else {
73                return;
74            };
75            form.before_edit(EditKind::Atomic);
76            if form.on_description {
77                form.description.insert_str(text);
78            } else {
79                form.name.insert_str(text);
80            }
81        }
82        Mode::Slash => {
83            app.input.insert_str(text);
84            app.slash_index = 0;
85            app.clamp_slash_index();
86        }
87        Mode::Search => {
88            app.input.insert_str(text);
89            app.update_search();
90        }
91        Mode::Labels => {
92            if let Some(editor) = &mut app.label_editor
93                && !editor.color_focused
94            {
95                editor.name.insert_str(text);
96                app.label_error = None;
97                app.dirty = true;
98            }
99        }
100        _ => {}
101    }
102}
103
104/// Ctrl+Z — undo (not Ctrl+Shift+Z).
105fn is_undo_chord(key: KeyEvent) -> bool {
106    matches!(key.code, KeyCode::Char('z') | KeyCode::Char('Z'))
107        && key.modifiers.contains(KeyModifiers::CONTROL)
108        && !key.modifiers.contains(KeyModifiers::SHIFT)
109        && !key.modifiers.contains(KeyModifiers::ALT)
110}
111
112/// Ctrl+Shift+Z or Ctrl+Y — redo.
113fn is_redo_chord(key: KeyEvent) -> bool {
114    let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
115    let shift = key.modifiers.contains(KeyModifiers::SHIFT);
116    let alt = key.modifiers.contains(KeyModifiers::ALT);
117    if !ctrl || alt {
118        return false;
119    }
120    match key.code {
121        KeyCode::Char('z') | KeyCode::Char('Z') if shift => true,
122        KeyCode::Char('y') | KeyCode::Char('Y') if !shift => true,
123        _ => false,
124    }
125}
126
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128enum TextEditAction {
129    SelectWord,
130    SelectWordLeft,
131    SelectWordRight,
132    WordLeft,
133    WordRight,
134    SelectHome,
135    SelectEnd,
136    Home,
137    End,
138    DeleteToStart,
139    DeleteToEnd,
140    DeleteWordLeft,
141    Insert(char),
142    Backspace,
143    Delete,
144    SelectLeft,
145    SelectRight,
146    Left,
147    Right,
148}
149
150impl TextEditAction {
151    const fn edit_kind(self) -> Option<EditKind> {
152        match self {
153            Self::Insert(_) | Self::Backspace | Self::Delete => Some(EditKind::Typing),
154            Self::DeleteToStart | Self::DeleteToEnd | Self::DeleteWordLeft => {
155                Some(EditKind::Atomic)
156            }
157            _ => None,
158        }
159    }
160
161    fn apply_line(self, input: &mut TextInput) {
162        match self {
163            Self::SelectWord => input.select_word(),
164            Self::SelectWordLeft => input.select_word_left(),
165            Self::SelectWordRight => input.select_word_right(),
166            Self::WordLeft => input.word_left(),
167            Self::WordRight => input.word_right(),
168            Self::SelectHome => input.select_home(),
169            Self::SelectEnd => input.select_end(),
170            Self::Home => input.home(),
171            Self::End => input.end(),
172            Self::DeleteToStart => input.delete_to_start(),
173            Self::DeleteToEnd => input.delete_to_end(),
174            Self::DeleteWordLeft => input.delete_word_left(),
175            Self::Insert(character) => input.insert(character),
176            Self::Backspace => input.backspace(),
177            Self::Delete => input.delete(),
178            Self::SelectLeft => input.select_left(),
179            Self::SelectRight => input.select_right(),
180            Self::Left => input.left(),
181            Self::Right => input.right(),
182        }
183    }
184
185    fn apply_description(self, description: &mut crate::description::DescriptionEditor) {
186        match self {
187            Self::SelectWord => description.select_word(),
188            Self::SelectWordLeft => description.select_word_left(),
189            Self::SelectWordRight => description.select_word_right(),
190            Self::WordLeft => description.word_left(),
191            Self::WordRight => description.word_right(),
192            Self::SelectHome => description.select_home(),
193            Self::SelectEnd => description.select_end(),
194            Self::Home => description.home(),
195            Self::End => description.end(),
196            Self::DeleteToStart => description.delete_to_start(),
197            Self::DeleteToEnd => description.delete_to_end(),
198            Self::DeleteWordLeft => description.delete_word_left(),
199            Self::Insert(character) => description.insert(character),
200            Self::Backspace => description.backspace(),
201            Self::Delete => description.delete(),
202            Self::SelectLeft => description.select_left(),
203            Self::SelectRight => description.select_right(),
204            Self::Left => description.left(),
205            Self::Right => description.right(),
206        }
207    }
208}
209
210fn text_edit_action(key: KeyEvent) -> Option<TextEditAction> {
211    let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
212    let alt = key.modifiers.contains(KeyModifiers::ALT);
213    let shift = key.modifiers.contains(KeyModifiers::SHIFT);
214    let word = word_mod(key);
215    match key.code {
216        KeyCode::Char('w') | KeyCode::Char('W') if alt && shift => Some(TextEditAction::SelectWord),
217        KeyCode::Char('b') | KeyCode::Char('B') if alt && shift => {
218            Some(TextEditAction::SelectWordLeft)
219        }
220        KeyCode::Char('f') | KeyCode::Char('F') if alt && shift => {
221            Some(TextEditAction::SelectWordRight)
222        }
223        KeyCode::Char('b') | KeyCode::Char('B') if alt => Some(TextEditAction::WordLeft),
224        KeyCode::Char('f') | KeyCode::Char('F') if alt => Some(TextEditAction::WordRight),
225        KeyCode::Char(c) if ctrl || alt => match c {
226            'a' if shift => Some(TextEditAction::SelectHome),
227            'e' if shift => Some(TextEditAction::SelectEnd),
228            'a' => Some(TextEditAction::Home),
229            'e' => Some(TextEditAction::End),
230            'u' => Some(TextEditAction::DeleteToStart),
231            'k' => Some(TextEditAction::DeleteToEnd),
232            'w' | 'W' => Some(TextEditAction::DeleteWordLeft),
233            _ => None,
234        },
235        KeyCode::Char(character) if !ctrl && !alt => Some(TextEditAction::Insert(character)),
236        KeyCode::Backspace if word => Some(TextEditAction::DeleteWordLeft),
237        KeyCode::Backspace => Some(TextEditAction::Backspace),
238        KeyCode::Delete => Some(TextEditAction::Delete),
239        KeyCode::Left if word && shift => Some(TextEditAction::SelectWordLeft),
240        KeyCode::Right if word && shift => Some(TextEditAction::SelectWordRight),
241        KeyCode::Left if shift => Some(TextEditAction::SelectLeft),
242        KeyCode::Right if shift => Some(TextEditAction::SelectRight),
243        KeyCode::Left if word => Some(TextEditAction::WordLeft),
244        KeyCode::Right if word => Some(TextEditAction::WordRight),
245        KeyCode::Left => Some(TextEditAction::Left),
246        KeyCode::Right => Some(TextEditAction::Right),
247        KeyCode::Home if shift => Some(TextEditAction::SelectHome),
248        KeyCode::End if shift => Some(TextEditAction::SelectEnd),
249        KeyCode::Home => Some(TextEditAction::Home),
250        KeyCode::End => Some(TextEditAction::End),
251        _ => None,
252    }
253}
254
255/// Whether this key mutates editor content, and how to group it for undo.
256fn content_edit_kind(key: KeyEvent) -> Option<EditKind> {
257    let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
258    let alt = key.modifiers.contains(KeyModifiers::ALT);
259    let shift = key.modifiers.contains(KeyModifiers::SHIFT);
260    if matches!(key.code, KeyCode::Char('d') | KeyCode::Char('D')) && ctrl && !alt && !shift {
261        return Some(EditKind::Atomic);
262    }
263    text_edit_action(key).and_then(TextEditAction::edit_kind)
264}
265
266fn handle_key(app: &mut App, key: KeyEvent) {
267    // Cmd/Ctrl+C on a selection copies it. Copying always wins over
268    // quitting, so the two can share the chord.
269    if is_copy_chord(key) {
270        if copy_selected_description_image(app) {
271            return;
272        }
273        // Command is Copy on macOS. When there is no selection it remains a
274        // no-op instead of falling through to the editor as a literal `c`.
275        if key.modifiers.contains(KeyModifiers::SUPER) {
276            return;
277        }
278    }
279    // Auto-repeat comes from one physical hold, not a second affirmative
280    // action. Keep navigation/edit repeats responsive, but never let one
281    // complete an armed delete, purge, discard, or quit confirmation.
282    if key.kind == KeyEventKind::Repeat
283        && app
284            .pending_confirmation()
285            .is_some_and(|confirm| confirmation_key_matches(confirm, key, app.mode))
286    {
287        return;
288    }
289    // With nothing to copy, Ctrl+C twice leaves mach — but only from
290    // the two panels. Inside a dialog or the `/` line it would be far too
291    // easy to throw away what was typed, and Esc already backs out there.
292    if is_ctrl_c(key) && app.mode == Mode::Normal {
293        if app.awaiting(Confirm::Quit) {
294            app.request_quit();
295        } else {
296            app.ask_confirm(Confirm::Quit, "Press Ctrl+C again to quit");
297        }
298        return;
299    }
300
301    // Confirmations are action-specific. Any key other than that action's
302    // explicit second step cancels it before normal routing continues.
303    let keeps_confirmation = app
304        .pending_confirmation()
305        .is_none_or(|confirm| confirmation_key_matches(confirm, key, app.mode));
306    if !keeps_confirmation {
307        app.cancel_pending();
308    }
309
310    if key.code == KeyCode::Enter
311        && app.mode == Mode::Normal
312        && let Some(Confirm::Purge(ids)) = app.pending_confirmation().cloned()
313    {
314        let count = app.purge_ids(&ids);
315        if count > 0 {
316            app.info(format!("Purged {count} done task(s)"));
317        }
318        return;
319    }
320
321    match app.mode {
322        Mode::Welcome | Mode::WhatsNew => {
323            app.mode = Mode::Normal;
324            if !matches!(key.code, KeyCode::Enter | KeyCode::Esc) {
325                handle_key(app, key);
326            }
327        }
328        Mode::Help => match key.code {
329            KeyCode::Esc | KeyCode::Enter | KeyCode::Char('?') => app.mode = Mode::Normal,
330            KeyCode::Up => app.help_scroll = app.help_scroll.saturating_sub(1),
331            KeyCode::Down => app.help_scroll = app.help_scroll.saturating_add(1),
332            KeyCode::PageUp => app.help_scroll = app.help_scroll.saturating_sub(10),
333            KeyCode::PageDown => app.help_scroll = app.help_scroll.saturating_add(10),
334            KeyCode::Home => app.help_scroll = 0,
335            KeyCode::End => app.help_scroll = usize::MAX,
336            _ => {}
337        },
338        Mode::Settings => handle_settings_key(app, key),
339        Mode::Labels => handle_labels_key(app, key),
340        Mode::TaskForm => handle_form_key(app, key),
341        Mode::CategoryForm => handle_category_key(app, key),
342        Mode::Slash => handle_slash_key(app, key),
343        Mode::Search => handle_search_key(app, key),
344        _ => handle_normal_key(app, key),
345    }
346}
347
348fn confirmation_key_matches(confirm: &Confirm, key: KeyEvent, mode: Mode) -> bool {
349    match confirm {
350        Confirm::DeleteTask(_) | Confirm::DeleteCategory(_) | Confirm::DeleteLabel(_) => {
351            key.code == KeyCode::Backspace
352        }
353        Confirm::Purge(_) => key.code == KeyCode::Enter && mode == Mode::Normal,
354        Confirm::DiscardTask(_) | Confirm::DiscardCategory(_) => key.code == KeyCode::Esc,
355        Confirm::Quit => is_ctrl_c(key),
356    }
357}
358
359/// ⌘C / Ctrl+C — macOS terminals often send SUPER for Command.
360fn is_copy_chord(key: KeyEvent) -> bool {
361    matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C'))
362        && (key.modifiers.contains(KeyModifiers::SUPER)
363            || key.modifiers.contains(KeyModifiers::CONTROL))
364}
365
366/// Ctrl+C alone. ⌘C is Copy on macOS and must never quit, so the quit
367/// chord is narrower than [`is_copy_chord`].
368fn is_ctrl_c(key: KeyEvent) -> bool {
369    matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C'))
370        && key.modifiers == KeyModifiers::CONTROL
371}
372
373/// Copy the current selection (text, picture, or both) from a form field.
374/// Returns true when the key was handled.
375fn copy_selected_description_image(app: &mut App) -> bool {
376    if app.mode == Mode::TaskForm
377        && let Some(form) = &app.form
378        && form.field == Field::Description
379        && let Some(payload) = form.description.selected_payload()
380    {
381        finish_copy(app, payload);
382        return true;
383    }
384    // Other fields: plain text selection only.
385    if let Some(text) = selected_text_in_app(app) {
386        finish_copy(app, crate::description::CopyPayload::Text(text));
387        return true;
388    }
389    if app.mode != Mode::TaskForm {
390        return false;
391    }
392    let Some(form) = &app.form else {
393        return false;
394    };
395    // Full-size preview: copy that picture even with no description selection.
396    if form.preview {
397        let path = form
398            .description
399            .selected_image()
400            .or_else(|| form.description.images().into_iter().next());
401        if let Some(path) = path {
402            finish_copy(app, crate::description::CopyPayload::Image(path));
403            return true;
404        }
405    }
406    false
407}
408
409fn selected_text_in_app(app: &App) -> Option<String> {
410    match app.mode {
411        Mode::TaskForm => {
412            let form = app.form.as_ref()?;
413            match form.field {
414                Field::Title => form.title.selected_text(),
415                Field::Description => form.description.selected_text(),
416                Field::Category | Field::Labels | Field::Due | Field::Importance => None,
417            }
418        }
419        Mode::CategoryForm => {
420            let form = app.category_form.as_ref()?;
421            if form.on_description {
422                form.description.selected_text()
423            } else {
424                form.name.selected_text()
425            }
426        }
427        Mode::Slash | Mode::Search => app.input.selected_text(),
428        _ => None,
429    }
430}
431
432// ---------------------------------------------------------------- normal
433
434fn handle_normal_key(app: &mut App, key: KeyEvent) {
435    match key.code {
436        KeyCode::Tab | KeyCode::BackTab => {
437            if !app.searching {
438                app.toggle_focus();
439            }
440        }
441        // Esc backs out one step and never quits; use `/quit`.
442        KeyCode::Esc => {
443            if app.cancel_archive() {
444                return;
445            }
446            if app.searching {
447                app.end_search();
448            }
449        }
450        // `/` opens the command palette (search, settings, …).
451        KeyCode::Char('/') => app.open_slash(),
452        KeyCode::Char('?') => {
453            app.help_scroll = 0;
454            app.mode = Mode::Help;
455        }
456        _ => match app.focus {
457            Focus::Tasks => task_key(app, key),
458            Focus::Sidebar => sidebar_key(app, key),
459        },
460    }
461}
462
463fn task_key(app: &mut App, key: KeyEvent) {
464    let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
465    let alt = key.modifiers.contains(KeyModifiers::ALT);
466    let meta = key.modifiers.contains(KeyModifiers::SUPER);
467
468    match key.code {
469        KeyCode::Char('a') | KeyCode::Char('A') if ctrl && !alt => {
470            if app.searching {
471                app.info("Leave search (Esc) before adding a task");
472                return;
473            }
474            app.open_new_task();
475        }
476        KeyCode::Char('f') | KeyCode::Char('F') if ctrl && !alt => {
477            app.cycle_importance(app.task_index);
478        }
479        KeyCode::Enter => app.open_edit_task(),
480        KeyCode::Char(' ') => app.toggle_done(app.task_index),
481        KeyCode::Up if alt && !ctrl && !meta => {
482            app.move_task_order(-1);
483        }
484        KeyCode::Down if alt && !ctrl && !meta => {
485            app.move_task_order(1);
486        }
487        KeyCode::Up => app.navigate_vertical(-1),
488        KeyCode::Down => app.navigate_vertical(1),
489        KeyCode::PageUp => app.select_first_task(),
490        KeyCode::PageDown => app.select_last_task(),
491        // The panels sit side by side, so the arrows that point at them
492        // are what moves between them.
493        KeyCode::Left => {
494            let _ = app.set_focus(Focus::Sidebar);
495        }
496        KeyCode::Backspace => {
497            if let Some(id) = app.selected_task().map(|task| task.id.clone()) {
498                let confirm = Confirm::DeleteTask(id.clone());
499                if app.awaiting(confirm.clone()) {
500                    if app.delete_task_by_id(&id) {
501                        app.info("Task deleted");
502                    }
503                } else {
504                    app.ask_confirm(confirm, "Press Backspace again to delete this task");
505                }
506            }
507        }
508        // Type-to-jump: plain characters fuzzy-select a row (no mode).
509        KeyCode::Char(c) if !ctrl && !alt && !meta && !c.is_control() => {
510            app.typeahead_jump(c);
511        }
512        _ => {}
513    }
514}
515
516fn sidebar_key(app: &mut App, key: KeyEvent) {
517    let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
518    let alt = key.modifiers.contains(KeyModifiers::ALT);
519    let meta = key.modifiers.contains(KeyModifiers::SUPER);
520
521    match key.code {
522        KeyCode::Char('a') | KeyCode::Char('A') if ctrl && !alt => app.open_new_category(),
523        // Enter opens whatever is selected, and a category opens into
524        // the same kind of dialog a task does.
525        KeyCode::Enter => app.open_edit_category(),
526        KeyCode::Right => {
527            let _ = app.set_focus(Focus::Tasks);
528        }
529        KeyCode::Up if alt && !ctrl && !meta => {
530            app.move_category_order(-1);
531        }
532        KeyCode::Down if alt && !ctrl && !meta => {
533            app.move_category_order(1);
534        }
535        KeyCode::Up => app.navigate_vertical(-1),
536        KeyCode::Down => app.navigate_vertical(1),
537        KeyCode::PageUp => app.select_category(0),
538        KeyCode::PageDown => app.select_last_category(),
539        KeyCode::Backspace => {
540            if app.is_all_view() {
541                return;
542            }
543            let id = app.current_category_id().to_string();
544            let confirm = Confirm::DeleteCategory(id.clone());
545            if app.awaiting(confirm.clone()) {
546                let count = app.category_progress(&id).1;
547                if app.delete_category_by_id(&id) {
548                    app.info(format!(
549                        "Category deleted; {count} task(s) kept as Uncategorized"
550                    ));
551                }
552            } else {
553                let count = app.category_progress(app.current_category_id()).1;
554                app.ask_confirm(
555                    confirm,
556                    format!(
557                        "Press Backspace again to delete this category; {count} task(s) will be kept as Uncategorized"
558                    ),
559                );
560            }
561        }
562        // Type-to-jump: plain characters fuzzy-select a category (no mode).
563        KeyCode::Char(c) if !ctrl && !alt && !meta && !c.is_control() => {
564            app.typeahead_jump(c);
565        }
566        _ => {}
567    }
568}
569
570// ----------------------------------------------------------- text editing
571
572/// macOS Option and Linux Alt both show up as [`KeyModifiers::ALT`].
573/// Ctrl is the common non-Mac habit for the same motions.
574fn word_mod(key: KeyEvent) -> bool {
575    key.modifiers
576        .intersects(KeyModifiers::ALT | KeyModifiers::CONTROL)
577}
578
579/// Shared bindings for every one-line editor.
580fn edit_line(input: &mut TextInput, key: KeyEvent) -> bool {
581    let Some(action) = text_edit_action(key) else {
582        return false;
583    };
584    action.apply_line(input);
585    true
586}
587
588/// The same bindings as [`edit_line`], for the multi-line block editors:
589/// a task's description and a category's description. Adds ↑/↓ across blocks.
590/// The `/` menu is handled by the caller before this runs.
591fn edit_description(description: &mut crate::description::DescriptionEditor, key: KeyEvent) {
592    match key.code {
593        KeyCode::Up => description.up(),
594        KeyCode::Down => description.down(),
595        _ => {
596            if let Some(action) = text_edit_action(key) {
597                action.apply_description(description);
598            }
599        }
600    }
601}
602
603/// The category dialog: a name and a structured, text-only description.
604fn handle_category_key(app: &mut App, key: KeyEvent) {
605    if matches!(key.code, KeyCode::Char('s')) && key.modifiers.contains(KeyModifiers::CONTROL) {
606        if app
607            .category_form
608            .as_ref()
609            .is_some_and(|form| form.description.menu.is_some())
610        {
611            app.error("Choose or dismiss the description command before saving");
612            return;
613        }
614        app.submit_category_form();
615        return;
616    }
617
618    if is_undo_chord(key) {
619        if let Some(form) = &mut app.category_form
620            && form.undo()
621        {
622            app.info("Undo");
623        }
624        return;
625    }
626    if is_redo_chord(key) {
627        if let Some(form) = &mut app.category_form
628            && form.redo()
629        {
630            app.info("Redo");
631        }
632        return;
633    }
634
635    // Slash menu owns arrows / Enter while open on the description.
636    if let Some(form) = app
637        .category_form
638        .as_mut()
639        .filter(|form| form.on_description && form.description.menu.is_some())
640    {
641        let outcome = {
642            // Structural apply (bullet etc.) needs a checkpoint first.
643            if matches!(key.code, KeyCode::Enter | KeyCode::Tab) {
644                form.before_edit(EditKind::Atomic);
645            }
646            description_menu_key(&mut form.description, key)
647        };
648        match outcome {
649            MenuKey::Ignored => {}
650            MenuKey::Handled => return,
651            MenuKey::Request(request) => {
652                finish_description_command(app, request);
653                return;
654            }
655        }
656    }
657
658    match key.code {
659        KeyCode::Esc => {
660            let _ = request_close_form(app, OpenForm::Category, FormCloseSource::Escape);
661        }
662        KeyCode::Tab | KeyCode::BackTab => {
663            if let Some(form) = &mut app.category_form {
664                form.description.close_menu();
665                form.toggle_field();
666            }
667        }
668        KeyCode::Enter
669            if key
670                .modifiers
671                .intersects(KeyModifiers::SUPER | KeyModifiers::CONTROL) =>
672        {
673            let url = app
674                .category_form
675                .as_ref()
676                .filter(|form| form.on_description)
677                .and_then(|form| form.description.link_url_at_cursor());
678            if let Some(url) = url {
679                open_link(app, &url);
680            }
681        }
682        KeyCode::Enter => {
683            let Some(form) = &mut app.category_form else {
684                return;
685            };
686            if form.on_description {
687                form.before_edit(EditKind::Atomic);
688                let _ = form.description.newline();
689            } else {
690                form.toggle_field();
691            }
692        }
693        _ => {
694            let Some(form) = &mut app.category_form else {
695                return;
696            };
697            if form.on_description {
698                if let Some(mut kind) = content_edit_kind(key) {
699                    if form.description.has_selection() {
700                        kind = EditKind::Atomic;
701                    }
702                    form.before_edit(kind);
703                } else {
704                    form.break_coalesce();
705                }
706                edit_description(&mut form.description, key);
707            } else if let Some(mut kind) = content_edit_kind(key) {
708                if form.name.has_selection() {
709                    kind = EditKind::Atomic;
710                }
711                form.before_edit(kind);
712                edit_line(&mut form.name, key);
713            } else {
714                form.break_coalesce();
715                edit_line(&mut form.name, key);
716            }
717        }
718    }
719}
720
721/// The `/` command palette above the status bar.
722fn cycle_index(index: &mut usize, count: usize, delta: isize) {
723    if count > 0 {
724        *index = ((*index as isize + delta).rem_euclid(count as isize)) as usize;
725    }
726}
727
728fn handle_slash_key(app: &mut App, key: KeyEvent) {
729    match key.code {
730        KeyCode::Esc => close_slash(app),
731        // Backspace on empty (or past the last char) drops the leading `/`.
732        KeyCode::Backspace if app.input.is_empty() => close_slash(app),
733        KeyCode::Up => {
734            let n = crate::slash::matching(&app.input.value()).len();
735            cycle_index(&mut app.slash_index, n, -1);
736        }
737        KeyCode::Down | KeyCode::Tab => {
738            let n = crate::slash::matching(&app.input.value()).len();
739            cycle_index(&mut app.slash_index, n, 1);
740        }
741        KeyCode::Enter => {
742            let query = app.input.value();
743            let matches = crate::slash::matching(&query);
744            let cmd = matches.get(app.slash_index).copied();
745            close_slash(app);
746            if let Some(cmd) = cmd {
747                run_slash(app, cmd, &query);
748            }
749        }
750        _ => {
751            if edit_line(&mut app.input, key) {
752                app.slash_index = 0;
753                app.clamp_slash_index();
754            }
755        }
756    }
757}
758
759fn close_slash(app: &mut App) {
760    app.mode = Mode::Normal;
761    app.input = TextInput::default();
762    app.slash_index = 0;
763}
764
765/// Live search after choosing Search from the palette.
766fn handle_search_key(app: &mut App, key: KeyEvent) {
767    match key.code {
768        KeyCode::Esc => {
769            app.input = TextInput::default();
770            app.end_search();
771        }
772        KeyCode::Enter => {
773            // Keep the current query; just leave the typing field.
774            app.mode = Mode::Normal;
775            app.input = TextInput::default();
776            // Keep searching/search_query so the list stays narrowed until Esc.
777            if app.search_query.is_empty() {
778                app.end_search();
779            }
780        }
781        _ => {
782            if edit_line(&mut app.input, key) {
783                app.update_search();
784            }
785        }
786    }
787}
788
789fn run_slash(app: &mut App, cmd: crate::slash::SlashCommand, query: &str) {
790    use crate::slash::{SlashCommand, args_for};
791    match cmd {
792        SlashCommand::Search => {
793            let q = args_for(cmd, query);
794            app.start_search(&q);
795        }
796        SlashCommand::Settings => {
797            app.settings_index = 0;
798            app.mode = Mode::Settings;
799        }
800        SlashCommand::Labels => app.open_labels(),
801        SlashCommand::Help => {
802            app.help_scroll = 0;
803            app.mode = Mode::Help;
804        }
805        SlashCommand::WhatsNew => app.mode = Mode::WhatsNew,
806        SlashCommand::CopyTitle => match app.selected_task() {
807            Some(task) => {
808                finish_copy(
809                    app,
810                    crate::description::CopyPayload::Text(task.title.clone()),
811                );
812            }
813            None => app.info("No task selected"),
814        },
815        SlashCommand::CopyTask => match app.selected_task() {
816            Some(task) => {
817                let text = task_clipboard_text(task);
818                finish_copy(app, crate::description::CopyPayload::Text(text));
819            }
820            None => app.info("No task selected"),
821        },
822        SlashCommand::Export => {
823            let argument = args_for(cmd, query);
824            if !argument.is_empty() {
825                app.error("Usage: /export");
826                return;
827            }
828            app.start_export_archive();
829        }
830        SlashCommand::Import => {
831            let argument = args_for(cmd, query);
832            if argument.is_empty() {
833                app.error("Usage: /import <FILE>");
834            } else {
835                app.start_import_archive(std::path::PathBuf::from(argument));
836            }
837        }
838        SlashCommand::Done => {
839            let _ = app.toggle_hide_done();
840        }
841        SlashCommand::Purge => {
842            let ids = app.purge_candidate_ids();
843            if ids.is_empty() {
844                app.info("No done tasks to purge");
845            } else {
846                let count = ids.len();
847                app.ask_confirm(
848                    Confirm::Purge(ids),
849                    format!("Press Enter to purge {count} done task(s)"),
850                );
851            }
852        }
853        SlashCommand::Update => app.start_update_install(),
854        SlashCommand::Quit => app.request_quit(),
855    }
856}
857
858// ------------------------------------------------------------ task dialog
859
860/// Tab and the mouse move between fields. Ctrl+S saves; Enter acts on the
861/// focused field (and starts a new block in the description).
862fn handle_form_key(app: &mut App, key: KeyEvent) {
863    let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
864
865    // Saving resolves the picker, but never guesses what an open description command
866    // or full-screen preview was meant to do.
867    if matches!(key.code, KeyCode::Char('s')) && ctrl {
868        if app.form.as_ref().is_some_and(|form| form.preview) {
869            app.error("Close the image preview before saving");
870            return;
871        }
872        if app
873            .form
874            .as_ref()
875            .is_some_and(|form| form.description.menu.is_some())
876        {
877            app.error("Choose or dismiss the description command before saving");
878            return;
879        }
880        if let Some(form) = &mut app.form
881            && form.picker.is_some()
882        {
883            form.take_due_picker();
884        }
885        app.submit_form();
886        return;
887    }
888
889    // Esc peels one layer: preview → picker → slash menu → leave the form.
890    // (Handled below in that order; bare Esc closes the dialog only when
891    // none of those overlays are open.)
892
893    // The image preview: Esc closes; Space / Enter toggles GIF pause.
894    if app.form.as_ref().is_some_and(|f| f.preview) {
895        match key.code {
896            KeyCode::Esc => {
897                // Drop frames/protocol first so the next draw cannot spend
898                // another encode tick on this preview.
899                if let Some(form) = &mut app.form {
900                    form.close_image_preview();
901                }
902                app.images.clear_preview();
903            }
904            KeyCode::Enter | KeyCode::Char(' ') => {
905                if let Some(form) = &mut app.form {
906                    form.preview_click();
907                }
908            }
909            _ => {}
910        }
911        return;
912    }
913
914    // Ctrl+Z / Ctrl+Shift+Z (Ctrl+Y) — form-wide undo/redo. The image
915    // preview above owns its keys until explicitly closed.
916    if is_undo_chord(key) {
917        if let Some(form) = &mut app.form
918            && form.undo()
919        {
920            app.info("Undo");
921        }
922        return;
923    }
924    if is_redo_chord(key) {
925        if let Some(form) = &mut app.form
926            && form.redo()
927        {
928            app.info("Redo");
929        }
930        return;
931    }
932
933    // The date/time picker owns Tab and the arrows while it is open.
934    if app.form.as_ref().is_some_and(|f| f.picker.is_some()) {
935        handle_picker_key(app, key);
936        return;
937    }
938
939    if app
940        .form
941        .as_ref()
942        .is_some_and(|form| form.label_picker_open())
943    {
944        handle_label_picker_key(app, key);
945        return;
946    }
947
948    // Slash menu: Esc closes the menu only (not the whole dialog).
949    if app
950        .form
951        .as_ref()
952        .is_some_and(|f| f.description.menu.is_some())
953        && handle_menu_key(app, key)
954    {
955        return;
956    }
957
958    match key.code {
959        KeyCode::Esc => {
960            let _ = request_close_form(app, OpenForm::Task, FormCloseSource::Escape);
961        }
962        KeyCode::Tab => {
963            if let Some(form) = &mut app.form {
964                form.focus_next();
965            }
966        }
967        KeyCode::BackTab => {
968            if let Some(form) = &mut app.form {
969                form.focus_prev();
970            }
971        }
972        // Enter never closes the dialog — it opens or adds whatever the
973        // focused field holds. Ctrl+S is what saves.
974        // ⌘Enter / Ctrl+Enter on a link opens it in the browser.
975        KeyCode::Enter
976            if key
977                .modifiers
978                .intersects(KeyModifiers::SUPER | KeyModifiers::CONTROL) =>
979        {
980            let url = app
981                .form
982                .as_ref()
983                .filter(|f| f.field == Field::Description)
984                .and_then(|f| f.description.link_url_at_cursor());
985            if let Some(url) = url {
986                open_link(app, &url);
987            }
988        }
989        KeyCode::Enter => {
990            let Some(form) = &mut app.form else { return };
991            match form.field {
992                Field::Title | Field::Category | Field::Importance => form.focus_next(),
993                Field::Labels => form.open_label_picker(),
994                Field::Due => form.open_due_picker(),
995                // On a picture there is nothing to type, so Enter is
996                // what opens it.
997                Field::Description if form.description.selected_image().is_some() => {
998                    if let Some(err) = form.open_image_preview() {
999                        app.error(err);
1000                    }
1001                }
1002                Field::Description => {
1003                    form.before_edit(EditKind::Atomic);
1004                    let _ = form.description.newline();
1005                }
1006            }
1007        }
1008        _ => {
1009            let Some(form) = &mut app.form else { return };
1010            match form.field {
1011                // Ctrl+D ticks a to-do off; everything else is ordinary
1012                // block editing.
1013                Field::Description
1014                    if ctrl && matches!(key.code, KeyCode::Char('d') | KeyCode::Char('D')) =>
1015                {
1016                    form.before_edit(EditKind::Atomic);
1017                    form.description.toggle();
1018                }
1019                Field::Description => {
1020                    if let Some(mut kind) = content_edit_kind(key) {
1021                        if form.description.has_selection() {
1022                            kind = EditKind::Atomic;
1023                        }
1024                        form.before_edit(kind);
1025                    } else {
1026                        form.break_coalesce();
1027                    }
1028                    edit_description(&mut form.description, key);
1029                }
1030                // Category is a bounded selector. All tasks is deliberately
1031                // absent; Backspace/Delete returns the task to Uncategorized.
1032                Field::Category => match key.code {
1033                    KeyCode::Left | KeyCode::Up => form.cycle_category(-1),
1034                    KeyCode::Right | KeyCode::Down | KeyCode::Char(' ') => form.cycle_category(1),
1035                    KeyCode::Backspace | KeyCode::Delete => form.clear_category(),
1036                    _ => form.break_coalesce(),
1037                },
1038                Field::Labels => match key.code {
1039                    KeyCode::Char(' ') => form.open_label_picker(),
1040                    KeyCode::Backspace | KeyCode::Delete => form.clear_labels(),
1041                    _ => form.break_coalesce(),
1042                },
1043                // Nothing to type here: the arrows and the digits set
1044                // how many flags the task carries.
1045                Field::Importance => match key.code {
1046                    KeyCode::Left | KeyCode::Down => {
1047                        form.set_importance(form.importance.saturating_sub(1))
1048                    }
1049                    KeyCode::Right | KeyCode::Up | KeyCode::Char(' ') => form.cycle_importance(),
1050                    KeyCode::Backspace | KeyCode::Delete => form.set_importance(0),
1051                    KeyCode::Char(c) if c.is_ascii_digit() => form.set_importance(c as u8 - b'0'),
1052                    _ => form.break_coalesce(),
1053                },
1054                // Due is picker-only — no free typing, so any character
1055                // opens the calendar instead of landing in the field.
1056                Field::Due => match key.code {
1057                    KeyCode::Char(_) => form.open_due_picker(),
1058                    KeyCode::Backspace | KeyCode::Delete => form.clear_due(),
1059                    _ => form.break_coalesce(),
1060                },
1061                // Arrow keys only ever move the cursor; Tab, Shift+Tab
1062                // and the mouse are what change fields.
1063                Field::Title => {
1064                    if let Some(mut kind) = content_edit_kind(key) {
1065                        if form.title.has_selection() {
1066                            kind = EditKind::Atomic;
1067                        }
1068                        form.before_edit(kind);
1069                    } else {
1070                        form.break_coalesce();
1071                    }
1072                    edit_line(&mut form.title, key);
1073                }
1074            }
1075        }
1076    }
1077}
1078
1079fn handle_label_picker_key(app: &mut App, key: KeyEvent) {
1080    let manage = app
1081        .form
1082        .as_ref()
1083        .is_some_and(|form| form.label_picker_manage_selected());
1084    if manage && matches!(key.code, KeyCode::Esc) {
1085        if let Some(form) = &mut app.form {
1086            form.close_label_picker();
1087        }
1088        return;
1089    }
1090    if manage && matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
1091        app.open_labels_from_form();
1092        return;
1093    }
1094    let Some(form) = &mut app.form else { return };
1095    match key.code {
1096        KeyCode::Esc | KeyCode::Enter => form.close_label_picker(),
1097        KeyCode::Up => form.move_label_picker(-1),
1098        KeyCode::Down | KeyCode::Tab => form.move_label_picker(1),
1099        KeyCode::BackTab => form.move_label_picker(-1),
1100        KeyCode::PageUp => form.move_label_picker(-8),
1101        KeyCode::PageDown => form.move_label_picker(8),
1102        KeyCode::Home => form.select_first_label(),
1103        KeyCode::End => form.select_last_label(),
1104        KeyCode::Char(' ') => {
1105            if let Err(error) = form.toggle_current_label() {
1106                form.error = Some(error.to_string());
1107            } else {
1108                form.error = None;
1109            }
1110        }
1111        _ => {}
1112    }
1113}
1114
1115/// Date + time picker: Tab moves Calendar → Hour → Minute; arrows adjust
1116/// the focused part; Enter writes the value back into Due.
1117fn handle_picker_key(app: &mut App, key: KeyEvent) {
1118    use crate::duepicker::PickerFocus;
1119
1120    let Some(form) = &mut app.form else { return };
1121    match key.code {
1122        KeyCode::Esc => {
1123            form.picker = None;
1124            return;
1125        }
1126        KeyCode::Char('x') | KeyCode::Delete => {
1127            form.clear_due();
1128            return;
1129        }
1130        KeyCode::Enter => {
1131            form.take_due_picker();
1132            return;
1133        }
1134        _ => {}
1135    }
1136
1137    let Some(picker) = &mut form.picker else {
1138        return;
1139    };
1140    match key.code {
1141        KeyCode::Tab => picker.focus_next(),
1142        KeyCode::BackTab => picker.focus_prev(),
1143        KeyCode::Char('t') => {
1144            picker.today();
1145            picker.now_time();
1146        }
1147        KeyCode::Left => match picker.focus {
1148            PickerFocus::Calendar => picker.move_days(-1),
1149            PickerFocus::Hour => picker.bump_hour(-1),
1150            PickerFocus::Minute => picker.bump_minute(-5),
1151        },
1152        KeyCode::Right => match picker.focus {
1153            PickerFocus::Calendar => picker.move_days(1),
1154            PickerFocus::Hour => picker.bump_hour(1),
1155            PickerFocus::Minute => picker.bump_minute(5),
1156        },
1157        KeyCode::Up => match picker.focus {
1158            PickerFocus::Calendar => picker.move_days(-7),
1159            PickerFocus::Hour => picker.bump_hour(1),
1160            PickerFocus::Minute => picker.bump_minute(5),
1161        },
1162        KeyCode::Down => match picker.focus {
1163            PickerFocus::Calendar => picker.move_days(7),
1164            PickerFocus::Hour => picker.bump_hour(-1),
1165            PickerFocus::Minute => picker.bump_minute(-5),
1166        },
1167        KeyCode::PageUp => match picker.focus {
1168            PickerFocus::Calendar => picker.move_months(-1),
1169            PickerFocus::Hour => picker.bump_hour(1),
1170            PickerFocus::Minute => picker.bump_minute(15),
1171        },
1172        KeyCode::PageDown => match picker.focus {
1173            PickerFocus::Calendar => picker.move_months(1),
1174            PickerFocus::Hour => picker.bump_hour(-1),
1175            PickerFocus::Minute => picker.bump_minute(-15),
1176        },
1177        // Space on the clock → now; digits type hour/minute directly.
1178        KeyCode::Char(' ') if picker.focus != PickerFocus::Calendar => picker.now_time(),
1179        KeyCode::Char(c) if c.is_ascii_digit() => picker.type_digit(c as u8 - b'0'),
1180        _ => {}
1181    }
1182}
1183
1184/// Returns true when the key belonged to the open slash menu.
1185fn handle_menu_key(app: &mut App, key: KeyEvent) -> bool {
1186    let Some(form) = app
1187        .form
1188        .as_mut()
1189        .filter(|form| form.description.menu.is_some())
1190    else {
1191        return false;
1192    };
1193    // Split the borrow: menu keys only need the description; clipboard work needs App.
1194    let outcome = {
1195        // Applying a command (Enter/Tab) mutates structure — checkpoint first.
1196        if matches!(key.code, KeyCode::Enter | KeyCode::Tab) {
1197            form.before_edit(EditKind::Atomic);
1198        }
1199        description_menu_key(&mut form.description, key)
1200    };
1201    match outcome {
1202        MenuKey::Ignored => false,
1203        MenuKey::Handled => true,
1204        MenuKey::Request(request) => {
1205            finish_description_command(app, request);
1206            true
1207        }
1208    }
1209}
1210
1211enum MenuKey {
1212    Ignored,
1213    Handled,
1214    Request(crate::description::CommandRequest),
1215}
1216
1217fn description_menu_key(
1218    description: &mut crate::description::DescriptionEditor,
1219    key: KeyEvent,
1220) -> MenuKey {
1221    match key.code {
1222        KeyCode::Up => {
1223            description.menu_prev();
1224            MenuKey::Handled
1225        }
1226        KeyCode::Down => {
1227            description.menu_next();
1228            MenuKey::Handled
1229        }
1230        KeyCode::Esc => {
1231            description.close_menu();
1232            MenuKey::Handled
1233        }
1234        KeyCode::Tab | KeyCode::Enter => match description.menu_selected() {
1235            Some(command) => match description.apply(command) {
1236                Some(request) => MenuKey::Request(request),
1237                None => MenuKey::Handled,
1238            },
1239            None => {
1240                description.close_menu();
1241                MenuKey::Handled
1242            }
1243        },
1244        _ => MenuKey::Ignored,
1245    }
1246}
1247
1248/// Title, then description as plain text (same export as description `/copy`).
1249fn task_clipboard_text(task: &crate::model::Task) -> String {
1250    use crate::model::Block;
1251
1252    // Tasks are already persisted as typed blocks. Formatting them must not
1253    // send stored text back through the editor's path-adoption logic, where
1254    // filesystem contents could silently reinterpret it as a picture.
1255    let mut number = 0usize;
1256    let description = task
1257        .description
1258        .iter()
1259        .filter_map(|block| match block {
1260            Block::Text { text } => {
1261                number = 0;
1262                (!text.trim().is_empty()).then(|| text.clone())
1263            }
1264            Block::Todo { text, done } => {
1265                number = 0;
1266                let mark = if *done { "[✓]" } else { "[ ]" };
1267                Some(format!("{mark} {text}"))
1268            }
1269            Block::Bullet { text } => {
1270                number = 0;
1271                Some(format!("- {text}"))
1272            }
1273            Block::Number { text } => {
1274                number += 1;
1275                Some(format!("{number}. {text}"))
1276            }
1277            Block::Link { url } => {
1278                number = 0;
1279                (!url.trim().is_empty()).then(|| url.clone())
1280            }
1281            Block::Image { .. } => {
1282                number = 0;
1283                None
1284            }
1285        })
1286        .collect::<Vec<_>>()
1287        .join("\n");
1288    if description.is_empty() {
1289        task.title.clone()
1290    } else {
1291        format!("{}\n\n{description}", task.title)
1292    }
1293}
1294
1295fn finish_description_command(app: &mut App, request: crate::description::CommandRequest) {
1296    match request {
1297        crate::description::CommandRequest::Copy(payload) => finish_copy(app, payload),
1298        crate::description::CommandRequest::Paste => paste_from_clipboard(app),
1299    }
1300}
1301
1302#[derive(Debug)]
1303struct ClipboardContent {
1304    image: Option<arboard::ImageData<'static>>,
1305    text: Option<String>,
1306}
1307
1308fn resolve_clipboard_content(
1309    image: Result<arboard::ImageData<'static>, arboard::Error>,
1310    text: Result<String, arboard::Error>,
1311) -> Result<Option<ClipboardContent>, String> {
1312    let mut errors = Vec::new();
1313    let image = match image {
1314        Ok(image) => Some(image),
1315        Err(arboard::Error::ContentNotAvailable) => None,
1316        Err(error) => {
1317            errors.push(format!("image ({error})"));
1318            None
1319        }
1320    };
1321    let text = match text {
1322        Ok(text) if !text.is_empty() => Some(text),
1323        Ok(_) | Err(arboard::Error::ContentNotAvailable) => None,
1324        Err(error) => {
1325            errors.push(format!("text ({error})"));
1326            None
1327        }
1328    };
1329    if image.is_some() || text.is_some() {
1330        Ok(Some(ClipboardContent { image, text }))
1331    } else if errors.is_empty() {
1332        Ok(None)
1333    } else {
1334        Err(format!("could not read clipboard {}", errors.join(" or ")))
1335    }
1336}
1337
1338fn read_clipboard_content(
1339    clipboard: &mut arboard::Clipboard,
1340) -> Result<Option<ClipboardContent>, String> {
1341    resolve_clipboard_content(clipboard.get_image(), clipboard.get_text())
1342}
1343
1344fn paste_from_clipboard(app: &mut App) {
1345    let mut clipboard = match arboard::Clipboard::new() {
1346        Ok(clipboard) => clipboard,
1347        Err(error) => {
1348            app.error(format!("Could not paste: {error}"));
1349            return;
1350        }
1351    };
1352    let content = match read_clipboard_content(&mut clipboard) {
1353        Ok(Some(content)) => content,
1354        Ok(None) => {
1355            app.info("Nothing to paste");
1356            return;
1357        }
1358        Err(error) => {
1359            app.error(format!("Could not paste: {error}"));
1360            return;
1361        }
1362    };
1363
1364    paste_clipboard_content(app, content);
1365}
1366
1367fn paste_clipboard_content(app: &mut App, content: ClipboardContent) {
1368    let ClipboardContent { image, text } = content;
1369    let pasted_text = text.is_some();
1370    let (pasted_image, image_error, category_ignored_image) = match app.mode {
1371        Mode::TaskForm => {
1372            let Some(form) = app
1373                .form
1374                .as_mut()
1375                .filter(|form| form.field == Field::Description)
1376            else {
1377                debug_assert!(false, "paste requires an active task description");
1378                return;
1379            };
1380            // When both representations exist, keep their deterministic
1381            // visual order: clipboard text first, then its image.
1382            if let Some(text) = text {
1383                form.description.insert_str(&text);
1384            }
1385            let (pasted_image, image_error) = match image.map(crate::image::stage_clipboard_image) {
1386                Some(Ok(image)) => {
1387                    let inserted = form.insert_temporary_image(image);
1388                    (
1389                        inserted,
1390                        (!inserted).then(|| "this field is full".to_string()),
1391                    )
1392                }
1393                Some(Err(error)) => (false, Some(error)),
1394                None => (false, None),
1395            };
1396            (pasted_image, image_error, false)
1397        }
1398        Mode::CategoryForm => {
1399            let Some(form) = app
1400                .category_form
1401                .as_mut()
1402                .filter(|form| form.on_description)
1403            else {
1404                debug_assert!(false, "paste requires an active category description");
1405                return;
1406            };
1407            if let Some(text) = text {
1408                form.description.insert_str(&text);
1409            }
1410            (false, None, image.is_some())
1411        }
1412        _ => {
1413            debug_assert!(false, "paste requires an active description editor");
1414            return;
1415        }
1416    };
1417
1418    if let Some(error) = image_error {
1419        if pasted_text {
1420            app.error(format!("Pasted text, but could not paste image: {error}"));
1421        } else {
1422            app.error(format!("Could not paste image: {error}"));
1423        }
1424        return;
1425    }
1426    match (pasted_text, pasted_image) {
1427        (true, true) => app.info("Pasted text and image from clipboard"),
1428        (true, false) if category_ignored_image => {
1429            app.info("Pasted text; category descriptions accept text only")
1430        }
1431        (true, false) => app.info("Pasted text from clipboard"),
1432        (false, true) => app.info("Pasted image from clipboard"),
1433        (false, false) if category_ignored_image => {
1434            app.info("Category descriptions accept text only")
1435        }
1436        (false, false) => app.info("Nothing to paste"),
1437    }
1438}
1439
1440fn finish_copy(app: &mut App, payload: crate::description::CopyPayload) {
1441    match payload {
1442        crate::description::CopyPayload::Text(text) => {
1443            if text.is_empty() {
1444                app.info("Nothing to copy");
1445                return;
1446            }
1447            match copy_text(&text) {
1448                Ok(ClipboardTarget::System) => app.info("Copied text to clipboard"),
1449                Ok(ClipboardTarget::Terminal) => app.info("Copied text through the terminal"),
1450                Err(err) => app.error(format!("Could not copy: {err}")),
1451            }
1452        }
1453        crate::description::CopyPayload::Image(path) => match copy_image_file(&path) {
1454            Ok(()) => app.info("Copied image to clipboard"),
1455            Err(err) => app.error(format!("Could not copy image: {err}")),
1456        },
1457        crate::description::CopyPayload::All(lines) => {
1458            if lines.is_empty() {
1459                app.info("Nothing to copy");
1460                return;
1461            }
1462            match copy_all(&lines) {
1463                Ok(ClipboardTarget::System) => app.info("Copied text and pictures"),
1464                Ok(ClipboardTarget::Terminal) => app.info("Copied plain text through the terminal"),
1465                Err(err) => app.error(format!("Could not copy: {err}")),
1466            }
1467        }
1468    }
1469}
1470
1471#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1472enum ClipboardTarget {
1473    System,
1474    Terminal,
1475}
1476
1477const MAX_OSC52_RAW_BYTES: usize = 64 * 1024;
1478const MAX_OSC52_ENCODED_BYTES: usize = 80 * 1024;
1479const MAX_RICH_CLIPBOARD_BYTES: usize = 8 * 1024 * 1024;
1480
1481fn copy_text(text: &str) -> Result<ClipboardTarget, String> {
1482    copy_with_terminal_fallback(text, || {
1483        arboard::Clipboard::new().and_then(|mut clipboard| clipboard.set_text(text))
1484    })
1485}
1486
1487fn copy_with_terminal_fallback(
1488    text: &str,
1489    system_copy: impl FnOnce() -> Result<(), arboard::Error>,
1490) -> Result<ClipboardTarget, String> {
1491    match system_copy() {
1492        Ok(()) => Ok(ClipboardTarget::System),
1493        Err(system_error) => osc52_copy(text).map_err(|terminal_error| {
1494            format!("system clipboard: {system_error}; terminal clipboard: {terminal_error}")
1495        }),
1496    }
1497}
1498
1499fn osc52_copy(text: &str) -> Result<ClipboardTarget, String> {
1500    use std::io::Write;
1501
1502    let sequence = osc52_sequence(text)?;
1503    let mut stdout = std::io::stdout().lock();
1504    stdout
1505        .write_all(sequence.as_bytes())
1506        .and_then(|()| stdout.flush())
1507        .map_err(|error| error.to_string())?;
1508    Ok(ClipboardTarget::Terminal)
1509}
1510
1511fn osc52_sequence(text: &str) -> Result<String, String> {
1512    use base64::Engine;
1513
1514    if text.len() > MAX_OSC52_RAW_BYTES {
1515        return Err(format!(
1516            "OSC 52 text is {} bytes; raw limit is {MAX_OSC52_RAW_BYTES} bytes",
1517            text.len()
1518        ));
1519    }
1520    let encoded = base64::engine::general_purpose::STANDARD.encode(text.as_bytes());
1521    if encoded.len() > MAX_OSC52_ENCODED_BYTES {
1522        return Err(format!(
1523            "OSC 52 payload is {} bytes; encoded limit is {MAX_OSC52_ENCODED_BYTES} bytes",
1524            encoded.len()
1525        ));
1526    }
1527    Ok(format!("\x1b]52;c;{encoded}\x07"))
1528}
1529
1530/// Decode a description image file and put its pixels on the system clipboard.
1531fn copy_image_file(path: &std::path::Path) -> Result<(), String> {
1532    let rgba = crate::image::load_dynamic(path)?.into_rgba8();
1533    let (width, height) = rgba.dimensions();
1534    let data = arboard::ImageData {
1535        width: width as usize,
1536        height: height as usize,
1537        bytes: rgba.into_raw().into(),
1538    };
1539    arboard::Clipboard::new()
1540        .and_then(|mut c| c.set_image(data))
1541        .map_err(|e| e.to_string())
1542}
1543
1544/// Put the whole description on the clipboard as HTML (with embedded images)
1545/// plus a plain-text fallback. Notes, browsers, and mail clients can
1546/// paste the rich form; terminals get the text.
1547fn copy_all(lines: &[crate::description::CopyLine]) -> Result<ClipboardTarget, String> {
1548    let (plain, html) = build_clipboard_payload(lines, MAX_RICH_CLIPBOARD_BYTES);
1549
1550    copy_with_terminal_fallback(&plain, || {
1551        arboard::Clipboard::new()
1552            .and_then(|mut clipboard| clipboard.set_html(html.as_str(), Some(plain.as_str())))
1553    })
1554}
1555
1556fn build_clipboard_payload(
1557    lines: &[crate::description::CopyLine],
1558    rich_budget: usize,
1559) -> (String, String) {
1560    build_clipboard_payload_with(lines, rich_budget, image_data_url)
1561}
1562
1563fn build_clipboard_payload_with(
1564    lines: &[crate::description::CopyLine],
1565    rich_budget: usize,
1566    mut load_image: impl FnMut(&std::path::Path, usize) -> Result<String, String>,
1567) -> (String, String) {
1568    use crate::description::CopyLine;
1569
1570    const IMAGE_PREFIX: &str = r#"<div><img src=""#;
1571    const IMAGE_SUFFIX: &str = r#"" /></div>"#;
1572    let mut html = String::new();
1573    let mut plain = String::new();
1574    for (i, line) in lines.iter().enumerate() {
1575        if i > 0 {
1576            plain.push('\n');
1577        }
1578        match line {
1579            CopyLine::Text(text) => {
1580                plain.push_str(text);
1581                push_rich_fragment(
1582                    &mut html,
1583                    &format!("<div>{}</div>", escape_html(text)),
1584                    rich_budget,
1585                );
1586            }
1587            CopyLine::Link(url) => {
1588                plain.push_str(url);
1589                let label = escape_html(url);
1590                let fragment = match crate::open::normalize_url(url) {
1591                    Some(url) => {
1592                        let href = escape_html(&url);
1593                        format!("<div><a href=\"{href}\">{label}</a></div>")
1594                    }
1595                    None => format!("<div>{label}</div>"),
1596                };
1597                push_rich_fragment(&mut html, &fragment, rich_budget);
1598            }
1599            CopyLine::Image(path) => {
1600                let label = format!("[image: {}]", path.display());
1601                plain.push_str(&label);
1602                let url_budget = rich_budget
1603                    .saturating_sub(html.len())
1604                    .saturating_sub(IMAGE_PREFIX.len() + IMAGE_SUFFIX.len());
1605                let image = load_image(path, url_budget)
1606                    .ok()
1607                    .filter(|url| url.len() <= url_budget)
1608                    .map(|url| format!("{IMAGE_PREFIX}{url}{IMAGE_SUFFIX}"));
1609                let fragment = image.unwrap_or_else(|| {
1610                    // Keep layout order without letting data URLs consume an
1611                    // unbounded clipboard string.
1612                    format!("<div>{}</div>", escape_html(&label))
1613                });
1614                push_rich_fragment(&mut html, &fragment, rich_budget);
1615            }
1616        }
1617    }
1618    (plain, html)
1619}
1620
1621fn push_rich_fragment(html: &mut String, fragment: &str, budget: usize) {
1622    if html.len().saturating_add(fragment.len()) <= budget {
1623        html.push_str(fragment);
1624    }
1625}
1626
1627fn image_data_url(path: &std::path::Path, url_budget: usize) -> Result<String, String> {
1628    use base64::Engine;
1629    use image::ImageEncoder;
1630    use std::io::Write;
1631
1632    const PREFIX: &str = "data:image/png;base64,";
1633    let encoded_budget = url_budget
1634        .checked_sub(PREFIX.len())
1635        .ok_or_else(|| "rich clipboard image budget is exhausted".to_string())?;
1636    // Base64 expands three bytes into four. Keep the encoded URL inside the
1637    // caller's remaining rich-payload budget before allocating its String.
1638    let png_budget = (encoded_budget / 4) * 3;
1639    if png_budget == 0 {
1640        return Err("rich clipboard image budget is exhausted".to_string());
1641    }
1642
1643    struct BoundedPng {
1644        bytes: Vec<u8>,
1645        limit: usize,
1646    }
1647
1648    impl Write for BoundedPng {
1649        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
1650            if self.bytes.len().saturating_add(buf.len()) > self.limit {
1651                return Err(std::io::Error::other(
1652                    "encoded image exceeds rich clipboard budget",
1653                ));
1654            }
1655            self.bytes.extend_from_slice(buf);
1656            Ok(buf.len())
1657        }
1658
1659        fn flush(&mut self) -> std::io::Result<()> {
1660            Ok(())
1661        }
1662    }
1663
1664    let rgba = crate::image::load_dynamic(path)?.into_rgba8();
1665    let (width, height) = rgba.dimensions();
1666    let mut png = BoundedPng {
1667        bytes: Vec::new(),
1668        limit: png_budget,
1669    };
1670    image::codecs::png::PngEncoder::new(&mut png)
1671        .write_image(
1672            rgba.as_raw(),
1673            width,
1674            height,
1675            image::ExtendedColorType::Rgba8,
1676        )
1677        .map_err(|e| format!("{}: {e}", path.display()))?;
1678    let b64 = base64::engine::general_purpose::STANDARD.encode(png.bytes);
1679    let url = format!("{PREFIX}{b64}");
1680    if url.len() > url_budget {
1681        return Err("encoded image exceeds rich clipboard budget".to_string());
1682    }
1683    Ok(url)
1684}
1685
1686fn escape_html(s: &str) -> String {
1687    let mut out = String::with_capacity(s.len());
1688    for c in s.chars() {
1689        match c {
1690            '&' => out.push_str("&amp;"),
1691            '<' => out.push_str("&lt;"),
1692            '>' => out.push_str("&gt;"),
1693            '"' => out.push_str("&quot;"),
1694            _ => out.push(c),
1695        }
1696    }
1697    out
1698}
1699
1700// -------------------------------------------------------------- settings
1701
1702fn handle_settings_key(app: &mut App, key: KeyEvent) {
1703    match key.code {
1704        KeyCode::Esc => app.mode = Mode::Normal,
1705        KeyCode::Up => {
1706            app.settings_index = app.settings_index.saturating_sub(1);
1707        }
1708        KeyCode::Down => {
1709            app.settings_index = (app.settings_index + 1).min(crate::app::SETTINGS_ITEMS.len() - 1);
1710        }
1711        KeyCode::Right | KeyCode::Tab => app.cycle_setting(app.settings_index, 1),
1712        KeyCode::Left | KeyCode::BackTab => app.cycle_setting(app.settings_index, -1),
1713        _ => {}
1714    }
1715}
1716
1717fn handle_labels_key(app: &mut App, key: KeyEvent) {
1718    if app.label_editor.is_some() {
1719        match key.code {
1720            KeyCode::Esc => app.cancel_label_editor(),
1721            KeyCode::Enter => app.submit_label_editor(),
1722            KeyCode::Char('s') | KeyCode::Char('S')
1723                if key.modifiers.contains(KeyModifiers::CONTROL) =>
1724            {
1725                app.submit_label_editor()
1726            }
1727            KeyCode::Tab | KeyCode::BackTab => {
1728                if let Some(editor) = &mut app.label_editor {
1729                    editor.color_focused = !editor.color_focused;
1730                }
1731                app.label_error = None;
1732                app.dirty = true;
1733            }
1734            KeyCode::Left | KeyCode::Right
1735                if app
1736                    .label_editor
1737                    .as_ref()
1738                    .is_some_and(|editor| editor.color_focused) =>
1739            {
1740                if let Some(editor) = &mut app.label_editor {
1741                    editor.move_color(if key.code == KeyCode::Left { -1 } else { 1 });
1742                }
1743                app.label_error = None;
1744                app.dirty = true;
1745            }
1746            _ => {
1747                if let Some(editor) = &mut app.label_editor
1748                    && !editor.color_focused
1749                {
1750                    edit_line(&mut editor.name, key);
1751                }
1752                app.label_error = None;
1753                app.dirty = true;
1754            }
1755        }
1756        return;
1757    }
1758
1759    match key.code {
1760        KeyCode::Esc => app.close_labels(),
1761        KeyCode::Left => app.move_label_selection(-1),
1762        KeyCode::Right => app.move_label_selection(1),
1763        KeyCode::Up => move_label_selection_row(app, false),
1764        KeyCode::Down => move_label_selection_row(app, true),
1765        KeyCode::PageUp => app.move_label_selection(-10),
1766        KeyCode::PageDown => app.move_label_selection(10),
1767        KeyCode::Home => {
1768            if app.label_index != 0 {
1769                app.label_index = 0;
1770                app.cancel_pending();
1771                app.dirty = true;
1772            }
1773        }
1774        KeyCode::End => {
1775            let last = app.labels.len().saturating_sub(1);
1776            if app.label_index != last {
1777                app.label_index = last;
1778                app.cancel_pending();
1779                app.dirty = true;
1780            }
1781        }
1782        KeyCode::Char('a') | KeyCode::Char('A')
1783            if key.modifiers.contains(KeyModifiers::CONTROL) =>
1784        {
1785            app.begin_new_label()
1786        }
1787        KeyCode::Enter => app.begin_rename_label(),
1788        KeyCode::Backspace => {
1789            let Some(label) = app.selected_label().cloned() else {
1790                return;
1791            };
1792            let confirm = Confirm::DeleteLabel(label.id.clone());
1793            if app.awaiting(confirm.clone()) {
1794                if app.delete_label_by_id(&label.id) {
1795                    app.info(format!("Label {} deleted and unassigned", label.name));
1796                }
1797            } else {
1798                app.ask_confirm(
1799                    confirm,
1800                    format!(
1801                        "Press Backspace again to delete {} and remove it from every task",
1802                        label.name
1803                    ),
1804                );
1805            }
1806        }
1807        _ => {}
1808    }
1809}
1810
1811fn move_label_selection_row(app: &mut App, down: bool) {
1812    let Some((_, selected)) = app
1813        .areas
1814        .label_hits
1815        .iter()
1816        .find(|(index, _)| *index == app.label_index)
1817        .copied()
1818    else {
1819        app.move_label_selection(if down { 1 } else { -1 });
1820        return;
1821    };
1822    let selected_center = selected.x.saturating_add(selected.width / 2);
1823    let candidate = app
1824        .areas
1825        .label_hits
1826        .iter()
1827        .filter(|(_, rect)| {
1828            if down {
1829                rect.y > selected.y
1830            } else {
1831                rect.y < selected.y
1832            }
1833        })
1834        .min_by_key(|(_, rect)| {
1835            let row_distance = selected.y.abs_diff(rect.y);
1836            let center = rect.x.saturating_add(rect.width / 2);
1837            (row_distance, selected_center.abs_diff(center))
1838        })
1839        .map(|(index, _)| *index);
1840    if let Some(index) = candidate {
1841        app.label_index = index;
1842        app.cancel_pending();
1843        app.dirty = true;
1844    } else {
1845        app.move_label_selection(if down { 1 } else { -1 });
1846    }
1847}
1848
1849#[derive(Clone, Copy)]
1850enum FormCloseSource {
1851    Escape,
1852    OutsideClick,
1853}
1854
1855impl FormCloseSource {
1856    const fn discard_prompt(self) -> &'static str {
1857        match self {
1858            Self::Escape => "Unsaved changes · press Esc again to discard",
1859            Self::OutsideClick => "Unsaved changes · press Esc to discard",
1860        }
1861    }
1862}
1863
1864/// Close a form only when there is no content to lose, or after the same
1865/// entity-bound discard action is explicitly confirmed with Esc.
1866#[derive(Clone, Copy)]
1867enum OpenForm {
1868    Task,
1869    Category,
1870}
1871
1872fn request_close_form(app: &mut App, form: OpenForm, source: FormCloseSource) -> bool {
1873    let state = match form {
1874        OpenForm::Task => app
1875            .form
1876            .as_ref()
1877            .map(|form| (form.is_dirty(), Confirm::DiscardTask(form.editing.clone()))),
1878        OpenForm::Category => app.category_form.as_ref().map(|form| {
1879            (
1880                form.is_dirty(),
1881                Confirm::DiscardCategory(form.editing.clone()),
1882            )
1883        }),
1884    };
1885    let Some((dirty, confirm)) = state else {
1886        return true;
1887    };
1888    let confirmed = !dirty || app.awaiting(confirm.clone());
1889    if confirmed {
1890        match form {
1891            OpenForm::Task => app.close_form(),
1892            OpenForm::Category => app.close_category_form(),
1893        }
1894        return true;
1895    }
1896    app.ask_confirm(confirm, source.discard_prompt());
1897    false
1898}
1899
1900// ------------------------------------------------------------------ mouse
1901
1902fn handle_mouse(app: &mut App, m: MouseEvent) {
1903    app.cancel_pending();
1904    if app.mode == Mode::Slash {
1905        handle_slash_mouse(app, m);
1906        return;
1907    }
1908    if app.mode == Mode::Labels {
1909        handle_labels_mouse(app, m);
1910        return;
1911    }
1912    if app.mode == Mode::TaskForm {
1913        // The full-size image is modal over the panels. Route it before
1914        // hit-testing the list beneath, or a preview click can close the form.
1915        if app.form.as_ref().is_some_and(|form| form.preview) {
1916            handle_form_mouse(app, m);
1917            return;
1918        }
1919        // A clean editor may yield to the underlying panels. Dirty content
1920        // stays modal; Esc is the explicit discard path.
1921        if click_on_panels(app, m) {
1922            if !request_close_form(app, OpenForm::Task, FormCloseSource::OutsideClick) {
1923                return;
1924            }
1925        } else {
1926            handle_form_mouse(app, m);
1927            return;
1928        }
1929    }
1930    if app.mode == Mode::CategoryForm {
1931        if click_on_panels(app, m) {
1932            if !request_close_form(app, OpenForm::Category, FormCloseSource::OutsideClick) {
1933                return;
1934            }
1935        } else {
1936            if m.kind == MouseEventKind::Down(MouseButton::Left)
1937                && app
1938                    .category_form
1939                    .as_ref()
1940                    .is_some_and(|form| form.description.menu.is_some())
1941            {
1942                match click_form_slash_menu(app, OpenForm::Category, m.column, m.row) {
1943                    MenuClick::Handled => return,
1944                    MenuClick::Miss => {}
1945                }
1946            }
1947            let clicked_link = if let (MouseEventKind::Down(MouseButton::Left), Some(form)) =
1948                (m.kind, &mut app.category_form)
1949            {
1950                if contains(form.name_area, m.column, m.row) {
1951                    form.set_description_focus(false);
1952                    form.name
1953                        .set_cursor_from_col((m.column - form.name_area.x) as usize);
1954                    None
1955                } else if contains(form.description_area, m.column, m.row) {
1956                    form.set_description_focus(true);
1957                    let row = m.row - form.description_area.y;
1958                    let column = (m.column - form.description_area.x) as usize;
1959                    let url = form.description.link_url_at_position(row, column);
1960                    form.description.click(row, column).then_some(url).flatten()
1961                } else {
1962                    None
1963                }
1964            } else {
1965                None
1966            };
1967            if let Some(url) = clicked_link {
1968                open_link(app, &url);
1969            }
1970            return;
1971        }
1972    }
1973    if app.mode.is_overlay() {
1974        return;
1975    }
1976    match m.kind {
1977        // The wheel works on the list under the pointer, not on whichever
1978        // panel holds the keyboard focus — so you can spin through
1979        // categories without first clicking into them. Focus stays put.
1980        MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => {
1981            let delta = if m.kind == MouseEventKind::ScrollUp {
1982                -1
1983            } else {
1984                1
1985            };
1986            if contains(app.areas.tasks, m.column, m.row) {
1987                app.move_task_selection(delta);
1988            } else if contains(app.areas.sidebar, m.column, m.row) && !app.searching {
1989                // Same reason clicking a category is blocked mid-search:
1990                // picking one would silently drop the query.
1991                app.move_category_selection(delta);
1992            }
1993        }
1994        MouseEventKind::Down(MouseButton::Left) => {
1995            let (x, y) = (m.column, m.row);
1996            let sidebar = app.areas.sidebar;
1997            let tasks = app.areas.tasks;
1998            if contains(app.areas.command_bar, x, y) {
1999                focus_command_bar(app, x);
2000            } else if contains(sidebar, x, y) {
2001                if app.searching {
2002                    return;
2003                }
2004                let _ = app.set_focus(Focus::Sidebar);
2005                let row = app.cat_state.offset() + (y - sidebar.y) as usize;
2006                if row >= app.categories.len() {
2007                    return;
2008                }
2009                app.select_category(row);
2010                if clicked_again(app, ClickTarget::Sidebar, row) {
2011                    app.open_edit_category();
2012                }
2013            } else if contains(tasks, x, y) {
2014                let _ = app.set_focus(Focus::Tasks);
2015                let visual = app.task_state.offset() + (y - tasks.y) as usize;
2016                let Some(row) = app.task_at_visual_row(visual) else {
2017                    // Separator / empty — no task under the pointer.
2018                    return;
2019                };
2020                // The checkbox and the flags toggle when clicked
2021                // directly; the flags are the last column, so everything
2022                // from their first cell rightwards counts as them.
2023                let on_flags = app.areas.flag_x.is_some_and(|at| x >= at);
2024                let on_done = app
2025                    .areas
2026                    .done_x
2027                    .is_some_and(|at| x >= at && x < at + crate::ui::DONE_MARK_WIDTH);
2028                if on_flags {
2029                    app.cycle_importance(row);
2030                } else if on_done {
2031                    app.toggle_done(row);
2032                } else {
2033                    // Anywhere else selects, and selecting twice in
2034                    // quick succession opens the task.
2035                    app.select_task(row);
2036                    if clicked_again(app, ClickTarget::Tasks, row) {
2037                        app.open_edit_task();
2038                    }
2039                }
2040            } else if contains(app.areas.preview, x, y) && app.selected_task().is_some() {
2041                // Click the permanent preview to edit the selected task.
2042                let _ = app.set_focus(Focus::Tasks);
2043                app.open_edit_task();
2044            }
2045        }
2046        _ => {}
2047    }
2048}
2049
2050fn handle_labels_mouse(app: &mut App, mouse: MouseEvent) {
2051    if mouse.kind != MouseEventKind::Down(MouseButton::Left) {
2052        return;
2053    }
2054    if app.label_editor.is_some() {
2055        let swatch = app
2056            .areas
2057            .label_color_hits
2058            .iter()
2059            .find(|(_, area)| contains(*area, mouse.column, mouse.row))
2060            .map(|(color, _)| *color);
2061        if let Some(editor) = &mut app.label_editor {
2062            if let Some(color) = swatch {
2063                editor.color = color;
2064                editor.color_focused = true;
2065                app.label_error = None;
2066                app.dirty = true;
2067            } else if contains(app.areas.label_name_input, mouse.column, mouse.row) {
2068                editor.color_focused = false;
2069                editor.name.set_cursor_from_col(
2070                    mouse.column.saturating_sub(app.areas.label_name_input.x) as usize,
2071                );
2072                app.dirty = true;
2073            }
2074        }
2075        return;
2076    }
2077    let Some(row) = app
2078        .areas
2079        .label_hits
2080        .iter()
2081        .find(|(_, area)| contains(*area, mouse.column, mouse.row))
2082        .map(|(index, _)| *index)
2083    else {
2084        return;
2085    };
2086    app.label_index = row;
2087    app.dirty = true;
2088    if clicked_again(app, ClickTarget::Labels, row) {
2089        app.begin_rename_label();
2090    }
2091}
2092
2093fn handle_slash_mouse(app: &mut App, mouse: MouseEvent) {
2094    let rect = app.areas.slash_menu;
2095    match mouse.kind {
2096        MouseEventKind::ScrollUp | MouseEventKind::ScrollDown
2097            if contains(rect, mouse.column, mouse.row) =>
2098        {
2099            let count = crate::slash::matching(&app.input.value()).len();
2100            if count == 0 {
2101                return;
2102            }
2103            let delta = if mouse.kind == MouseEventKind::ScrollUp {
2104                -1
2105            } else {
2106                1
2107            };
2108            cycle_index(&mut app.slash_index, count, delta);
2109        }
2110        MouseEventKind::Down(MouseButton::Left) => {
2111            if contains(app.areas.command_bar, mouse.column, mouse.row) {
2112                set_command_bar_cursor(app, mouse.column);
2113            } else if contains(rect, mouse.column, mouse.row)
2114                && mouse.row > rect.y
2115                && mouse.row + 1 < rect.bottom()
2116            {
2117                let row = (mouse.row - rect.y - 1) as usize;
2118                let index = app.areas.slash_menu_start + row;
2119                let query = app.input.value();
2120                let commands = crate::slash::matching(&query);
2121                if let Some(command) = commands.get(index).copied() {
2122                    app.slash_index = index;
2123                    close_slash(app);
2124                    run_slash(app, command, &query);
2125                }
2126            } else {
2127                close_slash(app);
2128            }
2129        }
2130        _ => {}
2131    }
2132}
2133
2134/// Give the bottom command bar the same input mode as its keyboard entry
2135/// point. A locked search resumes editing instead of being silently cleared.
2136fn focus_command_bar(app: &mut App, x: u16) {
2137    match app.mode {
2138        Mode::Slash | Mode::Search => {}
2139        Mode::Normal if app.searching => app.resume_search(),
2140        Mode::Normal => app.open_slash(),
2141        _ => return,
2142    }
2143    set_command_bar_cursor(app, x);
2144}
2145
2146fn set_command_bar_cursor(app: &mut App, x: u16) {
2147    // The visible slash occupies the first cell of the command field.
2148    let col = x.saturating_sub(app.areas.command_bar.x).saturating_sub(1) as usize;
2149    app.input.set_cursor_from_col(col);
2150    app.dirty = true;
2151}
2152
2153/// True when a left-click lands on the sidebar or task list (and not on
2154/// an open form control that happens to sit in those coordinates).
2155fn click_on_panels(app: &App, m: MouseEvent) -> bool {
2156    if m.kind != MouseEventKind::Down(MouseButton::Left) {
2157        return false;
2158    }
2159    let (x, y) = (m.column, m.row);
2160    if !contains(app.areas.sidebar, x, y) && !contains(app.areas.tasks, x, y) {
2161        return false;
2162    }
2163    // Prefer form chrome when it overlaps the panels (modal / picker).
2164    if let Some(form) = &app.form {
2165        if contains(form.form_area, x, y) {
2166            return false;
2167        }
2168        if form.areas.field_at(x, y).is_some() {
2169            return false;
2170        }
2171        if form.picker.as_ref().is_some_and(|p| p.contains(x, y)) {
2172            return false;
2173        }
2174        if form
2175            .label_picker_area()
2176            .is_some_and(|area| contains(area, x, y))
2177        {
2178            return false;
2179        }
2180        if form
2181            .description_menu_area
2182            .is_some_and(|r| contains(r, x, y))
2183        {
2184            return false;
2185        }
2186    }
2187    if let Some(form) = &app.category_form
2188        && (contains(form.form_area, x, y)
2189            || contains(form.name_area, x, y)
2190            || contains(form.description_area, x, y)
2191            || form
2192                .description_menu_area
2193                .is_some_and(|r| contains(r, x, y)))
2194    {
2195        return false;
2196    }
2197    true
2198}
2199
2200/// Clicking a field focuses it and puts the cursor where the pointer
2201/// landed. Clicks on the task list or sidebar leave the dialog (see
2202/// [`handle_mouse`]); other outside clicks are ignored. Double-click on
2203/// a picture opens it, same as Enter. The due picker also takes clicks
2204/// and scroll.
2205fn handle_form_mouse(app: &mut App, m: MouseEvent) {
2206    // The label picker owns wheel movement while the pointer is over it.
2207    if matches!(
2208        m.kind,
2209        MouseEventKind::ScrollUp | MouseEventKind::ScrollDown
2210    ) && app.form.as_ref().is_some_and(|form| {
2211        form.label_picker_area()
2212            .is_some_and(|area| contains(area, m.column, m.row))
2213    }) {
2214        let delta = if m.kind == MouseEventKind::ScrollUp {
2215            -1
2216        } else {
2217            1
2218        };
2219        if let Some(form) = &mut app.form {
2220            form.move_label_picker(delta);
2221        }
2222        return;
2223    }
2224
2225    // Scroll over the open date/time picker.
2226    if matches!(
2227        m.kind,
2228        MouseEventKind::ScrollUp | MouseEventKind::ScrollDown
2229    ) && app.form.as_ref().is_some_and(|f| f.picker.is_some())
2230    {
2231        let up = matches!(m.kind, MouseEventKind::ScrollUp);
2232        if let Some(form) = &mut app.form
2233            && let Some(picker) = &mut form.picker
2234        {
2235            let _ = picker.scroll(m.column, m.row, up);
2236        }
2237        return;
2238    }
2239
2240    // Scroll over the open description `/` menu moves the selection.
2241    if matches!(
2242        m.kind,
2243        MouseEventKind::ScrollUp | MouseEventKind::ScrollDown
2244    ) && app
2245        .form
2246        .as_ref()
2247        .is_some_and(|f| f.description.menu.is_some())
2248    {
2249        let up = matches!(m.kind, MouseEventKind::ScrollUp);
2250        if let Some(form) = &mut app.form {
2251            if up {
2252                form.description.menu_prev();
2253            } else {
2254                form.description.menu_next();
2255            }
2256        }
2257        return;
2258    }
2259
2260    if m.kind != MouseEventKind::Down(MouseButton::Left) {
2261        return;
2262    }
2263    // Click in the image preview: pause/resume GIF (does not close).
2264    if app.form.as_ref().is_some_and(|f| f.preview) {
2265        if let Some(form) = &mut app.form {
2266            form.preview_click();
2267        }
2268        return;
2269    }
2270
2271    // Consume picker chrome so re-clicking Labels cannot dismiss and
2272    // immediately reopen the overlay.
2273    if app
2274        .form
2275        .as_ref()
2276        .is_some_and(|form| form.label_picker_open())
2277    {
2278        let inside = app.form.as_ref().is_some_and(|form| {
2279            form.label_picker_area()
2280                .is_some_and(|area| contains(area, m.column, m.row))
2281        });
2282        if inside {
2283            let row = app
2284                .form
2285                .as_ref()
2286                .and_then(|form| form.label_picker_row_at(m.column, m.row));
2287            if let Some(index) = row {
2288                let manage = if let Some(form) = &mut app.form {
2289                    form.select_label_picker(index);
2290                    form.label_picker_manage_selected()
2291                } else {
2292                    false
2293                };
2294                if manage {
2295                    app.open_labels_from_form();
2296                } else if let Some(form) = &mut app.form {
2297                    if let Err(error) = form.toggle_current_label() {
2298                        form.error = Some(error.to_string());
2299                    } else {
2300                        form.error = None;
2301                    }
2302                }
2303            }
2304            return;
2305        }
2306        if let Some(form) = &mut app.form {
2307            form.close_label_picker();
2308        }
2309    }
2310
2311    // Clicks on the date/time picker (days, hour, minute).
2312    if app.form.as_ref().is_some_and(|f| f.picker.is_some()) {
2313        let Some(form) = &mut app.form else { return };
2314        let handled = form
2315            .picker
2316            .as_mut()
2317            .is_some_and(|p| p.click(m.column, m.row));
2318        if handled {
2319            return;
2320        }
2321        // Click outside the picker closes it (unless reopening Due).
2322        if !form.areas.due.contains(ratatui::layout::Position {
2323            x: m.column,
2324            y: m.row,
2325        }) {
2326            form.picker = None;
2327        }
2328    }
2329
2330    // Description `/` menu: click a row to run it; click elsewhere closes the
2331    // menu only (dialog stays open). Handled before description.click, which
2332    // would otherwise dismiss the menu without selecting anything.
2333    if app
2334        .form
2335        .as_ref()
2336        .is_some_and(|f| f.description.menu.is_some())
2337    {
2338        match click_form_slash_menu(app, OpenForm::Task, m.column, m.row) {
2339            MenuClick::Handled => return,
2340            MenuClick::Miss => {
2341                // Fall through: place the cursor / change field, menu
2342                // closes via description.click or set_field.
2343            }
2344        }
2345    }
2346
2347    enum AfterClick {
2348        None,
2349        OpenUrl(String),
2350        PreviewErr(String),
2351    }
2352    let after = {
2353        let Some(form) = &mut app.form else { return };
2354        let Some(field) = form.areas.field_at(m.column, m.row) else {
2355            // Click outside the dialog fields — do not keep a pending
2356            // double-click that could open a picture on the next hit.
2357            form.last_description_click = None;
2358            return;
2359        };
2360        // Leaving Due dismisses the calendar so keys go to the new field.
2361        form.set_field(field);
2362        let last_description_click = form.last_description_click.take();
2363
2364        let area = form.areas.rect(field);
2365        let col = (m.column - area.x) as usize;
2366        let row = (m.row - area.y) as usize;
2367        match field {
2368            Field::Title => {
2369                form.title.set_cursor_from_col(col);
2370                AfterClick::None
2371            }
2372            Field::Due => {
2373                form.open_due_picker();
2374                AfterClick::None
2375            }
2376            Field::Category => {
2377                form.cycle_category(1);
2378                AfterClick::None
2379            }
2380            Field::Labels => {
2381                form.open_label_picker();
2382                AfterClick::None
2383            }
2384            Field::Description => {
2385                // Resolve the link against the painted glyphs before click()
2386                // moves the cursor; blank row padding is not a hit target.
2387                let clicked_link = form.description.link_url_at_position(row as u16, col);
2388                let hit = form.description.click(row as u16, col);
2389                if !hit {
2390                    AfterClick::None
2391                } else if let Some(url) = clicked_link {
2392                    AfterClick::OpenUrl(url)
2393                } else if form.description.selected_image().is_some() {
2394                    // Pictures are letterboxed — only the drawn box counts.
2395                    // Gutter clicks must not select the picture or insert a
2396                    // blank line (←/→ are what create a caret next to it).
2397                    let line = form.description.cursor_line();
2398                    if !form.image_hit_at(line, m.column, m.row) {
2399                        form.description.abandon_image_selection();
2400                        AfterClick::None
2401                    } else {
2402                        let now = Instant::now();
2403                        let again = last_description_click.is_some_and(|(at, last)| {
2404                            last == line && now.duration_since(at) < DOUBLE_CLICK
2405                        });
2406                        if again {
2407                            match form.open_image_preview() {
2408                                Some(err) => AfterClick::PreviewErr(err),
2409                                None => AfterClick::None,
2410                            }
2411                        } else {
2412                            form.last_description_click = Some((now, line));
2413                            AfterClick::None
2414                        }
2415                    }
2416                } else {
2417                    AfterClick::None
2418                }
2419            }
2420            Field::Importance => {
2421                form.cycle_importance();
2422                AfterClick::None
2423            }
2424        }
2425    };
2426    match after {
2427        AfterClick::None => {}
2428        AfterClick::OpenUrl(url) => open_link(app, &url),
2429        AfterClick::PreviewErr(err) => app.error(err),
2430    }
2431}
2432
2433enum MenuClick {
2434    /// Click was on the menu (selected a row or the chrome).
2435    Handled,
2436    /// Click missed the menu rect entirely.
2437    Miss,
2438}
2439
2440enum MenuHit {
2441    Handled,
2442    Command {
2443        index: usize,
2444        command: crate::description::Command,
2445    },
2446    Miss,
2447}
2448
2449fn slash_menu_hit(
2450    description: &crate::description::DescriptionEditor,
2451    rect: Option<ratatui::layout::Rect>,
2452    x: u16,
2453    y: u16,
2454) -> MenuHit {
2455    let Some(rect) = rect else {
2456        return MenuHit::Miss;
2457    };
2458    if !contains(rect, x, y) {
2459        return MenuHit::Miss;
2460    }
2461
2462    // Rows sit inside the border: top border at rect.y, first command at y+1.
2463    let commands = description.menu_commands();
2464    if commands.is_empty() || y <= rect.y || y >= rect.bottom().saturating_sub(1) {
2465        return MenuHit::Handled;
2466    }
2467    let index = (y - rect.y - 1) as usize;
2468    match commands.get(index).copied() {
2469        Some(command) => MenuHit::Command { index, command },
2470        None => MenuHit::Handled,
2471    }
2472}
2473
2474/// Hit-test an open form's description `/` dropdown. Clicking a command row runs it.
2475fn click_form_slash_menu(app: &mut App, form_kind: OpenForm, x: u16, y: u16) -> MenuClick {
2476    let hit = match form_kind {
2477        OpenForm::Task => app.form.as_ref().map_or(MenuHit::Miss, |form| {
2478            slash_menu_hit(&form.description, form.description_menu_area, x, y)
2479        }),
2480        OpenForm::Category => app.category_form.as_ref().map_or(MenuHit::Miss, |form| {
2481            slash_menu_hit(&form.description, form.description_menu_area, x, y)
2482        }),
2483    };
2484    let (index, command) = match hit {
2485        MenuHit::Miss => return MenuClick::Miss,
2486        MenuHit::Handled => return MenuClick::Handled,
2487        MenuHit::Command { index, command } => (index, command),
2488    };
2489    let request = match form_kind {
2490        OpenForm::Task => {
2491            let Some(form) = app.form.as_mut() else {
2492                return MenuClick::Miss;
2493            };
2494            if let Some(menu) = &mut form.description.menu {
2495                menu.index = index;
2496            }
2497            form.before_edit(EditKind::Atomic);
2498            form.description.apply(command)
2499        }
2500        OpenForm::Category => {
2501            let Some(form) = app.category_form.as_mut() else {
2502                return MenuClick::Miss;
2503            };
2504            if let Some(menu) = &mut form.description.menu {
2505                menu.index = index;
2506            }
2507            form.before_edit(EditKind::Atomic);
2508            form.description.apply(command)
2509        }
2510    };
2511    if let Some(request) = request {
2512        finish_description_command(app, request);
2513    }
2514    MenuClick::Handled
2515}
2516
2517/// Whether this click lands on the row the last one did, soon enough to
2518/// count as a double click. Records the click either way.
2519fn clicked_again(app: &mut App, target: ClickTarget, row: usize) -> bool {
2520    let now = Instant::now();
2521    let again = app.last_click.is_some_and(|(at, last_target, last_row)| {
2522        last_target == target && last_row == row && now.duration_since(at) < DOUBLE_CLICK
2523    });
2524    app.last_click = (!again).then_some((now, target, row));
2525    again
2526}
2527
2528fn contains(area: ratatui::layout::Rect, x: u16, y: u16) -> bool {
2529    area.contains(ratatui::layout::Position { x, y })
2530}
2531
2532fn open_link(app: &mut App, url: &str) {
2533    match crate::open::open_url(url) {
2534        Ok(()) => app.info(format!("Opened {url}")),
2535        Err(error) => app.error(error),
2536    }
2537}
2538
2539#[cfg(test)]
2540mod tests {
2541    use std::borrow::Cow;
2542    use std::path::PathBuf;
2543
2544    use crate::description::CopyLine;
2545
2546    use super::{
2547        ClipboardContent, MAX_OSC52_ENCODED_BYTES, MAX_OSC52_RAW_BYTES,
2548        build_clipboard_payload_with, osc52_sequence, paste_clipboard_content,
2549        resolve_clipboard_content, task_clipboard_text,
2550    };
2551
2552    #[test]
2553    fn clipboard_keeps_image_and_text_when_both_are_available() {
2554        let content = resolve_clipboard_content(
2555            Ok(arboard::ImageData {
2556                width: 1,
2557                height: 1,
2558                bytes: Cow::Owned(vec![1, 2, 3, 255]),
2559            }),
2560            Ok("image alt text".into()),
2561        )
2562        .expect("read clipboard")
2563        .expect("clipboard content");
2564
2565        assert!(content.image.is_some());
2566        assert_eq!(content.text.as_deref(), Some("image alt text"));
2567    }
2568
2569    #[test]
2570    fn clipboard_text_is_used_when_no_image_format_is_available() {
2571        let content = resolve_clipboard_content(
2572            Err(arboard::Error::ContentNotAvailable),
2573            Ok("clipboard text".into()),
2574        )
2575        .expect("read clipboard")
2576        .expect("clipboard content");
2577
2578        assert!(content.image.is_none());
2579        assert_eq!(content.text.as_deref(), Some("clipboard text"));
2580    }
2581
2582    fn mixed_clipboard_content() -> ClipboardContent {
2583        ClipboardContent {
2584            image: Some(arboard::ImageData {
2585                width: 1,
2586                height: 1,
2587                bytes: Cow::Owned(vec![10, 20, 30, 255]),
2588            }),
2589            text: Some("clipboard text".into()),
2590        }
2591    }
2592
2593    fn assert_text_then_image(blocks: &[crate::model::Block]) -> PathBuf {
2594        assert!(matches!(
2595            blocks.first(),
2596            Some(crate::model::Block::Text { text }) if text == "clipboard text"
2597        ));
2598        let Some(crate::model::Block::Image { attachment_id }) = blocks.get(1) else {
2599            panic!("clipboard image must follow its text representation");
2600        };
2601        PathBuf::from(attachment_id)
2602    }
2603
2604    #[test]
2605    fn mixed_clipboard_content_is_inserted_into_a_task_description() {
2606        let logical_path = std::env::temp_dir().join(format!(
2607            "mach-mixed-task-clipboard-{}",
2608            uuid::Uuid::new_v4()
2609        ));
2610        let store = crate::store::Store::open_in_memory_with_paths(logical_path).unwrap();
2611        let mut app = crate::app::App::with_store("test", store).unwrap();
2612        app.open_new_task();
2613        app.form.as_mut().unwrap().field = crate::form::Field::Description;
2614
2615        paste_clipboard_content(&mut app, mixed_clipboard_content());
2616
2617        let path = assert_text_then_image(&app.form.as_ref().unwrap().description.value());
2618        assert!(path.is_file());
2619        drop(app);
2620        assert!(!path.exists());
2621    }
2622
2623    #[test]
2624    fn category_description_uses_only_text_from_mixed_clipboard_content() {
2625        let logical_path = std::env::temp_dir().join(format!(
2626            "mach-mixed-category-clipboard-{}",
2627            uuid::Uuid::new_v4()
2628        ));
2629        let store = crate::store::Store::open_in_memory_with_paths(logical_path).unwrap();
2630        let mut app = crate::app::App::with_store("test", store).unwrap();
2631        app.open_new_category();
2632        app.category_form
2633            .as_mut()
2634            .unwrap()
2635            .set_description_focus(true);
2636
2637        paste_clipboard_content(&mut app, mixed_clipboard_content());
2638
2639        assert_eq!(
2640            app.category_form.as_ref().unwrap().description.value(),
2641            vec![crate::model::Block::text("clipboard text")]
2642        );
2643    }
2644
2645    #[test]
2646    fn task_copy_preserves_stored_text_that_names_an_existing_image() {
2647        let path = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/screenshot.png");
2648        let mut task = crate::model::Task::new("Keep the path", 0, None, "");
2649        task.description = vec![crate::model::Block::text(path)];
2650
2651        assert_eq!(
2652            task_clipboard_text(&task),
2653            format!("Keep the path\n\n{path}")
2654        );
2655    }
2656
2657    #[test]
2658    fn terminal_clipboard_fallback_preserves_utf8_text() {
2659        assert_eq!(osc52_sequence("买菜").unwrap(), "\u{1b}]52;c;5Lmw6I+c\u{7}");
2660    }
2661
2662    #[test]
2663    fn terminal_clipboard_rejects_oversized_raw_and_encoded_payloads() {
2664        let raw = osc52_sequence(&"x".repeat(MAX_OSC52_RAW_BYTES + 1)).unwrap_err();
2665        assert!(raw.contains("raw limit"), "{raw}");
2666
2667        let encoded_input = "x".repeat(62 * 1024);
2668        assert!(encoded_input.len() <= MAX_OSC52_RAW_BYTES);
2669        let encoded = osc52_sequence(&encoded_input).unwrap_err();
2670        assert!(encoded.contains("encoded limit"), "{encoded}");
2671        assert!(MAX_OSC52_ENCODED_BYTES < encoded_input.len() * 4 / 3 + 4);
2672    }
2673
2674    #[test]
2675    fn rich_clipboard_budget_replaces_an_oversized_image_but_keeps_plain_text() {
2676        let lines = vec![
2677            CopyLine::Text("before".into()),
2678            CopyLine::Image(PathBuf::from("huge.png")),
2679            CopyLine::Text("after".into()),
2680        ];
2681        let budget = 128;
2682        let (plain, html) = build_clipboard_payload_with(&lines, budget, |_, _| {
2683            Ok(format!("data:image/png;base64,{}", "A".repeat(256)))
2684        });
2685
2686        assert_eq!(plain, "before\n[image: huge.png]\nafter");
2687        assert!(html.contains("[image: huge.png]"), "{html}");
2688        assert!(!html.contains("<img"), "{html}");
2689        assert!(html.len() <= budget);
2690    }
2691
2692    #[test]
2693    fn rich_clipboard_only_links_to_approved_url_schemes() {
2694        let lines = vec![
2695            CopyLine::Link("example.com/?a=1&b=2".into()),
2696            CopyLine::Link("javascript:alert(1)".into()),
2697        ];
2698
2699        let (plain, html) = build_clipboard_payload_with(&lines, 1024, |_, _| unreachable!());
2700
2701        assert_eq!(plain, "example.com/?a=1&b=2\njavascript:alert(1)");
2702        assert!(
2703            html.contains("href=\"https://example.com/?a=1&amp;b=2\""),
2704            "{html}"
2705        );
2706        assert_eq!(html.matches("<a ").count(), 1, "{html}");
2707        assert!(html.contains("<div>javascript:alert(1)</div>"), "{html}");
2708    }
2709}