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