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