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