1use 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
14const DOUBLE_CLICK: Duration = Duration::from_millis(400);
16
17pub 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 Event::Paste(text) if !text.is_empty() => {
39 paste_text(app, &text);
40 true
41 }
42 Event::Resize(_, _) => true,
45 _ => false,
46 }
47}
48
49fn 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 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
97fn 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
105fn 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
120fn 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 'u' | 'k' | 'w' | 'W' if !shift => Some(EditKind::Atomic),
130 '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 if is_copy_chord(key) && copy_selected_body_image(app) {
145 return;
146 }
147 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 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 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
224fn 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
231fn is_ctrl_c(key: KeyEvent) -> bool {
234 matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C'))
235 && key.modifiers == KeyModifiers::CONTROL
236}
237
238fn 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 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 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
297fn 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 KeyCode::Esc => {
308 if app.searching {
309 app.end_search();
310 }
311 }
312 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 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 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 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 KeyCode::Char(c) if !ctrl && !alt && !meta && !c.is_control() => {
426 app.typeahead_jump(c);
427 }
428 _ => {}
429 }
430}
431
432fn word_mod(key: KeyEvent) -> bool {
437 key.modifiers
438 .intersects(KeyModifiers::ALT | KeyModifiers::CONTROL)
439}
440
441fn 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 KeyCode::Char('w') | KeyCode::Char('W') if alt && shift => input.select_word(),
450 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 'w' | 'W' => input.delete_word_left(),
465 _ => return false,
466 },
467 KeyCode::Char(c) => input.insert(c),
468 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
489fn 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
535fn 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 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 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
639fn handle_slash_key(app: &mut App, key: KeyEvent) {
641 match key.code {
642 KeyCode::Esc => close_slash(app),
643 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
681fn 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 app.mode = Mode::Normal;
691 app.input = TextInput::default();
692 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
760fn handle_form_key(app: &mut App, key: KeyEvent) {
765 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
766
767 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 if app.form.as_ref().is_some_and(|f| f.preview) {
797 match key.code {
798 KeyCode::Esc => {
799 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 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 if app.form.as_ref().is_some_and(|f| f.picker.is_some()) {
837 handle_picker_key(app, key);
838 return;
839 }
840
841 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 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 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 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 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 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 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 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
964fn 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 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
1033fn 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 let outcome = {
1040 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
1090fn 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
1181fn 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
1195fn 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 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 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("&"),
1346 '<' => out.push_str("<"),
1347 '>' => out.push_str(">"),
1348 '"' => out.push_str("""),
1349 _ => out.push(c),
1350 }
1351 }
1352 out
1353}
1354
1355fn 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
1372fn 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
1410fn 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 if app.form.as_ref().is_some_and(|form| form.preview) {
1422 handle_form_mouse(app, m);
1423 return;
1424 }
1425 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 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 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(sidebar, x, y) {
1486 if app.searching {
1487 return;
1488 }
1489 let _ = app.set_focus(Focus::Sidebar);
1490 let row = app.cat_state.offset() + (y - sidebar.y) as usize;
1491 if row >= app.categories.len() {
1492 return;
1493 }
1494 app.select_category(row);
1495 if clicked_again(app, Focus::Sidebar, row) {
1496 app.open_edit_category();
1497 }
1498 } else if contains(tasks, x, y) {
1499 let _ = app.set_focus(Focus::Tasks);
1500 let visual = app.task_state.offset() + (y - tasks.y) as usize;
1501 let Some(row) = app.task_at_visual_row(visual) else {
1502 return;
1504 };
1505 let on_flags = app.areas.flag_x.is_some_and(|at| x >= at);
1509 let on_done = app
1510 .areas
1511 .done_x
1512 .is_some_and(|at| x >= at && x < at + crate::ui::DONE_MARK_WIDTH);
1513 if on_flags {
1514 app.cycle_importance(row);
1515 } else if on_done {
1516 app.toggle_done(row);
1517 } else {
1518 app.select_task(row);
1521 if clicked_again(app, Focus::Tasks, row) {
1522 app.open_edit_task();
1523 }
1524 }
1525 } else if contains(app.areas.preview, x, y) && app.selected_task().is_some() {
1526 let _ = app.set_focus(Focus::Tasks);
1528 app.open_edit_task();
1529 }
1530 }
1531 _ => {}
1532 }
1533}
1534
1535fn handle_slash_mouse(app: &mut App, mouse: MouseEvent) {
1536 let rect = app.areas.slash_menu;
1537 match mouse.kind {
1538 MouseEventKind::ScrollUp | MouseEventKind::ScrollDown
1539 if contains(rect, mouse.column, mouse.row) =>
1540 {
1541 let count = crate::slash::matching(&app.input.value()).len();
1542 if count == 0 {
1543 return;
1544 }
1545 if mouse.kind == MouseEventKind::ScrollUp {
1546 app.slash_index = (app.slash_index + count - 1) % count;
1547 } else {
1548 app.slash_index = (app.slash_index + 1) % count;
1549 }
1550 }
1551 MouseEventKind::Down(MouseButton::Left) => {
1552 if contains(rect, mouse.column, mouse.row)
1553 && mouse.row > rect.y
1554 && mouse.row + 1 < rect.bottom()
1555 {
1556 let row = (mouse.row - rect.y - 1) as usize;
1557 let query = app.input.value();
1558 let commands = crate::slash::matching(&query);
1559 if let Some(command) = commands.get(row).copied() {
1560 app.slash_index = row;
1561 close_slash(app);
1562 run_slash(app, command, &query);
1563 }
1564 } else {
1565 close_slash(app);
1566 }
1567 }
1568 _ => {}
1569 }
1570}
1571
1572fn click_on_panels(app: &App, m: MouseEvent) -> bool {
1575 if m.kind != MouseEventKind::Down(MouseButton::Left) {
1576 return false;
1577 }
1578 let (x, y) = (m.column, m.row);
1579 if !contains(app.areas.sidebar, x, y) && !contains(app.areas.tasks, x, y) {
1580 return false;
1581 }
1582 if let Some(form) = &app.form {
1584 if form.areas.field_at(x, y).is_some() {
1585 return false;
1586 }
1587 if form.picker.as_ref().is_some_and(|p| p.contains(x, y)) {
1588 return false;
1589 }
1590 if form.body_menu_area.is_some_and(|r| contains(r, x, y)) {
1591 return false;
1592 }
1593 }
1594 if let Some(form) = &app.category_form
1595 && (contains(form.name_area, x, y) || contains(form.description_area, x, y))
1596 {
1597 return false;
1598 }
1599 true
1600}
1601
1602fn handle_form_mouse(app: &mut App, m: MouseEvent) {
1608 if matches!(
1610 m.kind,
1611 MouseEventKind::ScrollUp | MouseEventKind::ScrollDown
1612 ) && app.form.as_ref().is_some_and(|f| f.picker.is_some())
1613 {
1614 let up = matches!(m.kind, MouseEventKind::ScrollUp);
1615 if let Some(form) = &mut app.form
1616 && let Some(picker) = &mut form.picker
1617 {
1618 let _ = picker.scroll(m.column, m.row, up);
1619 }
1620 return;
1621 }
1622
1623 if matches!(
1625 m.kind,
1626 MouseEventKind::ScrollUp | MouseEventKind::ScrollDown
1627 ) && app.form.as_ref().is_some_and(|f| f.body.menu.is_some())
1628 {
1629 let up = matches!(m.kind, MouseEventKind::ScrollUp);
1630 if let Some(form) = &mut app.form {
1631 if up {
1632 form.body.menu_prev();
1633 } else {
1634 form.body.menu_next();
1635 }
1636 }
1637 return;
1638 }
1639
1640 if m.kind != MouseEventKind::Down(MouseButton::Left) {
1641 return;
1642 }
1643 if app.form.as_ref().is_some_and(|f| f.preview) {
1645 if let Some(form) = &mut app.form {
1646 form.preview_click();
1647 }
1648 return;
1649 }
1650
1651 if app.form.as_ref().is_some_and(|f| f.picker.is_some()) {
1653 let Some(form) = &mut app.form else { return };
1654 let handled = form
1655 .picker
1656 .as_mut()
1657 .is_some_and(|p| p.click(m.column, m.row));
1658 if handled {
1659 return;
1660 }
1661 if !form.areas.due.contains(ratatui::layout::Position {
1663 x: m.column,
1664 y: m.row,
1665 }) {
1666 form.picker = None;
1667 }
1668 }
1669
1670 if app.form.as_ref().is_some_and(|f| f.body.menu.is_some()) {
1674 match click_body_slash_menu(app, m.column, m.row) {
1675 MenuClick::Handled | MenuClick::CopyDone => return,
1676 MenuClick::Miss => {
1677 }
1680 }
1681 }
1682
1683 enum AfterClick {
1684 None,
1685 OpenUrl(String),
1686 PreviewErr(String),
1687 }
1688 let after = {
1689 let Some(form) = &mut app.form else { return };
1690 let Some(field) = form.areas.field_at(m.column, m.row) else {
1691 form.last_body_click = None;
1694 return;
1695 };
1696 form.set_field(field);
1698
1699 let area = form.areas.rect(field);
1700 let col = (m.column - area.x) as usize;
1701 let row = (m.row - area.y) as usize;
1702 match field {
1703 Field::Title => {
1704 form.title.set_cursor_from_col(col);
1705 form.last_body_click = None;
1706 AfterClick::None
1707 }
1708 Field::Due => {
1709 form.open_due_picker();
1710 form.last_body_click = None;
1711 AfterClick::None
1712 }
1713 Field::Category => {
1714 form.cycle_category(1);
1715 form.last_body_click = None;
1716 AfterClick::None
1717 }
1718 Field::Body => {
1719 let clicked_link = form.body.link_url_at_position(row as u16, col);
1722 let hit = form.body.click(row as u16, col);
1723 if !hit {
1724 form.last_body_click = None;
1725 AfterClick::None
1726 } else if let Some(url) = clicked_link {
1727 form.last_body_click = None;
1728 AfterClick::OpenUrl(url)
1729 } else if form.body.selected_image().is_some() {
1730 let line = form.body.cursor_line();
1734 if !form.image_hit_at(line, m.column, m.row) {
1735 form.body.abandon_image_selection();
1736 form.last_body_click = None;
1737 AfterClick::None
1738 } else {
1739 let now = Instant::now();
1740 let again = form.last_body_click.is_some_and(|(at, last)| {
1741 last == line && now.duration_since(at) < DOUBLE_CLICK
1742 });
1743 if again {
1744 form.last_body_click = None;
1745 match form.open_image_preview() {
1746 Some(err) => AfterClick::PreviewErr(err),
1747 None => AfterClick::None,
1748 }
1749 } else {
1750 form.last_body_click = Some((now, line));
1751 AfterClick::None
1752 }
1753 }
1754 } else {
1755 form.last_body_click = None;
1756 AfterClick::None
1757 }
1758 }
1759 Field::Importance => {
1760 form.cycle_importance();
1761 form.last_body_click = None;
1762 AfterClick::None
1763 }
1764 }
1765 };
1766 match after {
1767 AfterClick::None => {}
1768 AfterClick::OpenUrl(url) => match crate::open::open_url(&url) {
1769 Ok(()) => app.info(format!("Opened {url}")),
1770 Err(err) => app.error(err),
1771 },
1772 AfterClick::PreviewErr(err) => app.error(err),
1773 }
1774}
1775
1776enum MenuClick {
1777 Handled,
1779 CopyDone,
1781 Miss,
1783}
1784
1785fn click_body_slash_menu(app: &mut App, x: u16, y: u16) -> MenuClick {
1787 let Some(form) = app.form.as_ref() else {
1788 return MenuClick::Miss;
1789 };
1790 let Some(rect) = form.body_menu_area else {
1791 return MenuClick::Miss;
1792 };
1793 if !contains(rect, x, y) {
1794 return MenuClick::Miss;
1795 }
1796
1797 let commands = form.body.menu_commands();
1799 if commands.is_empty() {
1800 return MenuClick::Handled;
1801 }
1802 if y <= rect.y || y >= rect.bottom().saturating_sub(1) {
1803 return MenuClick::Handled;
1805 }
1806 let idx = (y - rect.y - 1) as usize;
1807 if idx >= commands.len() {
1808 return MenuClick::Handled;
1809 }
1810
1811 let command = commands[idx];
1812 let Some(form) = app.form.as_mut() else {
1813 return MenuClick::Miss;
1814 };
1815 if let Some(menu) = &mut form.body.menu {
1816 menu.index = idx;
1817 }
1818 form.before_edit(EditKind::Atomic);
1819 match form.body.apply(command) {
1820 Some(payload) => {
1821 finish_copy(app, payload);
1822 MenuClick::CopyDone
1823 }
1824 None => MenuClick::Handled,
1825 }
1826}
1827
1828fn clicked_again(app: &mut App, panel: Focus, row: usize) -> bool {
1831 let now = Instant::now();
1832 let again = app.last_click.is_some_and(|(at, last_panel, last_row)| {
1833 last_panel == panel && last_row == row && now.duration_since(at) < DOUBLE_CLICK
1834 });
1835 app.last_click = (!again).then_some((now, panel, row));
1836 again
1837}
1838
1839fn contains(area: ratatui::layout::Rect, x: u16, y: u16) -> bool {
1840 area.contains(ratatui::layout::Position { x, y })
1841}
1842
1843#[cfg(test)]
1844mod tests {
1845 use std::path::PathBuf;
1846
1847 use crate::body::CopyLine;
1848
1849 use super::{
1850 MAX_OSC52_ENCODED_BYTES, MAX_OSC52_RAW_BYTES, build_clipboard_payload_with, osc52_sequence,
1851 };
1852
1853 #[test]
1854 fn terminal_clipboard_fallback_preserves_utf8_text() {
1855 assert_eq!(osc52_sequence("买菜").unwrap(), "\u{1b}]52;c;5Lmw6I+c\u{7}");
1856 }
1857
1858 #[test]
1859 fn terminal_clipboard_rejects_oversized_raw_and_encoded_payloads() {
1860 let raw = osc52_sequence(&"x".repeat(MAX_OSC52_RAW_BYTES + 1)).unwrap_err();
1861 assert!(raw.contains("raw limit"), "{raw}");
1862
1863 let encoded_input = "x".repeat(62 * 1024);
1864 assert!(encoded_input.len() <= MAX_OSC52_RAW_BYTES);
1865 let encoded = osc52_sequence(&encoded_input).unwrap_err();
1866 assert!(encoded.contains("encoded limit"), "{encoded}");
1867 assert!(MAX_OSC52_ENCODED_BYTES < encoded_input.len() * 4 / 3 + 4);
1868 }
1869
1870 #[test]
1871 fn rich_clipboard_budget_replaces_an_oversized_image_but_keeps_plain_text() {
1872 let lines = vec![
1873 CopyLine::Text("before".into()),
1874 CopyLine::Image(PathBuf::from("huge.png")),
1875 CopyLine::Text("after".into()),
1876 ];
1877 let budget = 128;
1878 let (plain, html) = build_clipboard_payload_with(&lines, budget, |_, _| {
1879 Ok(format!("data:image/png;base64,{}", "A".repeat(256)))
1880 });
1881
1882 assert_eq!(plain, "before\n[image: huge.png]\nafter");
1883 assert!(html.contains("[image: huge.png]"), "{html}");
1884 assert!(!html.contains("<img"), "{html}");
1885 assert!(html.len() <= budget);
1886 }
1887
1888 #[test]
1889 fn rich_clipboard_only_links_to_approved_url_schemes() {
1890 let lines = vec![
1891 CopyLine::Link("example.com/?a=1&b=2".into()),
1892 CopyLine::Link("javascript:alert(1)".into()),
1893 ];
1894
1895 let (plain, html) = build_clipboard_payload_with(&lines, 1024, |_, _| unreachable!());
1896
1897 assert_eq!(plain, "example.com/?a=1&b=2\njavascript:alert(1)");
1898 assert!(
1899 html.contains("href=\"https://example.com/?a=1&b=2\""),
1900 "{html}"
1901 );
1902 assert_eq!(html.matches("<a ").count(), 1, "{html}");
1903 assert!(html.contains("<div>javascript:alert(1)</div>"), "{html}");
1904 }
1905}