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, Confirm, Focus, Mode};
10use crate::form::Field;
11use crate::text_input::TextInput;
12use crate::undo::EditKind;
13
14/// Two clicks on the same task within this long open it.
15const DOUBLE_CLICK: Duration = Duration::from_millis(400);
16
17/// Handle one terminal event and report whether the screen may have changed.
18pub fn handle_event(app: &mut App, event: Event) -> bool {
19    match event {
20        Event::Key(key) if matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) => {
21            handle_key(app, key);
22            true
23        }
24        Event::Mouse(m)
25            if app.pending.is_some()
26                || matches!(
27                    m.kind,
28                    MouseEventKind::Down(MouseButton::Left)
29                        | MouseEventKind::ScrollUp
30                        | MouseEventKind::ScrollDown
31                ) =>
32        {
33            handle_mouse(app, m);
34            true
35        }
36        // The terminal's own paste (Cmd+V / middle click), delivered in
37        // one piece because bracketed paste is on.
38        Event::Paste(text) if !text.is_empty() => {
39            paste_text(app, &text);
40            true
41        }
42        // Crossterm has already resized the terminal; the next draw picks up
43        // the new dimensions without any App mutation here.
44        Event::Resize(_, _) => true,
45        _ => false,
46    }
47}
48
49/// Puts pasted text into whatever is being typed into. Bracketed paste
50/// (the terminal's own Cmd/Ctrl+V) is enough — no separate key binding.
51fn paste_text(app: &mut App, text: &str) {
52    if text.is_empty() {
53        return;
54    }
55    app.cancel_pending();
56    match app.mode {
57        Mode::TaskForm => {
58            let Some(form) = &mut app.form else { return };
59            match form.field {
60                Field::Title => {
61                    form.before_edit(EditKind::Atomic);
62                    form.title.insert_str(text);
63                }
64                // Selectors are changed with arrows/clicks, not pasted text.
65                Field::Category | Field::Due => {}
66                Field::Body => {
67                    form.before_edit(EditKind::Atomic);
68                    form.body.insert_str(text);
69                }
70                Field::Importance => {}
71            }
72        }
73        Mode::CategoryForm => {
74            let Some(form) = &mut app.category_form else {
75                return;
76            };
77            form.before_edit(EditKind::Atomic);
78            if form.on_description {
79                form.description.insert_str(text);
80            } else {
81                form.name.insert_str(text);
82            }
83        }
84        Mode::Slash => {
85            app.input.insert_str(text);
86            app.slash_index = 0;
87            app.clamp_slash_index();
88        }
89        Mode::Search => {
90            app.input.insert_str(text);
91            app.update_search();
92        }
93        _ => {}
94    }
95}
96
97/// Ctrl+Z — undo (not Ctrl+Shift+Z).
98fn is_undo_chord(key: KeyEvent) -> bool {
99    matches!(key.code, KeyCode::Char('z') | KeyCode::Char('Z'))
100        && key.modifiers.contains(KeyModifiers::CONTROL)
101        && !key.modifiers.contains(KeyModifiers::SHIFT)
102        && !key.modifiers.contains(KeyModifiers::ALT)
103}
104
105/// Ctrl+Shift+Z or Ctrl+Y — redo.
106fn is_redo_chord(key: KeyEvent) -> bool {
107    let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
108    let shift = key.modifiers.contains(KeyModifiers::SHIFT);
109    let alt = key.modifiers.contains(KeyModifiers::ALT);
110    if !ctrl || alt {
111        return false;
112    }
113    match key.code {
114        KeyCode::Char('z') | KeyCode::Char('Z') if shift => true,
115        KeyCode::Char('y') | KeyCode::Char('Y') if !shift => true,
116        _ => false,
117    }
118}
119
120/// Whether this key mutates editor content, and how to group it for undo.
121fn content_edit_kind(key: KeyEvent) -> Option<EditKind> {
122    let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
123    let alt = key.modifiers.contains(KeyModifiers::ALT);
124    let shift = key.modifiers.contains(KeyModifiers::SHIFT);
125    let word = word_mod(key);
126    match key.code {
127        KeyCode::Char(c) if ctrl || alt => match c {
128            // Deletes / kills — always their own step.
129            'u' | 'k' | 'w' | 'W' if !shift => Some(EditKind::Atomic),
130            // Ctrl+D toggles a body to-do (handled on body only).
131            'd' | 'D' if ctrl && !alt && !shift => Some(EditKind::Atomic),
132            _ => None,
133        },
134        KeyCode::Char(_) if !ctrl && !alt => Some(EditKind::Typing),
135        KeyCode::Backspace if word => Some(EditKind::Atomic),
136        KeyCode::Backspace | KeyCode::Delete => Some(EditKind::Typing),
137        _ => None,
138    }
139}
140
141fn handle_key(app: &mut App, key: KeyEvent) {
142    // Cmd/Ctrl+C on a selection copies it. Copying always wins over
143    // quitting, so the two can share the chord.
144    if is_copy_chord(key) && copy_selected_body_image(app) {
145        return;
146    }
147    // Auto-repeat comes from one physical hold, not a second affirmative
148    // action. Keep navigation/edit repeats responsive, but never let one
149    // complete an armed delete, purge, discard, or quit confirmation.
150    if key.kind == KeyEventKind::Repeat
151        && app
152            .pending_confirmation()
153            .is_some_and(|confirm| confirmation_key_matches(confirm, key, app.mode))
154    {
155        return;
156    }
157    // With nothing to copy, Ctrl+C twice leaves mach — but only from
158    // the two panels. Inside a dialog or the `/` line it would be far too
159    // easy to throw away what was typed, and Esc already backs out there.
160    if is_ctrl_c(key) && app.mode == Mode::Normal {
161        if app.awaiting(Confirm::Quit) {
162            app.should_quit = true;
163        } else {
164            app.ask_confirm(Confirm::Quit, "Press Ctrl+C again to quit");
165        }
166        return;
167    }
168
169    // Confirmations are action-specific. Any key other than that action's
170    // explicit second step cancels it before normal routing continues.
171    let keeps_confirmation = app
172        .pending_confirmation()
173        .is_none_or(|confirm| confirmation_key_matches(confirm, key, app.mode));
174    if !keeps_confirmation {
175        app.cancel_pending();
176    }
177
178    if key.code == KeyCode::Enter
179        && app.mode == Mode::Normal
180        && let Some(Confirm::Purge(ids)) = app.pending_confirmation().cloned()
181    {
182        let count = app.purge_ids(&ids);
183        if count > 0 {
184            app.info(format!("Purged {count} done task(s)"));
185        }
186        return;
187    }
188
189    match app.mode {
190        Mode::Welcome | Mode::WhatsNew => {
191            app.mode = Mode::Normal;
192            if !matches!(key.code, KeyCode::Enter | KeyCode::Esc) {
193                handle_key(app, key);
194            }
195        }
196        Mode::Help => match key.code {
197            KeyCode::Esc | KeyCode::Enter | KeyCode::Char('?') => app.mode = Mode::Normal,
198            KeyCode::Up => app.help_scroll = app.help_scroll.saturating_sub(1),
199            KeyCode::Down => app.help_scroll = app.help_scroll.saturating_add(1),
200            KeyCode::PageUp => app.help_scroll = app.help_scroll.saturating_sub(10),
201            KeyCode::PageDown => app.help_scroll = app.help_scroll.saturating_add(10),
202            KeyCode::Home => app.help_scroll = 0,
203            KeyCode::End => app.help_scroll = usize::MAX,
204            _ => {}
205        },
206        Mode::Settings => handle_settings_key(app, key),
207        Mode::TaskForm => handle_form_key(app, key),
208        Mode::CategoryForm => handle_category_key(app, key),
209        Mode::Slash => handle_slash_key(app, key),
210        Mode::Search => handle_search_key(app, key),
211        _ => handle_normal_key(app, key),
212    }
213}
214
215fn confirmation_key_matches(confirm: &Confirm, key: KeyEvent, mode: Mode) -> bool {
216    match confirm {
217        Confirm::DeleteTask(_) | Confirm::DeleteCategory(_) => key.code == KeyCode::Backspace,
218        Confirm::Purge(_) => key.code == KeyCode::Enter && mode == Mode::Normal,
219        Confirm::DiscardTask(_) | Confirm::DiscardCategory(_) => key.code == KeyCode::Esc,
220        Confirm::Quit => is_ctrl_c(key),
221    }
222}
223
224/// ⌘C / Ctrl+C — macOS terminals often send SUPER for Command.
225fn is_copy_chord(key: KeyEvent) -> bool {
226    matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C'))
227        && (key.modifiers.contains(KeyModifiers::SUPER)
228            || key.modifiers.contains(KeyModifiers::CONTROL))
229}
230
231/// Ctrl+C alone. ⌘C is Copy on macOS and must never quit, so the quit
232/// chord is narrower than [`is_copy_chord`].
233fn is_ctrl_c(key: KeyEvent) -> bool {
234    matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C'))
235        && key.modifiers == KeyModifiers::CONTROL
236}
237
238/// Copy the current selection (text, picture, or both) from a form field.
239/// Returns true when the key was handled.
240fn copy_selected_body_image(app: &mut App) -> bool {
241    if app.mode == Mode::TaskForm
242        && let Some(form) = &app.form
243        && form.field == Field::Body
244        && let Some(payload) = form.body.selected_payload()
245    {
246        finish_copy(app, payload);
247        return true;
248    }
249    // Other fields: plain text selection only.
250    if let Some(text) = selected_text_in_app(app) {
251        finish_copy(app, crate::body::CopyPayload::Text(text));
252        return true;
253    }
254    if app.mode != Mode::TaskForm {
255        return false;
256    }
257    let Some(form) = &app.form else {
258        return false;
259    };
260    // Full-size preview: copy that picture even with no body selection.
261    if form.preview {
262        let path = form
263            .body
264            .selected_image()
265            .or_else(|| form.body.images().into_iter().next());
266        if let Some(path) = path {
267            finish_copy(app, crate::body::CopyPayload::Image(path));
268            return true;
269        }
270    }
271    false
272}
273
274fn selected_text_in_app(app: &App) -> Option<String> {
275    match app.mode {
276        Mode::TaskForm => {
277            let form = app.form.as_ref()?;
278            match form.field {
279                Field::Title => form.title.selected_text(),
280                Field::Body => form.body.selected_text(),
281                Field::Category | Field::Due | Field::Importance => None,
282            }
283        }
284        Mode::CategoryForm => {
285            let form = app.category_form.as_ref()?;
286            if form.on_description {
287                form.description.selected_text()
288            } else {
289                form.name.selected_text()
290            }
291        }
292        Mode::Slash | Mode::Search => app.input.selected_text(),
293        _ => None,
294    }
295}
296
297// ---------------------------------------------------------------- normal
298
299fn handle_normal_key(app: &mut App, key: KeyEvent) {
300    match key.code {
301        KeyCode::Tab | KeyCode::BackTab => {
302            if !app.searching {
303                app.toggle_focus();
304            }
305        }
306        // Esc backs out one step and never quits; use `/quit`.
307        KeyCode::Esc => {
308            if app.searching {
309                app.end_search();
310            }
311        }
312        // `/` opens the command palette (search, settings, …).
313        KeyCode::Char('/') => app.open_slash(),
314        KeyCode::Char('?') => {
315            app.help_scroll = 0;
316            app.mode = Mode::Help;
317        }
318        _ => match app.focus {
319            Focus::Tasks => task_key(app, key),
320            Focus::Sidebar => sidebar_key(app, key),
321        },
322    }
323}
324
325fn task_key(app: &mut App, key: KeyEvent) {
326    let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
327    let alt = key.modifiers.contains(KeyModifiers::ALT);
328    let meta = key.modifiers.contains(KeyModifiers::SUPER);
329
330    match key.code {
331        KeyCode::Char('a') | KeyCode::Char('A') if ctrl && !alt => {
332            if app.searching {
333                app.info("Leave search (Esc) before adding a task");
334                return;
335            }
336            app.open_new_task();
337        }
338        KeyCode::Char('f') | KeyCode::Char('F') if ctrl && !alt => {
339            app.cycle_importance(app.task_index);
340        }
341        KeyCode::Enter => app.open_edit_task(),
342        KeyCode::Char(' ') => app.toggle_done(app.task_index),
343        KeyCode::Up if alt && !ctrl && !meta => {
344            app.move_task_order(-1);
345        }
346        KeyCode::Down if alt && !ctrl && !meta => {
347            app.move_task_order(1);
348        }
349        KeyCode::Up => app.navigate_vertical(-1),
350        KeyCode::Down => app.navigate_vertical(1),
351        KeyCode::PageUp => app.select_first_task(),
352        KeyCode::PageDown => app.select_last_task(),
353        // The panels sit side by side, so the arrows that point at them
354        // are what moves between them.
355        KeyCode::Left => {
356            let _ = app.set_focus(Focus::Sidebar);
357        }
358        KeyCode::Backspace => {
359            if let Some(id) = app.selected_task().map(|task| task.id.clone()) {
360                let confirm = Confirm::DeleteTask(id.clone());
361                if app.awaiting(confirm.clone()) {
362                    if app.delete_task_by_id(&id) {
363                        app.info("Task deleted");
364                    }
365                } else {
366                    app.ask_confirm(confirm, "Press Backspace again to delete this task");
367                }
368            }
369        }
370        // Type-to-jump: plain characters fuzzy-select a row (no mode).
371        KeyCode::Char(c) if !ctrl && !alt && !meta && !c.is_control() => {
372            app.typeahead_jump(c);
373        }
374        _ => {}
375    }
376}
377
378fn sidebar_key(app: &mut App, key: KeyEvent) {
379    let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
380    let alt = key.modifiers.contains(KeyModifiers::ALT);
381    let meta = key.modifiers.contains(KeyModifiers::SUPER);
382
383    match key.code {
384        KeyCode::Char('a') | KeyCode::Char('A') if ctrl && !alt => app.open_new_category(),
385        // Enter opens whatever is selected, and a category opens into
386        // the same kind of dialog a task does.
387        KeyCode::Enter => app.open_edit_category(),
388        KeyCode::Right => {
389            let _ = app.set_focus(Focus::Tasks);
390        }
391        KeyCode::Up if alt && !ctrl && !meta => {
392            app.move_category_order(-1);
393        }
394        KeyCode::Down if alt && !ctrl && !meta => {
395            app.move_category_order(1);
396        }
397        KeyCode::Up => app.navigate_vertical(-1),
398        KeyCode::Down => app.navigate_vertical(1),
399        KeyCode::PageUp => app.select_category(0),
400        KeyCode::PageDown => app.select_last_category(),
401        KeyCode::Backspace => {
402            if app.is_all_view() {
403                return;
404            }
405            let id = app.current_category_id().to_string();
406            let confirm = Confirm::DeleteCategory(id.clone());
407            if app.awaiting(confirm.clone()) {
408                let count = app.category_progress(&id).1;
409                if app.delete_category_by_id(&id) {
410                    app.info(format!(
411                        "Category deleted; {count} task(s) kept as Uncategorized"
412                    ));
413                }
414            } else {
415                let count = app.category_progress(app.current_category_id()).1;
416                app.ask_confirm(
417                    confirm,
418                    format!(
419                        "Press Backspace again to delete this category; {count} task(s) will be kept as Uncategorized"
420                    ),
421                );
422            }
423        }
424        // Type-to-jump: plain characters fuzzy-select a category (no mode).
425        KeyCode::Char(c) if !ctrl && !alt && !meta && !c.is_control() => {
426            app.typeahead_jump(c);
427        }
428        _ => {}
429    }
430}
431
432// ----------------------------------------------------------- text editing
433
434/// macOS Option and Linux Alt both show up as [`KeyModifiers::ALT`].
435/// Ctrl is the common non-Mac habit for the same motions.
436fn word_mod(key: KeyEvent) -> bool {
437    key.modifiers
438        .intersects(KeyModifiers::ALT | KeyModifiers::CONTROL)
439}
440
441/// Shared bindings for every one-line editor.
442fn edit_line(input: &mut TextInput, key: KeyEvent) -> bool {
443    let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
444    let alt = key.modifiers.contains(KeyModifiers::ALT);
445    let shift = key.modifiers.contains(KeyModifiers::SHIFT);
446    let word = word_mod(key);
447    match key.code {
448        // Shift+Option+W — select the word under the caret (macOS Select Word).
449        KeyCode::Char('w') | KeyCode::Char('W') if alt && shift => input.select_word(),
450        // Option/Alt+b/f — emacs meta, what many Mac terminals send for
451        // Option+←/→ when Option is wired as Meta.
452        KeyCode::Char('b') | KeyCode::Char('B') if alt && shift => input.select_word_left(),
453        KeyCode::Char('f') | KeyCode::Char('F') if alt && shift => input.select_word_right(),
454        KeyCode::Char('b') | KeyCode::Char('B') if alt => input.word_left(),
455        KeyCode::Char('f') | KeyCode::Char('F') if alt => input.word_right(),
456        KeyCode::Char(c) if ctrl || alt => match c {
457            'a' if shift => input.select_home(),
458            'e' if shift => input.select_end(),
459            'a' => input.home(),
460            'e' => input.end(),
461            'u' => input.delete_to_start(),
462            'k' => input.delete_to_end(),
463            // Option/Ctrl+W without Shift still deletes the previous word.
464            'w' | 'W' => input.delete_word_left(),
465            _ => return false,
466        },
467        KeyCode::Char(c) => input.insert(c),
468        // Option+Delete (Backspace) deletes the word to the left, like macOS.
469        KeyCode::Backspace if word => input.delete_word_left(),
470        KeyCode::Backspace => input.backspace(),
471        KeyCode::Delete => input.delete(),
472        KeyCode::Left if word && shift => input.select_word_left(),
473        KeyCode::Right if word && shift => input.select_word_right(),
474        KeyCode::Left if shift => input.select_left(),
475        KeyCode::Right if shift => input.select_right(),
476        KeyCode::Left if word => input.word_left(),
477        KeyCode::Right if word => input.word_right(),
478        KeyCode::Left => input.left(),
479        KeyCode::Right => input.right(),
480        KeyCode::Home if shift => input.select_home(),
481        KeyCode::End if shift => input.select_end(),
482        KeyCode::Home => input.home(),
483        KeyCode::End => input.end(),
484        _ => return false,
485    }
486    true
487}
488
489/// The same bindings as [`edit_line`], for the multi-line block editors:
490/// a task's body and a category's description. Adds ↑/↓ across blocks.
491/// The `/` menu is handled by the caller before this runs.
492fn edit_body(body: &mut crate::body::BodyEditor, key: KeyEvent) {
493    let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
494    let alt = key.modifiers.contains(KeyModifiers::ALT);
495    let shift = key.modifiers.contains(KeyModifiers::SHIFT);
496    let word = word_mod(key);
497    match key.code {
498        KeyCode::Char('w') | KeyCode::Char('W') if alt && shift => body.select_word(),
499        KeyCode::Char('b') | KeyCode::Char('B') if alt && shift => body.select_word_left(),
500        KeyCode::Char('f') | KeyCode::Char('F') if alt && shift => body.select_word_right(),
501        KeyCode::Char('b') | KeyCode::Char('B') if alt => body.word_left(),
502        KeyCode::Char('f') | KeyCode::Char('F') if alt => body.word_right(),
503        KeyCode::Char(c) if ctrl || alt => match c {
504            'a' if shift => body.select_home(),
505            'e' if shift => body.select_end(),
506            'a' => body.home(),
507            'e' => body.end(),
508            'u' => body.delete_to_start(),
509            'k' => body.delete_to_end(),
510            'w' | 'W' => body.delete_word_left(),
511            _ => {}
512        },
513        KeyCode::Char(c) => body.insert(c),
514        KeyCode::Backspace if word => body.delete_word_left(),
515        KeyCode::Backspace => body.backspace(),
516        KeyCode::Delete => body.delete(),
517        KeyCode::Left if word && shift => body.select_word_left(),
518        KeyCode::Right if word && shift => body.select_word_right(),
519        KeyCode::Left if shift => body.select_left(),
520        KeyCode::Right if shift => body.select_right(),
521        KeyCode::Left if word => body.word_left(),
522        KeyCode::Right if word => body.word_right(),
523        KeyCode::Left => body.left(),
524        KeyCode::Right => body.right(),
525        KeyCode::Up => body.up(),
526        KeyCode::Down => body.down(),
527        KeyCode::Home if shift => body.select_home(),
528        KeyCode::End if shift => body.select_end(),
529        KeyCode::Home => body.home(),
530        KeyCode::End => body.end(),
531        _ => {}
532    }
533}
534
535/// The category dialog: a name and a note (with `/` bullets).
536fn handle_category_key(app: &mut App, key: KeyEvent) {
537    if matches!(key.code, KeyCode::Char('s')) && key.modifiers.contains(KeyModifiers::CONTROL) {
538        if app
539            .category_form
540            .as_ref()
541            .is_some_and(|form| form.description.menu.is_some())
542        {
543            app.error("Choose or dismiss the description command before saving");
544            return;
545        }
546        app.submit_category_form();
547        return;
548    }
549
550    if is_undo_chord(key) {
551        if let Some(form) = &mut app.category_form
552            && form.undo()
553        {
554            app.info("Undo");
555        }
556        return;
557    }
558    if is_redo_chord(key) {
559        if let Some(form) = &mut app.category_form
560            && form.redo()
561        {
562            app.info("Redo");
563        }
564        return;
565    }
566
567    // Slash menu owns arrows / Enter while open on the description.
568    if let Some(form) = app
569        .category_form
570        .as_mut()
571        .filter(|form| form.on_description && form.description.menu.is_some())
572    {
573        let outcome = {
574            // Structural apply (bullet etc.) needs a checkpoint first.
575            if matches!(key.code, KeyCode::Enter | KeyCode::Tab) {
576                form.before_edit(EditKind::Atomic);
577            }
578            body_menu_key(&mut form.description, key)
579        };
580        match outcome {
581            MenuKey::Ignored => {}
582            MenuKey::Handled => return,
583            MenuKey::Copy(payload) => {
584                finish_copy(app, payload);
585                return;
586            }
587        }
588    }
589
590    match key.code {
591        KeyCode::Esc => {
592            let _ = request_close_category_form(app);
593        }
594        KeyCode::Tab | KeyCode::BackTab => {
595            if let Some(form) = &mut app.category_form {
596                form.description.close_menu();
597                form.toggle_field();
598            }
599        }
600        KeyCode::Enter => {
601            let Some(form) = &mut app.category_form else {
602                return;
603            };
604            if form.on_description {
605                form.before_edit(EditKind::Atomic);
606                let _ = form.description.newline();
607            } else {
608                form.toggle_field();
609            }
610        }
611        _ => {
612            let Some(form) = &mut app.category_form else {
613                return;
614            };
615            if form.on_description {
616                if let Some(mut kind) = content_edit_kind(key) {
617                    if form.description.has_selection() {
618                        kind = EditKind::Atomic;
619                    }
620                    form.before_edit(kind);
621                } else {
622                    form.break_coalesce();
623                }
624                edit_body(&mut form.description, key);
625            } else if let Some(mut kind) = content_edit_kind(key) {
626                if form.name.has_selection() {
627                    kind = EditKind::Atomic;
628                }
629                form.before_edit(kind);
630                edit_line(&mut form.name, key);
631            } else {
632                form.break_coalesce();
633                edit_line(&mut form.name, key);
634            }
635        }
636    }
637}
638
639/// The `/` command palette above the status bar.
640fn handle_slash_key(app: &mut App, key: KeyEvent) {
641    match key.code {
642        KeyCode::Esc => close_slash(app),
643        // Backspace on empty (or past the last char) drops the leading `/`.
644        KeyCode::Backspace if app.input.is_empty() => close_slash(app),
645        KeyCode::Up => {
646            let n = crate::slash::matching(&app.input.value()).len();
647            if n > 0 {
648                app.slash_index = (app.slash_index + n - 1) % n;
649            }
650        }
651        KeyCode::Down | KeyCode::Tab => {
652            let n = crate::slash::matching(&app.input.value()).len();
653            if n > 0 {
654                app.slash_index = (app.slash_index + 1) % n;
655            }
656        }
657        KeyCode::Enter => {
658            let query = app.input.value();
659            let matches = crate::slash::matching(&query);
660            let cmd = matches.get(app.slash_index).copied();
661            close_slash(app);
662            if let Some(cmd) = cmd {
663                run_slash(app, cmd, &query);
664            }
665        }
666        _ => {
667            if edit_line(&mut app.input, key) {
668                app.slash_index = 0;
669                app.clamp_slash_index();
670            }
671        }
672    }
673}
674
675fn close_slash(app: &mut App) {
676    app.mode = Mode::Normal;
677    app.input = TextInput::default();
678    app.slash_index = 0;
679}
680
681/// Live search after choosing Search from the palette.
682fn handle_search_key(app: &mut App, key: KeyEvent) {
683    match key.code {
684        KeyCode::Esc => {
685            app.input = TextInput::default();
686            app.end_search();
687        }
688        KeyCode::Enter => {
689            // Keep the current query; just leave the typing field.
690            app.mode = Mode::Normal;
691            app.input = TextInput::default();
692            // Keep searching/search_query so the list stays narrowed until Esc.
693            if app.search_query.is_empty() {
694                app.end_search();
695            }
696        }
697        _ => {
698            if edit_line(&mut app.input, key) {
699                app.update_search();
700            }
701        }
702    }
703}
704
705fn run_slash(app: &mut App, cmd: crate::slash::SlashCommand, query: &str) {
706    use crate::slash::{SlashCommand, args_for};
707    match cmd {
708        SlashCommand::Search => {
709            let q = args_for(cmd, query);
710            app.start_search(&q);
711        }
712        SlashCommand::Settings => {
713            app.settings_index = 0;
714            app.mode = Mode::Settings;
715        }
716        SlashCommand::Help => {
717            app.help_scroll = 0;
718            app.mode = Mode::Help;
719        }
720        SlashCommand::WhatsNew => app.mode = Mode::WhatsNew,
721        SlashCommand::CopyTitle => match app.selected_task() {
722            Some(task) => {
723                finish_copy(app, crate::body::CopyPayload::Text(task.title.clone()));
724            }
725            None => app.info("No task selected"),
726        },
727        SlashCommand::CopyTask => match app.selected_task() {
728            Some(task) => {
729                let text = task_clipboard_text(task);
730                finish_copy(app, crate::body::CopyPayload::Text(text));
731            }
732            None => app.info("No task selected"),
733        },
734        SlashCommand::Done => {
735            if let Some(hidden) = app.toggle_hide_done() {
736                if hidden {
737                    app.info("Hiding completed tasks");
738                } else {
739                    app.info("Showing completed tasks");
740                }
741            }
742        }
743        SlashCommand::Purge => {
744            let ids = app.purge_candidate_ids();
745            if ids.is_empty() {
746                app.info("No done tasks to purge");
747            } else {
748                let count = ids.len();
749                app.ask_confirm(
750                    Confirm::Purge(ids),
751                    format!("Press Enter to purge {count} done task(s)"),
752                );
753            }
754        }
755        SlashCommand::Update => app.start_update_install(),
756        SlashCommand::Quit => app.should_quit = true,
757    }
758}
759
760// ------------------------------------------------------------ task dialog
761
762/// Tab and the mouse move between fields; Enter saves, except in the
763/// body where it starts a new block.
764fn handle_form_key(app: &mut App, key: KeyEvent) {
765    let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
766
767    // Saving resolves the picker, but never guesses what an open body command
768    // or full-screen preview was meant to do.
769    if matches!(key.code, KeyCode::Char('s')) && ctrl {
770        if app.form.as_ref().is_some_and(|form| form.preview) {
771            app.error("Close the image preview before saving");
772            return;
773        }
774        if app
775            .form
776            .as_ref()
777            .is_some_and(|form| form.body.menu.is_some())
778        {
779            app.error("Choose or dismiss the body command before saving");
780            return;
781        }
782        if let Some(form) = &mut app.form
783            && form.picker.is_some()
784        {
785            form.take_due_picker();
786        }
787        app.submit_form();
788        return;
789    }
790
791    // Esc peels one layer: preview → picker → slash menu → leave the form.
792    // (Handled below in that order; bare Esc closes the dialog only when
793    // none of those overlays are open.)
794
795    // The image preview: Esc closes; Space / Enter toggles GIF pause.
796    if app.form.as_ref().is_some_and(|f| f.preview) {
797        match key.code {
798            KeyCode::Esc => {
799                // Drop frames/protocol first so the next draw cannot spend
800                // another encode tick on this preview.
801                if let Some(form) = &mut app.form {
802                    form.close_image_preview();
803                }
804                app.images.clear_preview();
805            }
806            KeyCode::Enter | KeyCode::Char(' ') => {
807                if let Some(form) = &mut app.form {
808                    form.preview_click();
809                }
810            }
811            _ => {}
812        }
813        return;
814    }
815
816    // Ctrl+Z / Ctrl+Shift+Z (Ctrl+Y) — form-wide undo/redo. The image
817    // preview above owns its keys until explicitly closed.
818    if is_undo_chord(key) {
819        if let Some(form) = &mut app.form
820            && form.undo()
821        {
822            app.info("Undo");
823        }
824        return;
825    }
826    if is_redo_chord(key) {
827        if let Some(form) = &mut app.form
828            && form.redo()
829        {
830            app.info("Redo");
831        }
832        return;
833    }
834
835    // The date/time picker owns Tab and the arrows while it is open.
836    if app.form.as_ref().is_some_and(|f| f.picker.is_some()) {
837        handle_picker_key(app, key);
838        return;
839    }
840
841    // Slash menu: Esc closes the menu only (not the whole dialog).
842    if app.form.as_ref().is_some_and(|f| f.body.menu.is_some()) && handle_menu_key(app, key) {
843        return;
844    }
845
846    match key.code {
847        KeyCode::Esc => {
848            let _ = request_close_task_form(app);
849        }
850        KeyCode::Tab => {
851            if let Some(form) = &mut app.form {
852                form.focus_next();
853            }
854        }
855        KeyCode::BackTab => {
856            if let Some(form) = &mut app.form {
857                form.focus_prev();
858            }
859        }
860        // Enter never closes the dialog — it opens or adds whatever the
861        // focused field holds. Ctrl+S is what saves.
862        // ⌘Enter / Ctrl+Enter on a link opens it in the browser.
863        KeyCode::Enter
864            if key
865                .modifiers
866                .intersects(KeyModifiers::SUPER | KeyModifiers::CONTROL) =>
867        {
868            let url = app
869                .form
870                .as_ref()
871                .filter(|f| f.field == Field::Body)
872                .and_then(|f| f.body.link_url_at_cursor());
873            if let Some(url) = url {
874                match crate::open::open_url(&url) {
875                    Ok(()) => app.info(format!("Opened {url}")),
876                    Err(err) => app.error(err),
877                }
878            }
879        }
880        KeyCode::Enter => {
881            let Some(form) = &mut app.form else { return };
882            match form.field {
883                Field::Title | Field::Category | Field::Importance => form.focus_next(),
884                Field::Due => form.open_due_picker(),
885                // On a picture there is nothing to type, so Enter is
886                // what opens it.
887                Field::Body if form.body.selected_image().is_some() => {
888                    if let Some(err) = form.open_image_preview() {
889                        app.error(err);
890                    }
891                }
892                Field::Body => {
893                    form.before_edit(EditKind::Atomic);
894                    let _ = form.body.newline();
895                }
896            }
897        }
898        _ => {
899            let Some(form) = &mut app.form else { return };
900            match form.field {
901                // Ctrl+D ticks a to-do off; everything else is ordinary
902                // block editing.
903                Field::Body
904                    if ctrl && matches!(key.code, KeyCode::Char('d') | KeyCode::Char('D')) =>
905                {
906                    form.before_edit(EditKind::Atomic);
907                    form.body.toggle();
908                }
909                Field::Body => {
910                    if let Some(mut kind) = content_edit_kind(key) {
911                        if form.body.has_selection() {
912                            kind = EditKind::Atomic;
913                        }
914                        form.before_edit(kind);
915                    } else {
916                        form.break_coalesce();
917                    }
918                    edit_body(&mut form.body, key);
919                }
920                // Category is a bounded selector. All tasks is deliberately
921                // absent; Backspace/Delete returns the task to Uncategorized.
922                Field::Category => match key.code {
923                    KeyCode::Left | KeyCode::Up => form.cycle_category(-1),
924                    KeyCode::Right | KeyCode::Down | KeyCode::Char(' ') => form.cycle_category(1),
925                    KeyCode::Backspace | KeyCode::Delete => form.clear_category(),
926                    _ => form.break_coalesce(),
927                },
928                // Nothing to type here: the arrows and the digits set
929                // how many flags the task carries.
930                Field::Importance => match key.code {
931                    KeyCode::Left | KeyCode::Down => {
932                        form.set_importance(form.importance.saturating_sub(1))
933                    }
934                    KeyCode::Right | KeyCode::Up | KeyCode::Char(' ') => form.cycle_importance(),
935                    KeyCode::Backspace | KeyCode::Delete => form.set_importance(0),
936                    KeyCode::Char(c) if c.is_ascii_digit() => form.set_importance(c as u8 - b'0'),
937                    _ => form.break_coalesce(),
938                },
939                // Due is picker-only — no free typing, so any character
940                // opens the calendar instead of landing in the field.
941                Field::Due => match key.code {
942                    KeyCode::Char(_) => form.open_due_picker(),
943                    KeyCode::Backspace | KeyCode::Delete => form.clear_due(),
944                    _ => form.break_coalesce(),
945                },
946                // Arrow keys only ever move the cursor; Tab, Shift+Tab
947                // and the mouse are what change fields.
948                Field::Title => {
949                    if let Some(mut kind) = content_edit_kind(key) {
950                        if form.title.has_selection() {
951                            kind = EditKind::Atomic;
952                        }
953                        form.before_edit(kind);
954                    } else {
955                        form.break_coalesce();
956                    }
957                    edit_line(&mut form.title, key);
958                }
959            }
960        }
961    }
962}
963
964/// Date + time picker: Tab moves Calendar → Hour → Minute; arrows adjust
965/// the focused part; Enter writes the value back into Due.
966fn handle_picker_key(app: &mut App, key: KeyEvent) {
967    use crate::duepicker::PickerFocus;
968
969    let Some(form) = &mut app.form else { return };
970    match key.code {
971        KeyCode::Esc => {
972            form.picker = None;
973            return;
974        }
975        KeyCode::Char('x') | KeyCode::Delete => {
976            form.clear_due();
977            return;
978        }
979        KeyCode::Enter => {
980            form.take_due_picker();
981            return;
982        }
983        _ => {}
984    }
985
986    let Some(picker) = &mut form.picker else {
987        return;
988    };
989    match key.code {
990        KeyCode::Tab => picker.focus_next(),
991        KeyCode::BackTab => picker.focus_prev(),
992        KeyCode::Char('t') => {
993            picker.today();
994            picker.now_time();
995        }
996        KeyCode::Left => match picker.focus {
997            PickerFocus::Calendar => picker.move_days(-1),
998            PickerFocus::Hour => picker.bump_hour(-1),
999            PickerFocus::Minute => picker.bump_minute(-5),
1000        },
1001        KeyCode::Right => match picker.focus {
1002            PickerFocus::Calendar => picker.move_days(1),
1003            PickerFocus::Hour => picker.bump_hour(1),
1004            PickerFocus::Minute => picker.bump_minute(5),
1005        },
1006        KeyCode::Up => match picker.focus {
1007            PickerFocus::Calendar => picker.move_days(-7),
1008            PickerFocus::Hour => picker.bump_hour(1),
1009            PickerFocus::Minute => picker.bump_minute(5),
1010        },
1011        KeyCode::Down => match picker.focus {
1012            PickerFocus::Calendar => picker.move_days(7),
1013            PickerFocus::Hour => picker.bump_hour(-1),
1014            PickerFocus::Minute => picker.bump_minute(-5),
1015        },
1016        KeyCode::PageUp => match picker.focus {
1017            PickerFocus::Calendar => picker.move_months(-1),
1018            PickerFocus::Hour => picker.bump_hour(1),
1019            PickerFocus::Minute => picker.bump_minute(15),
1020        },
1021        KeyCode::PageDown => match picker.focus {
1022            PickerFocus::Calendar => picker.move_months(1),
1023            PickerFocus::Hour => picker.bump_hour(-1),
1024            PickerFocus::Minute => picker.bump_minute(-15),
1025        },
1026        // Space on the clock → now; digits type hour/minute directly.
1027        KeyCode::Char(' ') if picker.focus != PickerFocus::Calendar => picker.now_time(),
1028        KeyCode::Char(c) if c.is_ascii_digit() => picker.type_digit(c as u8 - b'0'),
1029        _ => {}
1030    }
1031}
1032
1033/// Returns true when the key belonged to the open slash menu.
1034fn handle_menu_key(app: &mut App, key: KeyEvent) -> bool {
1035    let Some(form) = app.form.as_mut().filter(|form| form.body.menu.is_some()) else {
1036        return false;
1037    };
1038    // Split the borrow: menu keys only need the body, copy needs App.
1039    let outcome = {
1040        // Applying a command (Enter/Tab) mutates structure — checkpoint first.
1041        if matches!(key.code, KeyCode::Enter | KeyCode::Tab) {
1042            form.before_edit(EditKind::Atomic);
1043        }
1044        body_menu_key(&mut form.body, key)
1045    };
1046    match outcome {
1047        MenuKey::Ignored => false,
1048        MenuKey::Handled => true,
1049        MenuKey::Copy(payload) => {
1050            finish_copy(app, payload);
1051            true
1052        }
1053    }
1054}
1055
1056enum MenuKey {
1057    Ignored,
1058    Handled,
1059    Copy(crate::body::CopyPayload),
1060}
1061
1062fn body_menu_key(body: &mut crate::body::BodyEditor, key: KeyEvent) -> MenuKey {
1063    match key.code {
1064        KeyCode::Up => {
1065            body.menu_prev();
1066            MenuKey::Handled
1067        }
1068        KeyCode::Down => {
1069            body.menu_next();
1070            MenuKey::Handled
1071        }
1072        KeyCode::Esc => {
1073            body.close_menu();
1074            MenuKey::Handled
1075        }
1076        KeyCode::Tab | KeyCode::Enter => match body.menu_selected() {
1077            Some(command) => match body.apply(command) {
1078                Some(payload) => MenuKey::Copy(payload),
1079                None => MenuKey::Handled,
1080            },
1081            None => {
1082                body.close_menu();
1083                MenuKey::Handled
1084            }
1085        },
1086        _ => MenuKey::Ignored,
1087    }
1088}
1089
1090/// Title, then body as plain text (same export as body `/copy`).
1091fn task_clipboard_text(task: &crate::model::Task) -> String {
1092    let body = crate::body::BodyEditor::new(&task.body).text_for_copy();
1093    if body.is_empty() {
1094        task.title.clone()
1095    } else {
1096        format!("{}\n\n{body}", task.title)
1097    }
1098}
1099
1100fn finish_copy(app: &mut App, payload: crate::body::CopyPayload) {
1101    match payload {
1102        crate::body::CopyPayload::Text(text) => {
1103            if text.is_empty() {
1104                app.info("Nothing to copy");
1105                return;
1106            }
1107            match copy_text(&text) {
1108                Ok(ClipboardTarget::System) => app.info("Copied text to clipboard"),
1109                Ok(ClipboardTarget::Terminal) => app.info("Copied text through the terminal"),
1110                Err(err) => app.error(format!("Could not copy: {err}")),
1111            }
1112        }
1113        crate::body::CopyPayload::Image(path) => match copy_image_file(&path) {
1114            Ok(()) => app.info("Copied image to clipboard"),
1115            Err(err) => app.error(format!("Could not copy image: {err}")),
1116        },
1117        crate::body::CopyPayload::All(lines) => {
1118            if lines.is_empty() {
1119                app.info("Nothing to copy");
1120                return;
1121            }
1122            match copy_all(&lines) {
1123                Ok(ClipboardTarget::System) => app.info("Copied text and pictures"),
1124                Ok(ClipboardTarget::Terminal) => app.info("Copied plain text through the terminal"),
1125                Err(err) => app.error(format!("Could not copy: {err}")),
1126            }
1127        }
1128    }
1129}
1130
1131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1132enum ClipboardTarget {
1133    System,
1134    Terminal,
1135}
1136
1137const MAX_OSC52_RAW_BYTES: usize = 64 * 1024;
1138const MAX_OSC52_ENCODED_BYTES: usize = 80 * 1024;
1139const MAX_RICH_CLIPBOARD_BYTES: usize = 8 * 1024 * 1024;
1140
1141fn copy_text(text: &str) -> Result<ClipboardTarget, String> {
1142    match arboard::Clipboard::new().and_then(|mut clipboard| clipboard.set_text(text)) {
1143        Ok(()) => Ok(ClipboardTarget::System),
1144        Err(system_error) => osc52_copy(text).map_err(|terminal_error| {
1145            format!("system clipboard: {system_error}; terminal clipboard: {terminal_error}")
1146        }),
1147    }
1148}
1149
1150fn osc52_copy(text: &str) -> Result<ClipboardTarget, String> {
1151    use std::io::Write;
1152
1153    let sequence = osc52_sequence(text)?;
1154    let mut stdout = std::io::stdout().lock();
1155    stdout
1156        .write_all(sequence.as_bytes())
1157        .and_then(|()| stdout.flush())
1158        .map_err(|error| error.to_string())?;
1159    Ok(ClipboardTarget::Terminal)
1160}
1161
1162fn osc52_sequence(text: &str) -> Result<String, String> {
1163    use base64::Engine;
1164
1165    if text.len() > MAX_OSC52_RAW_BYTES {
1166        return Err(format!(
1167            "OSC 52 text is {} bytes; raw limit is {MAX_OSC52_RAW_BYTES} bytes",
1168            text.len()
1169        ));
1170    }
1171    let encoded = base64::engine::general_purpose::STANDARD.encode(text.as_bytes());
1172    if encoded.len() > MAX_OSC52_ENCODED_BYTES {
1173        return Err(format!(
1174            "OSC 52 payload is {} bytes; encoded limit is {MAX_OSC52_ENCODED_BYTES} bytes",
1175            encoded.len()
1176        ));
1177    }
1178    Ok(format!("\x1b]52;c;{encoded}\x07"))
1179}
1180
1181/// Decode a body image file and put its pixels on the system clipboard.
1182fn copy_image_file(path: &std::path::Path) -> Result<(), String> {
1183    let rgba = crate::image::load_dynamic(path)?.into_rgba8();
1184    let (width, height) = rgba.dimensions();
1185    let data = arboard::ImageData {
1186        width: width as usize,
1187        height: height as usize,
1188        bytes: rgba.into_raw().into(),
1189    };
1190    arboard::Clipboard::new()
1191        .and_then(|mut c| c.set_image(data))
1192        .map_err(|e| e.to_string())
1193}
1194
1195/// Put the whole body on the clipboard as HTML (with embedded images)
1196/// plus a plain-text fallback. Notes, browsers, and mail clients can
1197/// paste the rich form; terminals get the text.
1198fn copy_all(lines: &[crate::body::CopyLine]) -> Result<ClipboardTarget, String> {
1199    let (plain, html) = build_clipboard_payload(lines, MAX_RICH_CLIPBOARD_BYTES);
1200
1201    match arboard::Clipboard::new()
1202        .and_then(|mut clipboard| clipboard.set_html(html.as_str(), Some(plain.as_str())))
1203    {
1204        Ok(()) => Ok(ClipboardTarget::System),
1205        Err(system_error) => osc52_copy(&plain).map_err(|terminal_error| {
1206            format!("system clipboard: {system_error}; terminal clipboard: {terminal_error}")
1207        }),
1208    }
1209}
1210
1211fn build_clipboard_payload(
1212    lines: &[crate::body::CopyLine],
1213    rich_budget: usize,
1214) -> (String, String) {
1215    build_clipboard_payload_with(lines, rich_budget, image_data_url)
1216}
1217
1218fn build_clipboard_payload_with(
1219    lines: &[crate::body::CopyLine],
1220    rich_budget: usize,
1221    mut load_image: impl FnMut(&std::path::Path, usize) -> Result<String, String>,
1222) -> (String, String) {
1223    use crate::body::CopyLine;
1224
1225    const IMAGE_PREFIX: &str = r#"<div><img src=""#;
1226    const IMAGE_SUFFIX: &str = r#"" /></div>"#;
1227    let mut html = String::new();
1228    let mut plain = String::new();
1229    for (i, line) in lines.iter().enumerate() {
1230        if i > 0 {
1231            plain.push('\n');
1232        }
1233        match line {
1234            CopyLine::Text(text) => {
1235                plain.push_str(text);
1236                push_rich_fragment(
1237                    &mut html,
1238                    &format!("<div>{}</div>", escape_html(text)),
1239                    rich_budget,
1240                );
1241            }
1242            CopyLine::Link(url) => {
1243                plain.push_str(url);
1244                let label = escape_html(url);
1245                let fragment = match crate::open::normalize_url(url) {
1246                    Some(url) => {
1247                        let href = escape_html(&url);
1248                        format!("<div><a href=\"{href}\">{label}</a></div>")
1249                    }
1250                    None => format!("<div>{label}</div>"),
1251                };
1252                push_rich_fragment(&mut html, &fragment, rich_budget);
1253            }
1254            CopyLine::Image(path) => {
1255                let label = format!("[image: {}]", path.display());
1256                plain.push_str(&label);
1257                let url_budget = rich_budget
1258                    .saturating_sub(html.len())
1259                    .saturating_sub(IMAGE_PREFIX.len() + IMAGE_SUFFIX.len());
1260                let image = load_image(path, url_budget)
1261                    .ok()
1262                    .filter(|url| url.len() <= url_budget)
1263                    .map(|url| format!("{IMAGE_PREFIX}{url}{IMAGE_SUFFIX}"));
1264                let fragment = image.unwrap_or_else(|| {
1265                    // Keep layout order without letting data URLs consume an
1266                    // unbounded clipboard string.
1267                    format!("<div>{}</div>", escape_html(&label))
1268                });
1269                push_rich_fragment(&mut html, &fragment, rich_budget);
1270            }
1271        }
1272    }
1273    (plain, html)
1274}
1275
1276fn push_rich_fragment(html: &mut String, fragment: &str, budget: usize) {
1277    if html.len().saturating_add(fragment.len()) <= budget {
1278        html.push_str(fragment);
1279    }
1280}
1281
1282fn image_data_url(path: &std::path::Path, url_budget: usize) -> Result<String, String> {
1283    use base64::Engine;
1284    use image::ImageEncoder;
1285    use std::io::Write;
1286
1287    const PREFIX: &str = "data:image/png;base64,";
1288    let encoded_budget = url_budget
1289        .checked_sub(PREFIX.len())
1290        .ok_or_else(|| "rich clipboard image budget is exhausted".to_string())?;
1291    // Base64 expands three bytes into four. Keep the encoded URL inside the
1292    // caller's remaining rich-payload budget before allocating its String.
1293    let png_budget = (encoded_budget / 4) * 3;
1294    if png_budget == 0 {
1295        return Err("rich clipboard image budget is exhausted".to_string());
1296    }
1297
1298    struct BoundedPng {
1299        bytes: Vec<u8>,
1300        limit: usize,
1301    }
1302
1303    impl Write for BoundedPng {
1304        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
1305            if self.bytes.len().saturating_add(buf.len()) > self.limit {
1306                return Err(std::io::Error::other(
1307                    "encoded image exceeds rich clipboard budget",
1308                ));
1309            }
1310            self.bytes.extend_from_slice(buf);
1311            Ok(buf.len())
1312        }
1313
1314        fn flush(&mut self) -> std::io::Result<()> {
1315            Ok(())
1316        }
1317    }
1318
1319    let rgba = crate::image::load_dynamic(path)?.into_rgba8();
1320    let (width, height) = rgba.dimensions();
1321    let mut png = BoundedPng {
1322        bytes: Vec::new(),
1323        limit: png_budget,
1324    };
1325    image::codecs::png::PngEncoder::new(&mut png)
1326        .write_image(
1327            rgba.as_raw(),
1328            width,
1329            height,
1330            image::ExtendedColorType::Rgba8,
1331        )
1332        .map_err(|e| format!("{}: {e}", path.display()))?;
1333    let b64 = base64::engine::general_purpose::STANDARD.encode(png.bytes);
1334    let url = format!("{PREFIX}{b64}");
1335    if url.len() > url_budget {
1336        return Err("encoded image exceeds rich clipboard budget".to_string());
1337    }
1338    Ok(url)
1339}
1340
1341fn escape_html(s: &str) -> String {
1342    let mut out = String::with_capacity(s.len());
1343    for c in s.chars() {
1344        match c {
1345            '&' => out.push_str("&amp;"),
1346            '<' => out.push_str("&lt;"),
1347            '>' => out.push_str("&gt;"),
1348            '"' => out.push_str("&quot;"),
1349            _ => out.push(c),
1350        }
1351    }
1352    out
1353}
1354
1355// -------------------------------------------------------------- settings
1356
1357fn handle_settings_key(app: &mut App, key: KeyEvent) {
1358    match key.code {
1359        KeyCode::Esc => app.mode = Mode::Normal,
1360        KeyCode::Up => {
1361            app.settings_index = app.settings_index.saturating_sub(1);
1362        }
1363        KeyCode::Down => {
1364            app.settings_index = (app.settings_index + 1).min(crate::app::SETTINGS_ITEMS.len() - 1);
1365        }
1366        KeyCode::Right | KeyCode::Tab => app.cycle_setting(app.settings_index, 1),
1367        KeyCode::Left | KeyCode::BackTab => app.cycle_setting(app.settings_index, -1),
1368        _ => {}
1369    }
1370}
1371
1372/// Close a form only when there is no content to lose, or after the same
1373/// entity-bound discard action is explicitly confirmed with Esc again.
1374fn request_close_task_form(app: &mut App) -> bool {
1375    let Some(form) = app.form.as_ref() else {
1376        return true;
1377    };
1378    if !form.is_dirty() {
1379        app.close_form();
1380        return true;
1381    }
1382    let confirm = Confirm::DiscardTask(form.editing.clone());
1383    if app.awaiting(confirm.clone()) {
1384        app.close_form();
1385        true
1386    } else {
1387        app.ask_confirm(confirm, "Unsaved changes · press Esc again to discard");
1388        false
1389    }
1390}
1391
1392fn request_close_category_form(app: &mut App) -> bool {
1393    let Some(form) = app.category_form.as_ref() else {
1394        return true;
1395    };
1396    if !form.is_dirty() {
1397        app.close_category_form();
1398        return true;
1399    }
1400    let confirm = Confirm::DiscardCategory(form.editing.clone());
1401    if app.awaiting(confirm.clone()) {
1402        app.close_category_form();
1403        true
1404    } else {
1405        app.ask_confirm(confirm, "Unsaved changes · press Esc again to discard");
1406        false
1407    }
1408}
1409
1410// ------------------------------------------------------------------ mouse
1411
1412fn handle_mouse(app: &mut App, m: MouseEvent) {
1413    app.cancel_pending();
1414    if app.mode == Mode::Slash {
1415        handle_slash_mouse(app, m);
1416        return;
1417    }
1418    if app.mode == Mode::TaskForm {
1419        // The full-size image is modal over the panels. Route it before
1420        // hit-testing the list beneath, or a preview click can close the form.
1421        if app.form.as_ref().is_some_and(|form| form.preview) {
1422            handle_form_mouse(app, m);
1423            return;
1424        }
1425        // A clean editor may yield to the underlying panels. Dirty content
1426        // stays modal; Esc is the explicit discard path.
1427        if click_on_panels(app, m) {
1428            if !request_close_task_form(app) {
1429                return;
1430            }
1431        } else {
1432            handle_form_mouse(app, m);
1433            return;
1434        }
1435    }
1436    if app.mode == Mode::CategoryForm {
1437        if click_on_panels(app, m) {
1438            if !request_close_category_form(app) {
1439                return;
1440            }
1441        } else {
1442            if let (MouseEventKind::Down(MouseButton::Left), Some(form)) =
1443                (m.kind, &mut app.category_form)
1444            {
1445                if contains(form.name_area, m.column, m.row) {
1446                    form.set_description_focus(false);
1447                    form.name
1448                        .set_cursor_from_col((m.column - form.name_area.x) as usize);
1449                } else if contains(form.description_area, m.column, m.row) {
1450                    form.set_description_focus(true);
1451                    form.description.click(
1452                        m.row - form.description_area.y,
1453                        (m.column - form.description_area.x) as usize,
1454                    );
1455                }
1456            }
1457            return;
1458        }
1459    }
1460    if app.mode.is_overlay() {
1461        return;
1462    }
1463    match m.kind {
1464        // The wheel works on the list under the pointer, not on whichever
1465        // panel holds the keyboard focus — so you can spin through
1466        // categories without first clicking into them. Focus stays put.
1467        MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => {
1468            let delta = if m.kind == MouseEventKind::ScrollUp {
1469                -1
1470            } else {
1471                1
1472            };
1473            if contains(app.areas.tasks, m.column, m.row) {
1474                app.move_task_selection(delta);
1475            } else if contains(app.areas.sidebar, m.column, m.row) && !app.searching {
1476                // Same reason clicking a category is blocked mid-search:
1477                // picking one would silently drop the query.
1478                app.move_category_selection(delta);
1479            }
1480        }
1481        MouseEventKind::Down(MouseButton::Left) => {
1482            let (x, y) = (m.column, m.row);
1483            let sidebar = app.areas.sidebar;
1484            let tasks = app.areas.tasks;
1485            if contains(app.areas.command_bar, x, y) {
1486                focus_command_bar(app, x);
1487            } else if contains(sidebar, x, y) {
1488                if app.searching {
1489                    return;
1490                }
1491                let _ = app.set_focus(Focus::Sidebar);
1492                let row = app.cat_state.offset() + (y - sidebar.y) as usize;
1493                if row >= app.categories.len() {
1494                    return;
1495                }
1496                app.select_category(row);
1497                if clicked_again(app, Focus::Sidebar, row) {
1498                    app.open_edit_category();
1499                }
1500            } else if contains(tasks, x, y) {
1501                let _ = app.set_focus(Focus::Tasks);
1502                let visual = app.task_state.offset() + (y - tasks.y) as usize;
1503                let Some(row) = app.task_at_visual_row(visual) else {
1504                    // Separator / empty — no task under the pointer.
1505                    return;
1506                };
1507                // The checkbox and the flags toggle when clicked
1508                // directly; the flags are the last column, so everything
1509                // from their first cell rightwards counts as them.
1510                let on_flags = app.areas.flag_x.is_some_and(|at| x >= at);
1511                let on_done = app
1512                    .areas
1513                    .done_x
1514                    .is_some_and(|at| x >= at && x < at + crate::ui::DONE_MARK_WIDTH);
1515                if on_flags {
1516                    app.cycle_importance(row);
1517                } else if on_done {
1518                    app.toggle_done(row);
1519                } else {
1520                    // Anywhere else selects, and selecting twice in
1521                    // quick succession opens the task.
1522                    app.select_task(row);
1523                    if clicked_again(app, Focus::Tasks, row) {
1524                        app.open_edit_task();
1525                    }
1526                }
1527            } else if contains(app.areas.preview, x, y) && app.selected_task().is_some() {
1528                // Click the permanent preview to edit the selected task.
1529                let _ = app.set_focus(Focus::Tasks);
1530                app.open_edit_task();
1531            }
1532        }
1533        _ => {}
1534    }
1535}
1536
1537fn handle_slash_mouse(app: &mut App, mouse: MouseEvent) {
1538    let rect = app.areas.slash_menu;
1539    match mouse.kind {
1540        MouseEventKind::ScrollUp | MouseEventKind::ScrollDown
1541            if contains(rect, mouse.column, mouse.row) =>
1542        {
1543            let count = crate::slash::matching(&app.input.value()).len();
1544            if count == 0 {
1545                return;
1546            }
1547            if mouse.kind == MouseEventKind::ScrollUp {
1548                app.slash_index = (app.slash_index + count - 1) % count;
1549            } else {
1550                app.slash_index = (app.slash_index + 1) % count;
1551            }
1552        }
1553        MouseEventKind::Down(MouseButton::Left) => {
1554            if contains(app.areas.command_bar, mouse.column, mouse.row) {
1555                set_command_bar_cursor(app, mouse.column);
1556            } else if contains(rect, mouse.column, mouse.row)
1557                && mouse.row > rect.y
1558                && mouse.row + 1 < rect.bottom()
1559            {
1560                let row = (mouse.row - rect.y - 1) as usize;
1561                let query = app.input.value();
1562                let commands = crate::slash::matching(&query);
1563                if let Some(command) = commands.get(row).copied() {
1564                    app.slash_index = row;
1565                    close_slash(app);
1566                    run_slash(app, command, &query);
1567                }
1568            } else {
1569                close_slash(app);
1570            }
1571        }
1572        _ => {}
1573    }
1574}
1575
1576/// Give the bottom command bar the same input mode as its keyboard entry
1577/// point. A locked search resumes editing instead of being silently cleared.
1578fn focus_command_bar(app: &mut App, x: u16) {
1579    match app.mode {
1580        Mode::Slash | Mode::Search => {}
1581        Mode::Normal if app.searching => app.resume_search(),
1582        Mode::Normal => app.open_slash(),
1583        _ => return,
1584    }
1585    set_command_bar_cursor(app, x);
1586}
1587
1588fn set_command_bar_cursor(app: &mut App, x: u16) {
1589    // The visible slash occupies the first cell of the command field.
1590    let col = x.saturating_sub(app.areas.command_bar.x).saturating_sub(1) as usize;
1591    app.input.set_cursor_from_col(col);
1592    app.dirty = true;
1593}
1594
1595/// True when a left-click lands on the sidebar or task list (and not on
1596/// an open form control that happens to sit in those coordinates).
1597fn click_on_panels(app: &App, m: MouseEvent) -> bool {
1598    if m.kind != MouseEventKind::Down(MouseButton::Left) {
1599        return false;
1600    }
1601    let (x, y) = (m.column, m.row);
1602    if !contains(app.areas.sidebar, x, y) && !contains(app.areas.tasks, x, y) {
1603        return false;
1604    }
1605    // Prefer form chrome when it overlaps the panels (modal / picker).
1606    if let Some(form) = &app.form {
1607        if form.areas.field_at(x, y).is_some() {
1608            return false;
1609        }
1610        if form.picker.as_ref().is_some_and(|p| p.contains(x, y)) {
1611            return false;
1612        }
1613        if form.body_menu_area.is_some_and(|r| contains(r, x, y)) {
1614            return false;
1615        }
1616    }
1617    if let Some(form) = &app.category_form
1618        && (contains(form.name_area, x, y) || contains(form.description_area, x, y))
1619    {
1620        return false;
1621    }
1622    true
1623}
1624
1625/// Clicking a field focuses it and puts the cursor where the pointer
1626/// landed. Clicks on the task list or sidebar leave the dialog (see
1627/// [`handle_mouse`]); other outside clicks are ignored. Double-click on
1628/// a picture opens it, same as Enter. The due picker also takes clicks
1629/// and scroll.
1630fn handle_form_mouse(app: &mut App, m: MouseEvent) {
1631    // Scroll over the open date/time picker.
1632    if matches!(
1633        m.kind,
1634        MouseEventKind::ScrollUp | MouseEventKind::ScrollDown
1635    ) && app.form.as_ref().is_some_and(|f| f.picker.is_some())
1636    {
1637        let up = matches!(m.kind, MouseEventKind::ScrollUp);
1638        if let Some(form) = &mut app.form
1639            && let Some(picker) = &mut form.picker
1640        {
1641            let _ = picker.scroll(m.column, m.row, up);
1642        }
1643        return;
1644    }
1645
1646    // Scroll over the open body `/` menu moves the selection.
1647    if matches!(
1648        m.kind,
1649        MouseEventKind::ScrollUp | MouseEventKind::ScrollDown
1650    ) && app.form.as_ref().is_some_and(|f| f.body.menu.is_some())
1651    {
1652        let up = matches!(m.kind, MouseEventKind::ScrollUp);
1653        if let Some(form) = &mut app.form {
1654            if up {
1655                form.body.menu_prev();
1656            } else {
1657                form.body.menu_next();
1658            }
1659        }
1660        return;
1661    }
1662
1663    if m.kind != MouseEventKind::Down(MouseButton::Left) {
1664        return;
1665    }
1666    // Click in the image preview: pause/resume GIF (does not close).
1667    if app.form.as_ref().is_some_and(|f| f.preview) {
1668        if let Some(form) = &mut app.form {
1669            form.preview_click();
1670        }
1671        return;
1672    }
1673
1674    // Clicks on the date/time picker (days, hour, minute).
1675    if app.form.as_ref().is_some_and(|f| f.picker.is_some()) {
1676        let Some(form) = &mut app.form else { return };
1677        let handled = form
1678            .picker
1679            .as_mut()
1680            .is_some_and(|p| p.click(m.column, m.row));
1681        if handled {
1682            return;
1683        }
1684        // Click outside the picker closes it (unless reopening Due).
1685        if !form.areas.due.contains(ratatui::layout::Position {
1686            x: m.column,
1687            y: m.row,
1688        }) {
1689            form.picker = None;
1690        }
1691    }
1692
1693    // Body `/` menu: click a row to run it; click elsewhere closes the
1694    // menu only (dialog stays open). Handled before body.click, which
1695    // would otherwise dismiss the menu without selecting anything.
1696    if app.form.as_ref().is_some_and(|f| f.body.menu.is_some()) {
1697        match click_body_slash_menu(app, m.column, m.row) {
1698            MenuClick::Handled | MenuClick::CopyDone => return,
1699            MenuClick::Miss => {
1700                // Fall through: place the cursor / change field, menu
1701                // closes via body.click or set_field.
1702            }
1703        }
1704    }
1705
1706    enum AfterClick {
1707        None,
1708        OpenUrl(String),
1709        PreviewErr(String),
1710    }
1711    let after = {
1712        let Some(form) = &mut app.form else { return };
1713        let Some(field) = form.areas.field_at(m.column, m.row) else {
1714            // Click outside the dialog fields — do not keep a pending
1715            // double-click that could open a picture on the next hit.
1716            form.last_body_click = None;
1717            return;
1718        };
1719        // Leaving Due dismisses the calendar so keys go to the new field.
1720        form.set_field(field);
1721
1722        let area = form.areas.rect(field);
1723        let col = (m.column - area.x) as usize;
1724        let row = (m.row - area.y) as usize;
1725        match field {
1726            Field::Title => {
1727                form.title.set_cursor_from_col(col);
1728                form.last_body_click = None;
1729                AfterClick::None
1730            }
1731            Field::Due => {
1732                form.open_due_picker();
1733                form.last_body_click = None;
1734                AfterClick::None
1735            }
1736            Field::Category => {
1737                form.cycle_category(1);
1738                form.last_body_click = None;
1739                AfterClick::None
1740            }
1741            Field::Body => {
1742                // Resolve the link against the painted glyphs before click()
1743                // moves the cursor; blank row padding is not a hit target.
1744                let clicked_link = form.body.link_url_at_position(row as u16, col);
1745                let hit = form.body.click(row as u16, col);
1746                if !hit {
1747                    form.last_body_click = None;
1748                    AfterClick::None
1749                } else if let Some(url) = clicked_link {
1750                    form.last_body_click = None;
1751                    AfterClick::OpenUrl(url)
1752                } else if form.body.selected_image().is_some() {
1753                    // Pictures are letterboxed — only the drawn box counts.
1754                    // Gutter clicks must not select the picture or insert a
1755                    // blank line (←/→ are what create a caret next to it).
1756                    let line = form.body.cursor_line();
1757                    if !form.image_hit_at(line, m.column, m.row) {
1758                        form.body.abandon_image_selection();
1759                        form.last_body_click = None;
1760                        AfterClick::None
1761                    } else {
1762                        let now = Instant::now();
1763                        let again = form.last_body_click.is_some_and(|(at, last)| {
1764                            last == line && now.duration_since(at) < DOUBLE_CLICK
1765                        });
1766                        if again {
1767                            form.last_body_click = None;
1768                            match form.open_image_preview() {
1769                                Some(err) => AfterClick::PreviewErr(err),
1770                                None => AfterClick::None,
1771                            }
1772                        } else {
1773                            form.last_body_click = Some((now, line));
1774                            AfterClick::None
1775                        }
1776                    }
1777                } else {
1778                    form.last_body_click = None;
1779                    AfterClick::None
1780                }
1781            }
1782            Field::Importance => {
1783                form.cycle_importance();
1784                form.last_body_click = None;
1785                AfterClick::None
1786            }
1787        }
1788    };
1789    match after {
1790        AfterClick::None => {}
1791        AfterClick::OpenUrl(url) => match crate::open::open_url(&url) {
1792            Ok(()) => app.info(format!("Opened {url}")),
1793            Err(err) => app.error(err),
1794        },
1795        AfterClick::PreviewErr(err) => app.error(err),
1796    }
1797}
1798
1799enum MenuClick {
1800    /// Click was on the menu (selected a row or the chrome).
1801    Handled,
1802    /// Copy command finished (clipboard side effects applied).
1803    CopyDone,
1804    /// Click missed the menu rect entirely.
1805    Miss,
1806}
1807
1808/// Hit-test the open body `/` dropdown. Clicking a command row runs it.
1809fn click_body_slash_menu(app: &mut App, x: u16, y: u16) -> MenuClick {
1810    let Some(form) = app.form.as_ref() else {
1811        return MenuClick::Miss;
1812    };
1813    let Some(rect) = form.body_menu_area else {
1814        return MenuClick::Miss;
1815    };
1816    if !contains(rect, x, y) {
1817        return MenuClick::Miss;
1818    }
1819
1820    // Rows sit inside the border: top border at rect.y, first command at y+1.
1821    let commands = form.body.menu_commands();
1822    if commands.is_empty() {
1823        return MenuClick::Handled;
1824    }
1825    if y <= rect.y || y >= rect.bottom().saturating_sub(1) {
1826        // Title / bottom border — keep the menu open, select nothing.
1827        return MenuClick::Handled;
1828    }
1829    let idx = (y - rect.y - 1) as usize;
1830    if idx >= commands.len() {
1831        return MenuClick::Handled;
1832    }
1833
1834    let command = commands[idx];
1835    let Some(form) = app.form.as_mut() else {
1836        return MenuClick::Miss;
1837    };
1838    if let Some(menu) = &mut form.body.menu {
1839        menu.index = idx;
1840    }
1841    form.before_edit(EditKind::Atomic);
1842    match form.body.apply(command) {
1843        Some(payload) => {
1844            finish_copy(app, payload);
1845            MenuClick::CopyDone
1846        }
1847        None => MenuClick::Handled,
1848    }
1849}
1850
1851/// Whether this click lands on the row the last one did, soon enough to
1852/// count as a double click. Records the click either way.
1853fn clicked_again(app: &mut App, panel: Focus, row: usize) -> bool {
1854    let now = Instant::now();
1855    let again = app.last_click.is_some_and(|(at, last_panel, last_row)| {
1856        last_panel == panel && last_row == row && now.duration_since(at) < DOUBLE_CLICK
1857    });
1858    app.last_click = (!again).then_some((now, panel, row));
1859    again
1860}
1861
1862fn contains(area: ratatui::layout::Rect, x: u16, y: u16) -> bool {
1863    area.contains(ratatui::layout::Position { x, y })
1864}
1865
1866#[cfg(test)]
1867mod tests {
1868    use std::path::PathBuf;
1869
1870    use crate::body::CopyLine;
1871
1872    use super::{
1873        MAX_OSC52_ENCODED_BYTES, MAX_OSC52_RAW_BYTES, build_clipboard_payload_with, osc52_sequence,
1874    };
1875
1876    #[test]
1877    fn terminal_clipboard_fallback_preserves_utf8_text() {
1878        assert_eq!(osc52_sequence("买菜").unwrap(), "\u{1b}]52;c;5Lmw6I+c\u{7}");
1879    }
1880
1881    #[test]
1882    fn terminal_clipboard_rejects_oversized_raw_and_encoded_payloads() {
1883        let raw = osc52_sequence(&"x".repeat(MAX_OSC52_RAW_BYTES + 1)).unwrap_err();
1884        assert!(raw.contains("raw limit"), "{raw}");
1885
1886        let encoded_input = "x".repeat(62 * 1024);
1887        assert!(encoded_input.len() <= MAX_OSC52_RAW_BYTES);
1888        let encoded = osc52_sequence(&encoded_input).unwrap_err();
1889        assert!(encoded.contains("encoded limit"), "{encoded}");
1890        assert!(MAX_OSC52_ENCODED_BYTES < encoded_input.len() * 4 / 3 + 4);
1891    }
1892
1893    #[test]
1894    fn rich_clipboard_budget_replaces_an_oversized_image_but_keeps_plain_text() {
1895        let lines = vec![
1896            CopyLine::Text("before".into()),
1897            CopyLine::Image(PathBuf::from("huge.png")),
1898            CopyLine::Text("after".into()),
1899        ];
1900        let budget = 128;
1901        let (plain, html) = build_clipboard_payload_with(&lines, budget, |_, _| {
1902            Ok(format!("data:image/png;base64,{}", "A".repeat(256)))
1903        });
1904
1905        assert_eq!(plain, "before\n[image: huge.png]\nafter");
1906        assert!(html.contains("[image: huge.png]"), "{html}");
1907        assert!(!html.contains("<img"), "{html}");
1908        assert!(html.len() <= budget);
1909    }
1910
1911    #[test]
1912    fn rich_clipboard_only_links_to_approved_url_schemes() {
1913        let lines = vec![
1914            CopyLine::Link("example.com/?a=1&b=2".into()),
1915            CopyLine::Link("javascript:alert(1)".into()),
1916        ];
1917
1918        let (plain, html) = build_clipboard_payload_with(&lines, 1024, |_, _| unreachable!());
1919
1920        assert_eq!(plain, "example.com/?a=1&b=2\njavascript:alert(1)");
1921        assert!(
1922            html.contains("href=\"https://example.com/?a=1&amp;b=2\""),
1923            "{html}"
1924        );
1925        assert_eq!(html.matches("<a ").count(), 1, "{html}");
1926        assert!(html.contains("<div>javascript:alert(1)</div>"), "{html}");
1927    }
1928}