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 => {
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::CopyTitle => match app.selected_task() {
721 Some(task) => {
722 finish_copy(app, crate::body::CopyPayload::Text(task.title.clone()));
723 }
724 None => app.info("No task selected"),
725 },
726 SlashCommand::CopyTask => match app.selected_task() {
727 Some(task) => {
728 let text = task_clipboard_text(task);
729 finish_copy(app, crate::body::CopyPayload::Text(text));
730 }
731 None => app.info("No task selected"),
732 },
733 SlashCommand::Done => {
734 if let Some(hidden) = app.toggle_hide_done() {
735 if hidden {
736 app.info("Hiding completed tasks");
737 } else {
738 app.info("Showing completed tasks");
739 }
740 }
741 }
742 SlashCommand::Purge => {
743 let ids = app.purge_candidate_ids();
744 if ids.is_empty() {
745 app.info("No done tasks to purge");
746 } else {
747 let count = ids.len();
748 app.ask_confirm(
749 Confirm::Purge(ids),
750 format!("Press Enter to purge {count} done task(s)"),
751 );
752 }
753 }
754 SlashCommand::Update => app.start_update_check(),
755 SlashCommand::Quit => app.should_quit = true,
756 }
757}
758
759fn handle_form_key(app: &mut App, key: KeyEvent) {
764 let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
765
766 if matches!(key.code, KeyCode::Char('s')) && ctrl {
769 if app.form.as_ref().is_some_and(|form| form.preview) {
770 app.error("Close the image preview before saving");
771 return;
772 }
773 if app
774 .form
775 .as_ref()
776 .is_some_and(|form| form.body.menu.is_some())
777 {
778 app.error("Choose or dismiss the body command before saving");
779 return;
780 }
781 if let Some(form) = &mut app.form
782 && form.picker.is_some()
783 {
784 form.take_due_picker();
785 }
786 app.submit_form();
787 return;
788 }
789
790 if app.form.as_ref().is_some_and(|f| f.preview) {
796 match key.code {
797 KeyCode::Esc => {
798 if let Some(form) = &mut app.form {
801 form.close_image_preview();
802 }
803 app.images.clear_preview();
804 }
805 KeyCode::Enter | KeyCode::Char(' ') => {
806 if let Some(form) = &mut app.form {
807 form.preview_click();
808 }
809 }
810 _ => {}
811 }
812 return;
813 }
814
815 if is_undo_chord(key) {
818 if let Some(form) = &mut app.form
819 && form.undo()
820 {
821 app.info("Undo");
822 }
823 return;
824 }
825 if is_redo_chord(key) {
826 if let Some(form) = &mut app.form
827 && form.redo()
828 {
829 app.info("Redo");
830 }
831 return;
832 }
833
834 if app.form.as_ref().is_some_and(|f| f.picker.is_some()) {
836 handle_picker_key(app, key);
837 return;
838 }
839
840 if app.form.as_ref().is_some_and(|f| f.body.menu.is_some()) && handle_menu_key(app, key) {
842 return;
843 }
844
845 match key.code {
846 KeyCode::Esc => {
847 let _ = request_close_task_form(app);
848 }
849 KeyCode::Tab => {
850 if let Some(form) = &mut app.form {
851 form.focus_next();
852 }
853 }
854 KeyCode::BackTab => {
855 if let Some(form) = &mut app.form {
856 form.focus_prev();
857 }
858 }
859 KeyCode::Enter
863 if key
864 .modifiers
865 .intersects(KeyModifiers::SUPER | KeyModifiers::CONTROL) =>
866 {
867 let url = app
868 .form
869 .as_ref()
870 .filter(|f| f.field == Field::Body)
871 .and_then(|f| f.body.link_url_at_cursor());
872 if let Some(url) = url {
873 match crate::open::open_url(&url) {
874 Ok(()) => app.info(format!("Opened {url}")),
875 Err(err) => app.error(err),
876 }
877 }
878 }
879 KeyCode::Enter => {
880 let Some(form) = &mut app.form else { return };
881 match form.field {
882 Field::Title | Field::Category | Field::Importance => form.focus_next(),
883 Field::Due => form.open_due_picker(),
884 Field::Body if form.body.selected_image().is_some() => {
887 if let Some(err) = form.open_image_preview() {
888 app.error(err);
889 }
890 }
891 Field::Body => {
892 form.before_edit(EditKind::Atomic);
893 let _ = form.body.newline();
894 }
895 }
896 }
897 _ => {
898 let Some(form) = &mut app.form else { return };
899 match form.field {
900 Field::Body
903 if ctrl && matches!(key.code, KeyCode::Char('d') | KeyCode::Char('D')) =>
904 {
905 form.before_edit(EditKind::Atomic);
906 form.body.toggle();
907 }
908 Field::Body => {
909 if let Some(mut kind) = content_edit_kind(key) {
910 if form.body.has_selection() {
911 kind = EditKind::Atomic;
912 }
913 form.before_edit(kind);
914 } else {
915 form.break_coalesce();
916 }
917 edit_body(&mut form.body, key);
918 }
919 Field::Category => match key.code {
922 KeyCode::Left | KeyCode::Up => form.cycle_category(-1),
923 KeyCode::Right | KeyCode::Down | KeyCode::Char(' ') => form.cycle_category(1),
924 KeyCode::Backspace | KeyCode::Delete => form.clear_category(),
925 _ => form.break_coalesce(),
926 },
927 Field::Importance => match key.code {
930 KeyCode::Left | KeyCode::Down => {
931 form.set_importance(form.importance.saturating_sub(1))
932 }
933 KeyCode::Right | KeyCode::Up | KeyCode::Char(' ') => form.cycle_importance(),
934 KeyCode::Backspace | KeyCode::Delete => form.set_importance(0),
935 KeyCode::Char(c) if c.is_ascii_digit() => form.set_importance(c as u8 - b'0'),
936 _ => form.break_coalesce(),
937 },
938 Field::Due => match key.code {
941 KeyCode::Char(_) => form.open_due_picker(),
942 KeyCode::Backspace | KeyCode::Delete => form.clear_due(),
943 _ => form.break_coalesce(),
944 },
945 Field::Title => {
948 if let Some(mut kind) = content_edit_kind(key) {
949 if form.title.has_selection() {
950 kind = EditKind::Atomic;
951 }
952 form.before_edit(kind);
953 } else {
954 form.break_coalesce();
955 }
956 edit_line(&mut form.title, key);
957 }
958 }
959 }
960 }
961}
962
963fn handle_picker_key(app: &mut App, key: KeyEvent) {
966 use crate::duepicker::PickerFocus;
967
968 let Some(form) = &mut app.form else { return };
969 match key.code {
970 KeyCode::Esc => {
971 form.picker = None;
972 return;
973 }
974 KeyCode::Char('x') | KeyCode::Delete => {
975 form.clear_due();
976 return;
977 }
978 KeyCode::Enter => {
979 form.take_due_picker();
980 return;
981 }
982 _ => {}
983 }
984
985 let Some(picker) = &mut form.picker else {
986 return;
987 };
988 match key.code {
989 KeyCode::Tab => picker.focus_next(),
990 KeyCode::BackTab => picker.focus_prev(),
991 KeyCode::Char('t') => {
992 picker.today();
993 picker.now_time();
994 }
995 KeyCode::Left => match picker.focus {
996 PickerFocus::Calendar => picker.move_days(-1),
997 PickerFocus::Hour => picker.bump_hour(-1),
998 PickerFocus::Minute => picker.bump_minute(-5),
999 },
1000 KeyCode::Right => match picker.focus {
1001 PickerFocus::Calendar => picker.move_days(1),
1002 PickerFocus::Hour => picker.bump_hour(1),
1003 PickerFocus::Minute => picker.bump_minute(5),
1004 },
1005 KeyCode::Up => match picker.focus {
1006 PickerFocus::Calendar => picker.move_days(-7),
1007 PickerFocus::Hour => picker.bump_hour(1),
1008 PickerFocus::Minute => picker.bump_minute(5),
1009 },
1010 KeyCode::Down => match picker.focus {
1011 PickerFocus::Calendar => picker.move_days(7),
1012 PickerFocus::Hour => picker.bump_hour(-1),
1013 PickerFocus::Minute => picker.bump_minute(-5),
1014 },
1015 KeyCode::PageUp => match picker.focus {
1016 PickerFocus::Calendar => picker.move_months(-1),
1017 PickerFocus::Hour => picker.bump_hour(1),
1018 PickerFocus::Minute => picker.bump_minute(15),
1019 },
1020 KeyCode::PageDown => match picker.focus {
1021 PickerFocus::Calendar => picker.move_months(1),
1022 PickerFocus::Hour => picker.bump_hour(-1),
1023 PickerFocus::Minute => picker.bump_minute(-15),
1024 },
1025 KeyCode::Char(' ') if picker.focus != PickerFocus::Calendar => picker.now_time(),
1027 KeyCode::Char(c) if c.is_ascii_digit() => picker.type_digit(c as u8 - b'0'),
1028 _ => {}
1029 }
1030}
1031
1032fn handle_menu_key(app: &mut App, key: KeyEvent) -> bool {
1034 let Some(form) = app.form.as_mut().filter(|form| form.body.menu.is_some()) else {
1035 return false;
1036 };
1037 let outcome = {
1039 if matches!(key.code, KeyCode::Enter | KeyCode::Tab) {
1041 form.before_edit(EditKind::Atomic);
1042 }
1043 body_menu_key(&mut form.body, key)
1044 };
1045 match outcome {
1046 MenuKey::Ignored => false,
1047 MenuKey::Handled => true,
1048 MenuKey::Copy(payload) => {
1049 finish_copy(app, payload);
1050 true
1051 }
1052 }
1053}
1054
1055enum MenuKey {
1056 Ignored,
1057 Handled,
1058 Copy(crate::body::CopyPayload),
1059}
1060
1061fn body_menu_key(body: &mut crate::body::BodyEditor, key: KeyEvent) -> MenuKey {
1062 match key.code {
1063 KeyCode::Up => {
1064 body.menu_prev();
1065 MenuKey::Handled
1066 }
1067 KeyCode::Down => {
1068 body.menu_next();
1069 MenuKey::Handled
1070 }
1071 KeyCode::Esc => {
1072 body.close_menu();
1073 MenuKey::Handled
1074 }
1075 KeyCode::Tab | KeyCode::Enter => match body.menu_selected() {
1076 Some(command) => match body.apply(command) {
1077 Some(payload) => MenuKey::Copy(payload),
1078 None => MenuKey::Handled,
1079 },
1080 None => {
1081 body.close_menu();
1082 MenuKey::Handled
1083 }
1084 },
1085 _ => MenuKey::Ignored,
1086 }
1087}
1088
1089fn task_clipboard_text(task: &crate::model::Task) -> String {
1091 let body = crate::body::BodyEditor::new(&task.body).text_for_copy();
1092 if body.is_empty() {
1093 task.title.clone()
1094 } else {
1095 format!("{}\n\n{body}", task.title)
1096 }
1097}
1098
1099fn finish_copy(app: &mut App, payload: crate::body::CopyPayload) {
1100 match payload {
1101 crate::body::CopyPayload::Text(text) => {
1102 if text.is_empty() {
1103 app.info("Nothing to copy");
1104 return;
1105 }
1106 match copy_text(&text) {
1107 Ok(ClipboardTarget::System) => app.info("Copied text to clipboard"),
1108 Ok(ClipboardTarget::Terminal) => app.info("Copied text through the terminal"),
1109 Err(err) => app.error(format!("Could not copy: {err}")),
1110 }
1111 }
1112 crate::body::CopyPayload::Image(path) => match copy_image_file(&path) {
1113 Ok(()) => app.info("Copied image to clipboard"),
1114 Err(err) => app.error(format!("Could not copy image: {err}")),
1115 },
1116 crate::body::CopyPayload::All(lines) => {
1117 if lines.is_empty() {
1118 app.info("Nothing to copy");
1119 return;
1120 }
1121 match copy_all(&lines) {
1122 Ok(ClipboardTarget::System) => app.info("Copied text and pictures"),
1123 Ok(ClipboardTarget::Terminal) => app.info("Copied plain text through the terminal"),
1124 Err(err) => app.error(format!("Could not copy: {err}")),
1125 }
1126 }
1127 }
1128}
1129
1130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1131enum ClipboardTarget {
1132 System,
1133 Terminal,
1134}
1135
1136const MAX_OSC52_RAW_BYTES: usize = 64 * 1024;
1137const MAX_OSC52_ENCODED_BYTES: usize = 80 * 1024;
1138const MAX_RICH_CLIPBOARD_BYTES: usize = 8 * 1024 * 1024;
1139
1140fn copy_text(text: &str) -> Result<ClipboardTarget, String> {
1141 match arboard::Clipboard::new().and_then(|mut clipboard| clipboard.set_text(text)) {
1142 Ok(()) => Ok(ClipboardTarget::System),
1143 Err(system_error) => osc52_copy(text).map_err(|terminal_error| {
1144 format!("system clipboard: {system_error}; terminal clipboard: {terminal_error}")
1145 }),
1146 }
1147}
1148
1149fn osc52_copy(text: &str) -> Result<ClipboardTarget, String> {
1150 use std::io::Write;
1151
1152 let sequence = osc52_sequence(text)?;
1153 let mut stdout = std::io::stdout().lock();
1154 stdout
1155 .write_all(sequence.as_bytes())
1156 .and_then(|()| stdout.flush())
1157 .map_err(|error| error.to_string())?;
1158 Ok(ClipboardTarget::Terminal)
1159}
1160
1161fn osc52_sequence(text: &str) -> Result<String, String> {
1162 use base64::Engine;
1163
1164 if text.len() > MAX_OSC52_RAW_BYTES {
1165 return Err(format!(
1166 "OSC 52 text is {} bytes; raw limit is {MAX_OSC52_RAW_BYTES} bytes",
1167 text.len()
1168 ));
1169 }
1170 let encoded = base64::engine::general_purpose::STANDARD.encode(text.as_bytes());
1171 if encoded.len() > MAX_OSC52_ENCODED_BYTES {
1172 return Err(format!(
1173 "OSC 52 payload is {} bytes; encoded limit is {MAX_OSC52_ENCODED_BYTES} bytes",
1174 encoded.len()
1175 ));
1176 }
1177 Ok(format!("\x1b]52;c;{encoded}\x07"))
1178}
1179
1180fn copy_image_file(path: &std::path::Path) -> Result<(), String> {
1182 let rgba = crate::image::load_dynamic(path)?.into_rgba8();
1183 let (width, height) = rgba.dimensions();
1184 let data = arboard::ImageData {
1185 width: width as usize,
1186 height: height as usize,
1187 bytes: rgba.into_raw().into(),
1188 };
1189 arboard::Clipboard::new()
1190 .and_then(|mut c| c.set_image(data))
1191 .map_err(|e| e.to_string())
1192}
1193
1194fn copy_all(lines: &[crate::body::CopyLine]) -> Result<ClipboardTarget, String> {
1198 let (plain, html) = build_clipboard_payload(lines, MAX_RICH_CLIPBOARD_BYTES);
1199
1200 match arboard::Clipboard::new()
1201 .and_then(|mut clipboard| clipboard.set_html(html.as_str(), Some(plain.as_str())))
1202 {
1203 Ok(()) => Ok(ClipboardTarget::System),
1204 Err(system_error) => osc52_copy(&plain).map_err(|terminal_error| {
1205 format!("system clipboard: {system_error}; terminal clipboard: {terminal_error}")
1206 }),
1207 }
1208}
1209
1210fn build_clipboard_payload(
1211 lines: &[crate::body::CopyLine],
1212 rich_budget: usize,
1213) -> (String, String) {
1214 build_clipboard_payload_with(lines, rich_budget, image_data_url)
1215}
1216
1217fn build_clipboard_payload_with(
1218 lines: &[crate::body::CopyLine],
1219 rich_budget: usize,
1220 mut load_image: impl FnMut(&std::path::Path, usize) -> Result<String, String>,
1221) -> (String, String) {
1222 use crate::body::CopyLine;
1223
1224 const IMAGE_PREFIX: &str = r#"<div><img src=""#;
1225 const IMAGE_SUFFIX: &str = r#"" /></div>"#;
1226 let mut html = String::new();
1227 let mut plain = String::new();
1228 for (i, line) in lines.iter().enumerate() {
1229 if i > 0 {
1230 plain.push('\n');
1231 }
1232 match line {
1233 CopyLine::Text(text) => {
1234 plain.push_str(text);
1235 push_rich_fragment(
1236 &mut html,
1237 &format!("<div>{}</div>", escape_html(text)),
1238 rich_budget,
1239 );
1240 }
1241 CopyLine::Link(url) => {
1242 plain.push_str(url);
1243 let label = escape_html(url);
1244 let fragment = match crate::open::normalize_url(url) {
1245 Some(url) => {
1246 let href = escape_html(&url);
1247 format!("<div><a href=\"{href}\">{label}</a></div>")
1248 }
1249 None => format!("<div>{label}</div>"),
1250 };
1251 push_rich_fragment(&mut html, &fragment, rich_budget);
1252 }
1253 CopyLine::Image(path) => {
1254 let label = format!("[image: {}]", path.display());
1255 plain.push_str(&label);
1256 let url_budget = rich_budget
1257 .saturating_sub(html.len())
1258 .saturating_sub(IMAGE_PREFIX.len() + IMAGE_SUFFIX.len());
1259 let image = load_image(path, url_budget)
1260 .ok()
1261 .filter(|url| url.len() <= url_budget)
1262 .map(|url| format!("{IMAGE_PREFIX}{url}{IMAGE_SUFFIX}"));
1263 let fragment = image.unwrap_or_else(|| {
1264 format!("<div>{}</div>", escape_html(&label))
1267 });
1268 push_rich_fragment(&mut html, &fragment, rich_budget);
1269 }
1270 }
1271 }
1272 (plain, html)
1273}
1274
1275fn push_rich_fragment(html: &mut String, fragment: &str, budget: usize) {
1276 if html.len().saturating_add(fragment.len()) <= budget {
1277 html.push_str(fragment);
1278 }
1279}
1280
1281fn image_data_url(path: &std::path::Path, url_budget: usize) -> Result<String, String> {
1282 use base64::Engine;
1283 use image::ImageEncoder;
1284 use std::io::Write;
1285
1286 const PREFIX: &str = "data:image/png;base64,";
1287 let encoded_budget = url_budget
1288 .checked_sub(PREFIX.len())
1289 .ok_or_else(|| "rich clipboard image budget is exhausted".to_string())?;
1290 let png_budget = (encoded_budget / 4) * 3;
1293 if png_budget == 0 {
1294 return Err("rich clipboard image budget is exhausted".to_string());
1295 }
1296
1297 struct BoundedPng {
1298 bytes: Vec<u8>,
1299 limit: usize,
1300 }
1301
1302 impl Write for BoundedPng {
1303 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
1304 if self.bytes.len().saturating_add(buf.len()) > self.limit {
1305 return Err(std::io::Error::other(
1306 "encoded image exceeds rich clipboard budget",
1307 ));
1308 }
1309 self.bytes.extend_from_slice(buf);
1310 Ok(buf.len())
1311 }
1312
1313 fn flush(&mut self) -> std::io::Result<()> {
1314 Ok(())
1315 }
1316 }
1317
1318 let rgba = crate::image::load_dynamic(path)?.into_rgba8();
1319 let (width, height) = rgba.dimensions();
1320 let mut png = BoundedPng {
1321 bytes: Vec::new(),
1322 limit: png_budget,
1323 };
1324 image::codecs::png::PngEncoder::new(&mut png)
1325 .write_image(
1326 rgba.as_raw(),
1327 width,
1328 height,
1329 image::ExtendedColorType::Rgba8,
1330 )
1331 .map_err(|e| format!("{}: {e}", path.display()))?;
1332 let b64 = base64::engine::general_purpose::STANDARD.encode(png.bytes);
1333 let url = format!("{PREFIX}{b64}");
1334 if url.len() > url_budget {
1335 return Err("encoded image exceeds rich clipboard budget".to_string());
1336 }
1337 Ok(url)
1338}
1339
1340fn escape_html(s: &str) -> String {
1341 let mut out = String::with_capacity(s.len());
1342 for c in s.chars() {
1343 match c {
1344 '&' => out.push_str("&"),
1345 '<' => out.push_str("<"),
1346 '>' => out.push_str(">"),
1347 '"' => out.push_str("""),
1348 _ => out.push(c),
1349 }
1350 }
1351 out
1352}
1353
1354fn handle_settings_key(app: &mut App, key: KeyEvent) {
1357 match key.code {
1358 KeyCode::Esc => app.mode = Mode::Normal,
1359 KeyCode::Up => {
1360 app.settings_index = app.settings_index.saturating_sub(1);
1361 }
1362 KeyCode::Down => {
1363 app.settings_index = (app.settings_index + 1).min(crate::app::SETTINGS_ITEMS.len() - 1);
1364 }
1365 KeyCode::Right | KeyCode::Tab => app.cycle_setting(app.settings_index, 1),
1366 KeyCode::Left | KeyCode::BackTab => app.cycle_setting(app.settings_index, -1),
1367 _ => {}
1368 }
1369}
1370
1371fn request_close_task_form(app: &mut App) -> bool {
1374 let Some(form) = app.form.as_ref() else {
1375 return true;
1376 };
1377 if !form.is_dirty() {
1378 app.close_form();
1379 return true;
1380 }
1381 let confirm = Confirm::DiscardTask(form.editing.clone());
1382 if app.awaiting(confirm.clone()) {
1383 app.close_form();
1384 true
1385 } else {
1386 app.ask_confirm(confirm, "Unsaved changes · press Esc again to discard");
1387 false
1388 }
1389}
1390
1391fn request_close_category_form(app: &mut App) -> bool {
1392 let Some(form) = app.category_form.as_ref() else {
1393 return true;
1394 };
1395 if !form.is_dirty() {
1396 app.close_category_form();
1397 return true;
1398 }
1399 let confirm = Confirm::DiscardCategory(form.editing.clone());
1400 if app.awaiting(confirm.clone()) {
1401 app.close_category_form();
1402 true
1403 } else {
1404 app.ask_confirm(confirm, "Unsaved changes · press Esc again to discard");
1405 false
1406 }
1407}
1408
1409fn handle_mouse(app: &mut App, m: MouseEvent) {
1412 app.cancel_pending();
1413 if app.mode == Mode::Slash {
1414 handle_slash_mouse(app, m);
1415 return;
1416 }
1417 if app.mode == Mode::TaskForm {
1418 if app.form.as_ref().is_some_and(|form| form.preview) {
1421 handle_form_mouse(app, m);
1422 return;
1423 }
1424 if click_on_panels(app, m) {
1427 if !request_close_task_form(app) {
1428 return;
1429 }
1430 } else {
1431 handle_form_mouse(app, m);
1432 return;
1433 }
1434 }
1435 if app.mode == Mode::CategoryForm {
1436 if click_on_panels(app, m) {
1437 if !request_close_category_form(app) {
1438 return;
1439 }
1440 } else {
1441 if let (MouseEventKind::Down(MouseButton::Left), Some(form)) =
1442 (m.kind, &mut app.category_form)
1443 {
1444 if contains(form.name_area, m.column, m.row) {
1445 form.set_description_focus(false);
1446 form.name
1447 .set_cursor_from_col((m.column - form.name_area.x) as usize);
1448 } else if contains(form.description_area, m.column, m.row) {
1449 form.set_description_focus(true);
1450 form.description.click(
1451 m.row - form.description_area.y,
1452 (m.column - form.description_area.x) as usize,
1453 );
1454 }
1455 }
1456 return;
1457 }
1458 }
1459 if app.mode.is_overlay() {
1460 return;
1461 }
1462 match m.kind {
1463 MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => {
1467 let delta = if m.kind == MouseEventKind::ScrollUp {
1468 -1
1469 } else {
1470 1
1471 };
1472 if contains(app.areas.tasks, m.column, m.row) {
1473 app.move_task_selection(delta);
1474 } else if contains(app.areas.sidebar, m.column, m.row) && !app.searching {
1475 app.move_category_selection(delta);
1478 }
1479 }
1480 MouseEventKind::Down(MouseButton::Left) => {
1481 let (x, y) = (m.column, m.row);
1482 let sidebar = app.areas.sidebar;
1483 let tasks = app.areas.tasks;
1484 if contains(sidebar, x, y) {
1485 if app.searching {
1486 return;
1487 }
1488 let _ = app.set_focus(Focus::Sidebar);
1489 let row = app.cat_state.offset() + (y - sidebar.y) as usize;
1490 if row >= app.categories.len() {
1491 return;
1492 }
1493 app.select_category(row);
1494 if clicked_again(app, Focus::Sidebar, row) {
1495 app.open_edit_category();
1496 }
1497 } else if contains(tasks, x, y) {
1498 let _ = app.set_focus(Focus::Tasks);
1499 let visual = app.task_state.offset() + (y - tasks.y) as usize;
1500 let Some(row) = app.task_at_visual_row(visual) else {
1501 return;
1503 };
1504 let on_flags = app.areas.flag_x.is_some_and(|at| x >= at);
1508 let on_done = app
1509 .areas
1510 .done_x
1511 .is_some_and(|at| x >= at && x < at + crate::ui::DONE_MARK_WIDTH);
1512 if on_flags {
1513 app.cycle_importance(row);
1514 } else if on_done {
1515 app.toggle_done(row);
1516 } else {
1517 app.select_task(row);
1520 if clicked_again(app, Focus::Tasks, row) {
1521 app.open_edit_task();
1522 }
1523 }
1524 } else if contains(app.areas.preview, x, y) && app.selected_task().is_some() {
1525 let _ = app.set_focus(Focus::Tasks);
1527 app.open_edit_task();
1528 }
1529 }
1530 _ => {}
1531 }
1532}
1533
1534fn handle_slash_mouse(app: &mut App, mouse: MouseEvent) {
1535 let rect = app.areas.slash_menu;
1536 match mouse.kind {
1537 MouseEventKind::ScrollUp | MouseEventKind::ScrollDown
1538 if contains(rect, mouse.column, mouse.row) =>
1539 {
1540 let count = crate::slash::matching(&app.input.value()).len();
1541 if count == 0 {
1542 return;
1543 }
1544 if mouse.kind == MouseEventKind::ScrollUp {
1545 app.slash_index = (app.slash_index + count - 1) % count;
1546 } else {
1547 app.slash_index = (app.slash_index + 1) % count;
1548 }
1549 }
1550 MouseEventKind::Down(MouseButton::Left) => {
1551 if contains(rect, mouse.column, mouse.row)
1552 && mouse.row > rect.y
1553 && mouse.row + 1 < rect.bottom()
1554 {
1555 let row = (mouse.row - rect.y - 1) as usize;
1556 let query = app.input.value();
1557 let commands = crate::slash::matching(&query);
1558 if let Some(command) = commands.get(row).copied() {
1559 app.slash_index = row;
1560 close_slash(app);
1561 run_slash(app, command, &query);
1562 }
1563 } else {
1564 close_slash(app);
1565 }
1566 }
1567 _ => {}
1568 }
1569}
1570
1571fn click_on_panels(app: &App, m: MouseEvent) -> bool {
1574 if m.kind != MouseEventKind::Down(MouseButton::Left) {
1575 return false;
1576 }
1577 let (x, y) = (m.column, m.row);
1578 if !contains(app.areas.sidebar, x, y) && !contains(app.areas.tasks, x, y) {
1579 return false;
1580 }
1581 if let Some(form) = &app.form {
1583 if form.areas.field_at(x, y).is_some() {
1584 return false;
1585 }
1586 if form.picker.as_ref().is_some_and(|p| p.contains(x, y)) {
1587 return false;
1588 }
1589 if form.body_menu_area.is_some_and(|r| contains(r, x, y)) {
1590 return false;
1591 }
1592 }
1593 if let Some(form) = &app.category_form
1594 && (contains(form.name_area, x, y) || contains(form.description_area, x, y))
1595 {
1596 return false;
1597 }
1598 true
1599}
1600
1601fn handle_form_mouse(app: &mut App, m: MouseEvent) {
1607 if matches!(
1609 m.kind,
1610 MouseEventKind::ScrollUp | MouseEventKind::ScrollDown
1611 ) && app.form.as_ref().is_some_and(|f| f.picker.is_some())
1612 {
1613 let up = matches!(m.kind, MouseEventKind::ScrollUp);
1614 if let Some(form) = &mut app.form
1615 && let Some(picker) = &mut form.picker
1616 {
1617 let _ = picker.scroll(m.column, m.row, up);
1618 }
1619 return;
1620 }
1621
1622 if matches!(
1624 m.kind,
1625 MouseEventKind::ScrollUp | MouseEventKind::ScrollDown
1626 ) && app.form.as_ref().is_some_and(|f| f.body.menu.is_some())
1627 {
1628 let up = matches!(m.kind, MouseEventKind::ScrollUp);
1629 if let Some(form) = &mut app.form {
1630 if up {
1631 form.body.menu_prev();
1632 } else {
1633 form.body.menu_next();
1634 }
1635 }
1636 return;
1637 }
1638
1639 if m.kind != MouseEventKind::Down(MouseButton::Left) {
1640 return;
1641 }
1642 if app.form.as_ref().is_some_and(|f| f.preview) {
1644 if let Some(form) = &mut app.form {
1645 form.preview_click();
1646 }
1647 return;
1648 }
1649
1650 if app.form.as_ref().is_some_and(|f| f.picker.is_some()) {
1652 let Some(form) = &mut app.form else { return };
1653 let handled = form
1654 .picker
1655 .as_mut()
1656 .is_some_and(|p| p.click(m.column, m.row));
1657 if handled {
1658 return;
1659 }
1660 if !form.areas.due.contains(ratatui::layout::Position {
1662 x: m.column,
1663 y: m.row,
1664 }) {
1665 form.picker = None;
1666 }
1667 }
1668
1669 if app.form.as_ref().is_some_and(|f| f.body.menu.is_some()) {
1673 match click_body_slash_menu(app, m.column, m.row) {
1674 MenuClick::Handled | MenuClick::CopyDone => return,
1675 MenuClick::Miss => {
1676 }
1679 }
1680 }
1681
1682 enum AfterClick {
1683 None,
1684 OpenUrl(String),
1685 PreviewErr(String),
1686 }
1687 let after = {
1688 let Some(form) = &mut app.form else { return };
1689 let Some(field) = form.areas.field_at(m.column, m.row) else {
1690 form.last_body_click = None;
1693 return;
1694 };
1695 form.set_field(field);
1697
1698 let area = form.areas.rect(field);
1699 let col = (m.column - area.x) as usize;
1700 let row = (m.row - area.y) as usize;
1701 match field {
1702 Field::Title => {
1703 form.title.set_cursor_from_col(col);
1704 form.last_body_click = None;
1705 AfterClick::None
1706 }
1707 Field::Due => {
1708 form.open_due_picker();
1709 form.last_body_click = None;
1710 AfterClick::None
1711 }
1712 Field::Category => {
1713 form.cycle_category(1);
1714 form.last_body_click = None;
1715 AfterClick::None
1716 }
1717 Field::Body => {
1718 let clicked_link = form.body.link_url_at_position(row as u16, col);
1721 let hit = form.body.click(row as u16, col);
1722 if !hit {
1723 form.last_body_click = None;
1724 AfterClick::None
1725 } else if let Some(url) = clicked_link {
1726 form.last_body_click = None;
1727 AfterClick::OpenUrl(url)
1728 } else if form.body.selected_image().is_some() {
1729 let line = form.body.cursor_line();
1733 if !form.image_hit_at(line, m.column, m.row) {
1734 form.body.abandon_image_selection();
1735 form.last_body_click = None;
1736 AfterClick::None
1737 } else {
1738 let now = Instant::now();
1739 let again = form.last_body_click.is_some_and(|(at, last)| {
1740 last == line && now.duration_since(at) < DOUBLE_CLICK
1741 });
1742 if again {
1743 form.last_body_click = None;
1744 match form.open_image_preview() {
1745 Some(err) => AfterClick::PreviewErr(err),
1746 None => AfterClick::None,
1747 }
1748 } else {
1749 form.last_body_click = Some((now, line));
1750 AfterClick::None
1751 }
1752 }
1753 } else {
1754 form.last_body_click = None;
1755 AfterClick::None
1756 }
1757 }
1758 Field::Importance => {
1759 form.cycle_importance();
1760 form.last_body_click = None;
1761 AfterClick::None
1762 }
1763 }
1764 };
1765 match after {
1766 AfterClick::None => {}
1767 AfterClick::OpenUrl(url) => match crate::open::open_url(&url) {
1768 Ok(()) => app.info(format!("Opened {url}")),
1769 Err(err) => app.error(err),
1770 },
1771 AfterClick::PreviewErr(err) => app.error(err),
1772 }
1773}
1774
1775enum MenuClick {
1776 Handled,
1778 CopyDone,
1780 Miss,
1782}
1783
1784fn click_body_slash_menu(app: &mut App, x: u16, y: u16) -> MenuClick {
1786 let Some(form) = app.form.as_ref() else {
1787 return MenuClick::Miss;
1788 };
1789 let Some(rect) = form.body_menu_area else {
1790 return MenuClick::Miss;
1791 };
1792 if !contains(rect, x, y) {
1793 return MenuClick::Miss;
1794 }
1795
1796 let commands = form.body.menu_commands();
1798 if commands.is_empty() {
1799 return MenuClick::Handled;
1800 }
1801 if y <= rect.y || y >= rect.bottom().saturating_sub(1) {
1802 return MenuClick::Handled;
1804 }
1805 let idx = (y - rect.y - 1) as usize;
1806 if idx >= commands.len() {
1807 return MenuClick::Handled;
1808 }
1809
1810 let command = commands[idx];
1811 let Some(form) = app.form.as_mut() else {
1812 return MenuClick::Miss;
1813 };
1814 if let Some(menu) = &mut form.body.menu {
1815 menu.index = idx;
1816 }
1817 form.before_edit(EditKind::Atomic);
1818 match form.body.apply(command) {
1819 Some(payload) => {
1820 finish_copy(app, payload);
1821 MenuClick::CopyDone
1822 }
1823 None => MenuClick::Handled,
1824 }
1825}
1826
1827fn clicked_again(app: &mut App, panel: Focus, row: usize) -> bool {
1830 let now = Instant::now();
1831 let again = app.last_click.is_some_and(|(at, last_panel, last_row)| {
1832 last_panel == panel && last_row == row && now.duration_since(at) < DOUBLE_CLICK
1833 });
1834 app.last_click = (!again).then_some((now, panel, row));
1835 again
1836}
1837
1838fn contains(area: ratatui::layout::Rect, x: u16, y: u16) -> bool {
1839 area.contains(ratatui::layout::Position { x, y })
1840}
1841
1842#[cfg(test)]
1843mod tests {
1844 use std::path::PathBuf;
1845
1846 use crate::body::CopyLine;
1847
1848 use super::{
1849 MAX_OSC52_ENCODED_BYTES, MAX_OSC52_RAW_BYTES, build_clipboard_payload_with, osc52_sequence,
1850 };
1851
1852 #[test]
1853 fn terminal_clipboard_fallback_preserves_utf8_text() {
1854 assert_eq!(osc52_sequence("买菜").unwrap(), "\u{1b}]52;c;5Lmw6I+c\u{7}");
1855 }
1856
1857 #[test]
1858 fn terminal_clipboard_rejects_oversized_raw_and_encoded_payloads() {
1859 let raw = osc52_sequence(&"x".repeat(MAX_OSC52_RAW_BYTES + 1)).unwrap_err();
1860 assert!(raw.contains("raw limit"), "{raw}");
1861
1862 let encoded_input = "x".repeat(62 * 1024);
1863 assert!(encoded_input.len() <= MAX_OSC52_RAW_BYTES);
1864 let encoded = osc52_sequence(&encoded_input).unwrap_err();
1865 assert!(encoded.contains("encoded limit"), "{encoded}");
1866 assert!(MAX_OSC52_ENCODED_BYTES < encoded_input.len() * 4 / 3 + 4);
1867 }
1868
1869 #[test]
1870 fn rich_clipboard_budget_replaces_an_oversized_image_but_keeps_plain_text() {
1871 let lines = vec![
1872 CopyLine::Text("before".into()),
1873 CopyLine::Image(PathBuf::from("huge.png")),
1874 CopyLine::Text("after".into()),
1875 ];
1876 let budget = 128;
1877 let (plain, html) = build_clipboard_payload_with(&lines, budget, |_, _| {
1878 Ok(format!("data:image/png;base64,{}", "A".repeat(256)))
1879 });
1880
1881 assert_eq!(plain, "before\n[image: huge.png]\nafter");
1882 assert!(html.contains("[image: huge.png]"), "{html}");
1883 assert!(!html.contains("<img"), "{html}");
1884 assert!(html.len() <= budget);
1885 }
1886
1887 #[test]
1888 fn rich_clipboard_only_links_to_approved_url_schemes() {
1889 let lines = vec![
1890 CopyLine::Link("example.com/?a=1&b=2".into()),
1891 CopyLine::Link("javascript:alert(1)".into()),
1892 ];
1893
1894 let (plain, html) = build_clipboard_payload_with(&lines, 1024, |_, _| unreachable!());
1895
1896 assert_eq!(plain, "example.com/?a=1&b=2\njavascript:alert(1)");
1897 assert!(
1898 html.contains("href=\"https://example.com/?a=1&b=2\""),
1899 "{html}"
1900 );
1901 assert_eq!(html.matches("<a ").count(), 1, "{html}");
1902 assert!(html.contains("<div>javascript:alert(1)</div>"), "{html}");
1903 }
1904}