1use ratatui::crossterm::event::KeyEvent;
23
24use crate::components::drawer::DrawerView;
25use crate::components::events::InputEvent;
26use crate::components::overlay::OverlayKind;
27use crate::components::panel::PanelKind;
28use crate::components::text_editor::EditorClaim;
29use crate::keys::action_shortcuts::{ActionShortcuts, TextAction};
30use crate::keys::key_strike::KeyStrike;
31use crate::keys::{KeyBindings, key_event_to_combo};
32
33#[derive(Debug, Clone, PartialEq)]
37pub struct InputCtx {
38 pub overlay: Option<OverlayKind>,
40 pub leader_pending: bool,
42 pub focused: PanelKind,
44 pub drawer_view: DrawerView,
46 pub space_leads: bool,
48 pub claim: EditorClaim,
52 pub double_click: bool,
61 pub overlay_sortable: bool,
66 pub overlay_query: bool,
69 pub pointer_on_editor: bool,
74}
75
76impl InputCtx {
77 fn editor_active(&self) -> bool {
80 self.focused == PanelKind::Editor && self.overlay.is_none()
81 }
82
83 fn find_panel_focused(&self) -> bool {
84 self.focused == PanelKind::Drawer && self.drawer_view == DrawerView::Find
85 }
86
87 pub(crate) fn accepts_text_action(&self) -> bool {
92 self.editor_active() && self.claim == EditorClaim::None
93 }
94}
95
96#[derive(Debug, Clone, PartialEq)]
99pub struct Classification {
100 pub flash: Option<String>,
103 pub cancel_leader: bool,
106 pub intent: EditorIntent,
107}
108
109#[derive(Debug, Clone, PartialEq)]
113pub enum EditorIntent {
114 Consume,
116 EditorPaste,
121 ImageProbe,
128 FollowLink,
133 LeaderKey(KeyEvent),
135 LeaderStart,
137 Op(EditorOp),
141 ToggleOverlay {
143 kind: OverlayKind,
144 open: OverlayOpen,
145 },
146 OpenOverlay(OverlayOpen),
149 Overlay,
151 Mouse,
153 Panel { fallback: PanelFallback },
156}
157
158#[derive(Debug, Clone, Copy, PartialEq)]
164pub enum OverlayOpen {
165 SearchBrowser,
166 FileFinder,
167 SavedSearches,
168 CommandPalette,
169 WorkspaceSwitcher,
170 ThemePicker,
171 Help,
173 QueryHelp,
175 Cheatsheet,
177 SortQuery,
178 SortSidebar,
179 SortBrowser,
183 QueryHelpOverBrowser,
186 QuickNote,
187 PinnedNotes,
189}
190
191#[derive(Debug, Clone, Copy, PartialEq)]
193pub enum PanelFallback {
194 None,
195 FocusCycle(CycleDir),
197 FocusEditor,
199}
200
201#[derive(Debug, Clone, Copy, PartialEq)]
202pub enum CycleDir {
203 Left,
204 Right,
205}
206
207#[derive(Debug, Clone, PartialEq)]
208pub enum EditorOp {
209 ToggleDrawer,
210 FocusLeft,
211 FocusRight,
212 OpenJournal,
213 ShowFileOps,
214 ToggleQueryPanel,
215 OpenFileBrowserReveal,
218 SaveCurrentQuery,
219 FindInBuffer,
220 ReplaceInBuffer,
222 ApplyText(TextAction),
223 OpenAsk,
226}
227
228pub fn classify(event: &InputEvent, bindings: &KeyBindings, ctx: &InputCtx) -> Classification {
236 let mut classification = classify_unclaimed(event, bindings, ctx);
237 let off_editor = matches!(event, InputEvent::Mouse(_)) && !ctx.pointer_on_editor;
243 let claim = if ctx.editor_active() && !off_editor {
244 ctx.claim
245 } else {
246 EditorClaim::None
247 };
248 let filtered = apply_claim(classification.intent.clone(), event, claim);
249 if filtered != classification.intent {
250 classification.flash = None;
252 }
253 classification.intent = filtered;
254 classification
255}
256
257fn apply_claim(intent: EditorIntent, event: &InputEvent, claim: EditorClaim) -> EditorIntent {
269 if claim == EditorClaim::None {
270 return intent;
271 }
272 let deliver = EditorIntent::Panel {
273 fallback: PanelFallback::None,
274 };
275 let find_bar = claim == EditorClaim::FindBar;
276 match intent {
277 EditorIntent::LeaderKey(_)
281 | EditorIntent::Panel { .. }
282 | EditorIntent::Overlay
283 | EditorIntent::ToggleOverlay { .. }
284 | EditorIntent::OpenOverlay(_)
285 | EditorIntent::Consume => intent,
286
287 EditorIntent::Op(EditorOp::ApplyText(_)) => deliver,
290 EditorIntent::Op(_) => intent,
291
292 EditorIntent::EditorPaste | EditorIntent::ImageProbe => {
295 if find_bar {
296 deliver
297 } else {
298 intent
299 }
300 }
301
302 EditorIntent::FollowLink => match event {
310 InputEvent::Mouse(_) => claimed_mouse(event, find_bar),
311 _ if find_bar => deliver,
312 _ => intent,
313 },
314
315 EditorIntent::LeaderStart => match event {
319 InputEvent::Key(k)
320 if find_bar
321 && k.code == ratatui::crossterm::event::KeyCode::Char(' ')
322 && k.modifiers.is_empty() =>
323 {
324 deliver
325 }
326 _ => intent,
327 },
328
329 EditorIntent::Mouse => claimed_mouse(event, find_bar),
330 }
331}
332
333fn claimed_mouse(event: &InputEvent, find_bar: bool) -> EditorIntent {
341 use ratatui::crossterm::event::MouseEventKind;
342
343 match event {
344 InputEvent::Mouse(m)
345 if find_bar
346 && !matches!(
347 m.kind,
348 MouseEventKind::ScrollUp
349 | MouseEventKind::ScrollDown
350 | MouseEventKind::ScrollLeft
351 | MouseEventKind::ScrollRight
352 ) =>
353 {
354 EditorIntent::Consume
355 }
356 _ => EditorIntent::Mouse,
357 }
358}
359
360fn classify_unclaimed(
361 event: &InputEvent,
362 bindings: &KeyBindings,
363 ctx: &InputCtx,
364) -> Classification {
365 use ratatui::crossterm::event::{KeyCode, KeyModifiers};
366
367 let mut cancel_leader = false;
373 if ctx.leader_pending {
374 match event {
375 InputEvent::Key(key) => {
376 if ctx.overlay.is_some() {
377 cancel_leader = true;
378 } else if matches!(key.code, KeyCode::Char(_))
379 && key.modifiers.contains(KeyModifiers::CONTROL)
380 {
381 cancel_leader = true;
382 } else {
384 return Classification {
385 flash: None,
386 cancel_leader: false,
387 intent: EditorIntent::LeaderKey(*key),
388 };
389 }
390 }
391 InputEvent::Paste(_) => cancel_leader = true,
392 InputEvent::Mouse(_) => {}
394 }
395 }
396
397 if ctx.editor_active() && matches!(event, InputEvent::Paste(_)) {
400 return Classification {
401 flash: None,
402 cancel_leader,
403 intent: EditorIntent::EditorPaste,
404 };
405 }
406
407 if ctx.editor_active()
412 && let InputEvent::Key(key) = event
413 && key.modifiers == KeyModifiers::CONTROL
414 && key.code == KeyCode::Char('v')
415 {
416 return Classification {
417 flash: None,
418 cancel_leader,
419 intent: EditorIntent::ImageProbe,
420 };
421 }
422
423 classify_tail(event, bindings, ctx, cancel_leader)
424}
425
426pub(crate) fn classify_tail(
433 event: &InputEvent,
434 bindings: &KeyBindings,
435 ctx: &InputCtx,
436 cancel_leader: bool,
437) -> Classification {
438 use ratatui::crossterm::event::{KeyCode, KeyModifiers};
439
440 if ctx.editor_active()
443 && let InputEvent::Key(key) = event
444 && key.code == KeyCode::Enter
445 && key.modifiers.contains(KeyModifiers::CONTROL)
446 {
447 return Classification {
448 flash: None,
449 cancel_leader,
450 intent: EditorIntent::FollowLink,
451 };
452 }
453
454 let mut flash = None;
459 let mut shortcut_intent = None;
460 if let InputEvent::Key(key) = event
461 && let Some(combo) = key_event_to_combo(key)
462 {
463 let is_fkey = combo.key.is_fkey();
464 let action = bindings.get_action(&combo);
465 if action != Some(ActionShortcuts::Leader) && (is_fkey || combo.is_letter_chord()) {
478 flash = Some(match &action {
479 Some(a) => a.label(),
480 None => combo.to_string(),
481 });
482 }
483 shortcut_intent = match action {
484 Some(ActionShortcuts::OpenCommandPalette) => Some(EditorIntent::ToggleOverlay {
485 kind: OverlayKind::CommandPalette,
486 open: OverlayOpen::CommandPalette,
487 }),
488 Some(ActionShortcuts::Leader) => {
489 Some(if ctx.overlay.is_none() {
492 EditorIntent::LeaderStart
493 } else {
494 EditorIntent::Consume
495 })
496 }
497 Some(ActionShortcuts::ToggleSidebar) => Some(EditorIntent::Op(EditorOp::ToggleDrawer)),
498 Some(ActionShortcuts::FocusSidebar) => {
499 Some(if ctx.overlay.is_none() {
501 EditorIntent::Op(EditorOp::FocusLeft)
502 } else {
503 EditorIntent::Consume
504 })
505 }
506 Some(ActionShortcuts::FocusEditor) => Some(if ctx.overlay.is_none() {
507 EditorIntent::Op(EditorOp::FocusRight)
508 } else {
509 EditorIntent::Consume
510 }),
511 Some(ActionShortcuts::NewJournal) => Some(EditorIntent::Op(EditorOp::OpenJournal)),
512 Some(ActionShortcuts::SearchNotes) => Some(EditorIntent::ToggleOverlay {
513 kind: OverlayKind::NoteBrowser,
514 open: OverlayOpen::SearchBrowser,
515 }),
516 Some(ActionShortcuts::OpenNote) => Some(EditorIntent::ToggleOverlay {
517 kind: OverlayKind::NoteBrowser,
518 open: OverlayOpen::FileFinder,
519 }),
520 Some(ActionShortcuts::FileOperations) if ctx.editor_active() => {
521 Some(EditorIntent::Op(EditorOp::ShowFileOps))
522 }
523 Some(ActionShortcuts::FollowLink) if ctx.editor_active() => {
524 Some(EditorIntent::FollowLink)
525 }
526 Some(ActionShortcuts::ToggleQueryPanel) => {
527 Some(EditorIntent::Op(EditorOp::ToggleQueryPanel))
528 }
529 Some(ActionShortcuts::OpenFileBrowser) => {
530 Some(EditorIntent::Op(EditorOp::OpenFileBrowserReveal))
534 }
535 Some(ActionShortcuts::OpenSavedSearches) => Some(EditorIntent::ToggleOverlay {
536 kind: OverlayKind::SavedSearches,
537 open: OverlayOpen::SavedSearches,
538 }),
539 Some(ActionShortcuts::OpenAsk) => Some(EditorIntent::Op(EditorOp::OpenAsk)),
540 Some(ActionShortcuts::YankRow) => None,
547 Some(ActionShortcuts::OpenSortDialog) => {
548 if ctx.overlay == Some(OverlayKind::NoteBrowser) && ctx.overlay_sortable {
554 Some(EditorIntent::OpenOverlay(OverlayOpen::SortBrowser))
555 } else if ctx.focused == PanelKind::Drawer && ctx.overlay.is_none() {
556 Some(match ctx.drawer_view {
557 DrawerView::Find => EditorIntent::OpenOverlay(OverlayOpen::SortQuery),
558 DrawerView::Files => EditorIntent::OpenOverlay(OverlayOpen::SortSidebar),
559 _ => EditorIntent::Consume,
560 })
561 } else {
562 None
563 }
564 }
565 Some(ActionShortcuts::SaveCurrentQuery) => {
566 Some(EditorIntent::Op(EditorOp::SaveCurrentQuery))
569 }
570 Some(ActionShortcuts::SwitchWorkspace) => {
571 Some(EditorIntent::OpenOverlay(OverlayOpen::WorkspaceSwitcher))
572 }
573 Some(ActionShortcuts::QuickNote) => Some(if ctx.overlay.is_none() {
574 EditorIntent::OpenOverlay(OverlayOpen::QuickNote)
575 } else {
576 EditorIntent::Consume
577 }),
578 Some(ActionShortcuts::FindInBuffer) if ctx.editor_active() => {
579 Some(EditorIntent::Op(EditorOp::FindInBuffer))
580 }
581 Some(ActionShortcuts::ReplaceInBuffer) if ctx.editor_active() => {
582 Some(EditorIntent::Op(EditorOp::ReplaceInBuffer))
583 }
584 Some(ActionShortcuts::Text(
585 action @ (TextAction::Bold | TextAction::Italic | TextAction::Strikethrough),
586 )) if ctx.editor_active() => Some(EditorIntent::Op(EditorOp::ApplyText(action))),
587 _ => {
588 if is_fkey {
589 if combo.key == KeyStrike::F1 && combo.modifiers.is_empty() && ctx.overlay_query
593 {
594 Some(EditorIntent::OpenOverlay(OverlayOpen::QueryHelpOverBrowser))
596 } else if combo.key == KeyStrike::F1 && combo.modifiers.is_empty() {
597 Some(EditorIntent::OpenOverlay(if ctx.find_panel_focused() {
598 OverlayOpen::QueryHelp
599 } else {
600 OverlayOpen::Help
601 }))
602 } else {
603 Some(EditorIntent::Consume)
604 }
605 } else {
606 None
607 }
608 }
609 };
610 }
611
612 let done = move |intent| Classification {
615 flash,
616 cancel_leader,
617 intent,
618 };
619
620 if let Some(intent) = shortcut_intent {
621 return done(intent);
622 }
623
624 if ctx.overlay.is_some() {
626 return done(EditorIntent::Overlay);
627 }
628
629 if matches!(event, InputEvent::Mouse(_)) {
630 if ctx.double_click && ctx.editor_active() {
636 return done(EditorIntent::FollowLink);
637 }
638 return done(EditorIntent::Mouse);
639 }
640
641 if ctx.editor_active()
646 && (!ctx.leader_pending || cancel_leader)
647 && let InputEvent::Key(key) = event
648 && key.code == KeyCode::Char(' ')
649 && key.modifiers.is_empty()
650 && ctx.space_leads
651 {
652 return done(EditorIntent::LeaderStart);
653 }
654
655 if ctx.focused != PanelKind::Editor
659 && let InputEvent::Key(key) = event
660 && matches!(key.code, KeyCode::Tab | KeyCode::BackTab)
661 {
662 return done(EditorIntent::Panel {
663 fallback: PanelFallback::FocusCycle(if key.code == KeyCode::Tab {
664 CycleDir::Right
665 } else {
666 CycleDir::Left
667 }),
668 });
669 }
670
671 if ctx.find_panel_focused()
674 && let InputEvent::Key(key) = event
675 && key.code == KeyCode::Esc
676 {
677 return done(EditorIntent::Panel {
678 fallback: PanelFallback::FocusEditor,
679 });
680 }
681
682 done(EditorIntent::Panel {
683 fallback: PanelFallback::None,
684 })
685}
686
687#[cfg(test)]
688mod tests {
689 use super::*;
690 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind};
691
692 fn bindings() -> KeyBindings {
693 let mut kb = KeyBindings::empty();
694 kb.batch_add()
695 .with_ctrl()
696 .add(KeyStrike::KeyP, ActionShortcuts::OpenCommandPalette)
697 .add(KeyStrike::KeyK, ActionShortcuts::SearchNotes)
698 .add(KeyStrike::KeyG, ActionShortcuts::Leader)
699 .add(KeyStrike::KeyT, ActionShortcuts::ToggleSidebar)
700 .add(KeyStrike::KeyR, ActionShortcuts::OpenSortDialog)
701 .add(KeyStrike::KeyH, ActionShortcuts::FocusSidebar)
702 .add(KeyStrike::KeyB, ActionShortcuts::Text(TextAction::Bold))
703 .add(KeyStrike::KeyN, ActionShortcuts::FollowLink)
704 .add(KeyStrike::KeyW, ActionShortcuts::QuickNote);
705 kb.batch_add()
706 .add(KeyStrike::F2, ActionShortcuts::FileOperations);
707 kb
708 }
709
710 fn ctx() -> InputCtx {
712 InputCtx {
713 overlay: None,
714 leader_pending: false,
715 focused: PanelKind::Editor,
716 drawer_view: DrawerView::Files,
717 space_leads: false,
718 claim: EditorClaim::None,
719 double_click: false,
720 overlay_sortable: false,
721 overlay_query: false,
722 pointer_on_editor: true,
723 }
724 }
725
726 fn ctx_find_bar() -> InputCtx {
728 InputCtx {
729 claim: EditorClaim::FindBar,
730 ..ctx()
731 }
732 }
733
734 fn ctx_double() -> InputCtx {
737 InputCtx {
738 double_click: true,
739 ..ctx()
740 }
741 }
742
743 fn press() -> InputEvent {
747 use ratatui::crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
748 InputEvent::Mouse(MouseEvent {
749 kind: MouseEventKind::Down(MouseButton::Left),
750 column: 1,
751 row: 1,
752 modifiers: KeyModifiers::NONE,
753 })
754 }
755
756 fn key(code: KeyCode, mods: KeyModifiers) -> InputEvent {
757 InputEvent::Key(KeyEvent::new(code, mods))
758 }
759
760 fn ctrl(c: char) -> InputEvent {
761 key(KeyCode::Char(c), KeyModifiers::CONTROL)
762 }
763
764 fn plain(c: char) -> InputEvent {
765 key(KeyCode::Char(c), KeyModifiers::NONE)
766 }
767
768 fn classify_it(event: &InputEvent, ctx: &InputCtx) -> Classification {
769 classify(event, &bindings(), ctx)
770 }
771
772 #[test]
779 fn a_claim_blocks_a_buffer_edit() {
780 let ev = key(KeyCode::Char('b'), KeyModifiers::CONTROL);
781 assert_eq!(
782 classify_it(&ev, &ctx()).intent,
783 EditorIntent::Op(EditorOp::ApplyText(TextAction::Bold)),
784 "without a claim the chord still bolds"
785 );
786 assert_eq!(
787 classify_it(&ev, &ctx_find_bar()).intent,
788 EditorIntent::Panel {
789 fallback: PanelFallback::None
790 },
791 "under a claim it is delivered to the holder instead"
792 );
793 }
794
795 #[test]
798 fn a_claim_leaves_navigation_alone() {
799 for ev in [
800 key(KeyCode::Char('t'), KeyModifiers::CONTROL),
801 key(KeyCode::Char('p'), KeyModifiers::CONTROL),
802 ] {
803 assert_eq!(
804 classify_it(&ev, &ctx_find_bar()).intent,
805 classify_it(&ev, &ctx()).intent,
806 "a claim must not swallow navigation"
807 );
808 }
809 }
810
811 #[test]
814 fn a_find_bar_claim_blocks_the_paste_tiers() {
815 for ev in [
816 InputEvent::Paste("hi".into()),
817 key(KeyCode::Char('v'), KeyModifiers::CONTROL),
818 key(KeyCode::Enter, KeyModifiers::CONTROL),
819 ] {
820 assert_eq!(
821 classify_it(&ev, &ctx_find_bar()).intent,
822 EditorIntent::Panel {
823 fallback: PanelFallback::None
824 },
825 "paste, image probe and follow-link all belong to the bar"
826 );
827 }
828 }
829
830 #[test]
834 fn a_find_bar_claim_blocks_the_space_leader() {
835 let ev = key(KeyCode::Char(' '), KeyModifiers::NONE);
836 let vim_ctx = InputCtx {
837 space_leads: true,
838 ..ctx()
839 };
840 assert_eq!(classify_it(&ev, &vim_ctx).intent, EditorIntent::LeaderStart);
841 let vim_bar = InputCtx {
842 space_leads: true,
843 ..ctx_find_bar()
844 };
845 assert_eq!(
846 classify_it(&ev, &vim_bar).intent,
847 EditorIntent::Panel {
848 fallback: PanelFallback::None
849 }
850 );
851 }
852
853 #[test]
857 fn a_find_bar_claim_blocks_clicks_but_not_scrolling() {
858 use ratatui::crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
859 let at = |kind| {
860 InputEvent::Mouse(MouseEvent {
861 kind,
862 column: 1,
863 row: 1,
864 modifiers: KeyModifiers::NONE,
865 })
866 };
867 assert_eq!(
868 classify_it(
869 &at(MouseEventKind::Down(MouseButton::Left)),
870 &ctx_find_bar()
871 )
872 .intent,
873 EditorIntent::Consume,
874 "a click reaches nothing while the bar holds the claim"
875 );
876 assert_eq!(
877 classify_it(&at(MouseEventKind::ScrollUp), &ctx_find_bar()).intent,
878 EditorIntent::Mouse,
879 "scrolling still works"
880 );
881 }
882
883 #[test]
888 fn a_find_bar_claim_does_not_own_presses_off_the_editor() {
889 let off = InputCtx {
890 pointer_on_editor: false,
891 ..ctx_find_bar()
892 };
893 assert_eq!(classify_it(&press(), &off).intent, EditorIntent::Mouse);
894 assert_eq!(
895 classify_it(&key(KeyCode::Enter, KeyModifiers::CONTROL), &off).intent,
896 EditorIntent::Panel {
897 fallback: PanelFallback::None
898 },
899 "keys still belong to the bar"
900 );
901 }
902
903 #[test]
909 fn a_single_press_only_reaches_the_editor() {
910 assert_eq!(classify_it(&press(), &ctx()).intent, EditorIntent::Mouse);
911 }
912
913 #[test]
916 fn a_double_click_follows_the_link_under_it() {
917 assert_eq!(
918 classify_it(&press(), &ctx_double()).intent,
919 EditorIntent::FollowLink,
920 "the same intent Ctrl+N produces"
921 );
922 assert_eq!(
923 classify_it(&key(KeyCode::Char('n'), KeyModifiers::CONTROL), &ctx()).intent,
924 EditorIntent::FollowLink,
925 "and Ctrl+N still produces it"
926 );
927 }
928
929 #[test]
933 fn a_double_click_outside_the_editor_is_an_ordinary_click() {
934 let drawer = InputCtx {
935 focused: PanelKind::Drawer,
936 ..ctx_double()
937 };
938 assert_eq!(classify_it(&press(), &drawer).intent, EditorIntent::Mouse);
939 }
940
941 #[test]
944 fn an_overlay_outranks_a_double_click() {
945 let covered = InputCtx {
946 overlay: Some(OverlayKind::NoteBrowser),
947 ..ctx_double()
948 };
949 assert_eq!(
950 classify_it(&press(), &covered).intent,
951 EditorIntent::Overlay
952 );
953 }
954
955 #[test]
959 fn a_find_bar_claim_swallows_a_double_click_but_takes_ctrl_enter() {
960 let bar_double = InputCtx {
961 double_click: true,
962 ..ctx_find_bar()
963 };
964 assert_eq!(
965 classify_it(&press(), &bar_double).intent,
966 EditorIntent::Consume,
967 "a follow that arrived as a click is still a click"
968 );
969 assert_eq!(
970 classify_it(&key(KeyCode::Enter, KeyModifiers::CONTROL), &ctx_find_bar()).intent,
971 EditorIntent::Panel {
972 fallback: PanelFallback::None
973 },
974 "but a follow that arrived as a key is delivered to the bar"
975 );
976 }
977
978 #[test]
984 fn an_autocomplete_claim_turns_a_double_click_back_into_a_press() {
985 let popup = InputCtx {
986 claim: EditorClaim::Autocomplete,
987 double_click: true,
988 ..ctx()
989 };
990 assert_eq!(classify_it(&press(), &popup).intent, EditorIntent::Mouse);
991 assert_eq!(
992 classify_it(&key(KeyCode::Char('n'), KeyModifiers::CONTROL), &popup).intent,
993 EditorIntent::FollowLink,
994 "the popup does not own the keyboard follow"
995 );
996 }
997
998 #[test]
1002 fn an_autocomplete_claim_blocks_only_buffer_edits() {
1003 let popup = InputCtx {
1004 claim: EditorClaim::Autocomplete,
1005 ..ctx()
1006 };
1007 assert_eq!(
1008 classify_it(&InputEvent::Paste("hi".into()), &popup).intent,
1009 EditorIntent::EditorPaste,
1010 "the popup does not own pastes"
1011 );
1012 assert_eq!(
1013 classify_it(&key(KeyCode::Char('b'), KeyModifiers::CONTROL), &popup).intent,
1014 EditorIntent::Panel {
1015 fallback: PanelFallback::None
1016 },
1017 "but a buffer edit still loses to any holder"
1018 );
1019 }
1020
1021 #[test]
1023 fn the_leader_outranks_a_claim() {
1024 let ev = key(KeyCode::Char('x'), KeyModifiers::NONE);
1025 let pending = InputCtx {
1026 leader_pending: true,
1027 ..ctx_find_bar()
1028 };
1029 assert!(matches!(
1030 classify_it(&ev, &pending).intent,
1031 EditorIntent::LeaderKey(_)
1032 ));
1033 }
1034
1035 #[test]
1038 fn bracketed_paste_in_editor_is_editor_paste() {
1039 let c = classify_it(&InputEvent::Paste("hi".into()), &ctx());
1040 assert_eq!(c.intent, EditorIntent::EditorPaste);
1041 }
1042
1043 #[test]
1044 fn bracketed_paste_with_overlay_routes_to_overlay() {
1045 let mut cx = ctx();
1046 cx.overlay = Some(OverlayKind::NoteBrowser);
1047 let c = classify_it(&InputEvent::Paste("hi".into()), &cx);
1048 assert_eq!(c.intent, EditorIntent::Overlay);
1049 }
1050
1051 #[test]
1052 fn bracketed_paste_drawer_focused_goes_to_panel() {
1053 let mut cx = ctx();
1054 cx.focused = PanelKind::Drawer;
1055 let c = classify_it(&InputEvent::Paste("hi".into()), &cx);
1056 assert_eq!(
1057 c.intent,
1058 EditorIntent::Panel {
1059 fallback: PanelFallback::None
1060 }
1061 );
1062 }
1063
1064 #[test]
1065 fn ctrl_v_in_editor_is_a_bare_image_probe() {
1066 let c = classify_it(&ctrl('v'), &ctx());
1069 assert_eq!(c.intent, EditorIntent::ImageProbe);
1070 assert_eq!(c.flash, None, "flash belongs to the no-image tail only");
1071 }
1072
1073 #[test]
1074 fn ctrl_v_no_image_tail_reaches_panel_with_a_flash() {
1075 let c = classify_tail(&ctrl('v'), &bindings(), &ctx(), false);
1080 assert_eq!(
1081 c.intent,
1082 EditorIntent::Panel {
1083 fallback: PanelFallback::None
1084 }
1085 );
1086 assert!(c.flash.is_some());
1087 assert!(!c.cancel_leader, "the outer probe already owned the cancel");
1088 }
1089
1090 #[test]
1091 fn leader_pending_ctrl_v_cancels_leader_then_probes_image() {
1092 let mut cx = ctx();
1098 cx.leader_pending = true;
1099 let c = classify_it(&ctrl('v'), &cx);
1100 assert!(c.cancel_leader);
1101 assert_eq!(c.intent, EditorIntent::ImageProbe);
1102 }
1103
1104 #[test]
1105 fn leader_pending_bracketed_paste_cancels_leader_then_pastes() {
1106 let mut cx = ctx();
1109 cx.leader_pending = true;
1110 let c = classify_it(&InputEvent::Paste("hi".into()), &cx);
1111 assert!(c.cancel_leader);
1112 assert_eq!(c.intent, EditorIntent::EditorPaste);
1113 }
1114
1115 #[test]
1116 fn leader_pending_ctrl_enter_feeds_leader() {
1117 let mut cx = ctx();
1120 cx.leader_pending = true;
1121 let c = classify_it(&key(KeyCode::Enter, KeyModifiers::CONTROL), &cx);
1122 assert_eq!(
1123 c.intent,
1124 EditorIntent::LeaderKey(KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL))
1125 );
1126 assert!(!c.cancel_leader);
1127 }
1128
1129 #[test]
1130 fn ctrl_enter_in_editor_follows_link() {
1131 let c = classify_it(&key(KeyCode::Enter, KeyModifiers::CONTROL), &ctx());
1132 assert_eq!(c.intent, EditorIntent::FollowLink);
1133 }
1134
1135 #[test]
1138 fn leader_pending_plain_key_feeds_leader() {
1139 let mut cx = ctx();
1140 cx.leader_pending = true;
1141 let c = classify_it(&plain('f'), &cx);
1142 assert_eq!(
1143 c.intent,
1144 EditorIntent::LeaderKey(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE))
1145 );
1146 assert!(!c.cancel_leader);
1147 }
1148
1149 #[test]
1150 fn leader_pending_with_overlay_cancels_and_routes_to_overlay() {
1151 let mut cx = ctx();
1152 cx.leader_pending = true;
1153 cx.overlay = Some(OverlayKind::Dialog);
1154 let c = classify_it(&plain('f'), &cx);
1155 assert!(c.cancel_leader);
1156 assert_eq!(c.intent, EditorIntent::Overlay);
1157 }
1158
1159 #[test]
1160 fn leader_pending_ctrl_chord_cancels_then_dispatches() {
1161 let mut cx = ctx();
1162 cx.leader_pending = true;
1163 let c = classify_it(&ctrl('p'), &cx);
1164 assert!(c.cancel_leader);
1165 assert_eq!(
1166 c.intent,
1167 EditorIntent::ToggleOverlay {
1168 kind: OverlayKind::CommandPalette,
1169 open: OverlayOpen::CommandPalette
1170 }
1171 );
1172 }
1173
1174 #[test]
1177 fn command_palette_chord_toggles_regardless_of_open_state() {
1178 let c = classify_it(&ctrl('p'), &ctx());
1181 assert_eq!(
1182 c.intent,
1183 EditorIntent::ToggleOverlay {
1184 kind: OverlayKind::CommandPalette,
1185 open: OverlayOpen::CommandPalette
1186 }
1187 );
1188 assert!(c.flash.is_some());
1189
1190 let mut cx = ctx();
1191 cx.overlay = Some(OverlayKind::CommandPalette);
1192 let c = classify_it(&ctrl('p'), &cx);
1193 assert_eq!(
1194 c.intent,
1195 EditorIntent::ToggleOverlay {
1196 kind: OverlayKind::CommandPalette,
1197 open: OverlayOpen::CommandPalette
1198 }
1199 );
1200 }
1201
1202 #[test]
1203 fn leader_gateway_starts_sequence_without_flash() {
1204 let c = classify_it(&ctrl('g'), &ctx());
1205 assert_eq!(c.intent, EditorIntent::LeaderStart);
1206 assert_eq!(c.flash, None);
1207 }
1208
1209 #[test]
1210 fn leader_gateway_with_overlay_is_consumed_noop() {
1211 let mut cx = ctx();
1212 cx.overlay = Some(OverlayKind::NoteBrowser);
1213 let c = classify_it(&ctrl('g'), &cx);
1214 assert_eq!(c.intent, EditorIntent::Consume);
1215 }
1216
1217 #[test]
1218 fn focus_sidebar_with_overlay_is_consumed_noop() {
1219 let mut cx = ctx();
1220 cx.overlay = Some(OverlayKind::NoteBrowser);
1221 let c = classify_it(&ctrl('h'), &cx);
1222 assert_eq!(c.intent, EditorIntent::Consume);
1223 }
1224
1225 #[test]
1226 fn toggle_drawer_chord_is_action() {
1227 let c = classify_it(&ctrl('t'), &ctx());
1228 assert_eq!(c.intent, EditorIntent::Op(EditorOp::ToggleDrawer));
1229 }
1230
1231 #[test]
1232 fn quick_note_opens_dialog_only_without_overlay() {
1233 let c = classify_it(&ctrl('w'), &ctx());
1234 assert_eq!(c.intent, EditorIntent::OpenOverlay(OverlayOpen::QuickNote));
1235
1236 let mut cx = ctx();
1237 cx.overlay = Some(OverlayKind::Dialog);
1238 let c = classify_it(&ctrl('w'), &cx);
1239 assert_eq!(c.intent, EditorIntent::Consume);
1240 }
1241
1242 #[test]
1243 fn text_style_chord_needs_active_editor() {
1244 let c = classify_it(&ctrl('b'), &ctx());
1245 assert_eq!(
1246 c.intent,
1247 EditorIntent::Op(EditorOp::ApplyText(TextAction::Bold))
1248 );
1249
1250 let mut cx = ctx();
1253 cx.focused = PanelKind::Drawer;
1254 let c = classify_it(&ctrl('b'), &cx);
1255 assert_eq!(
1256 c.intent,
1257 EditorIntent::Panel {
1258 fallback: PanelFallback::None
1259 }
1260 );
1261 }
1262
1263 #[test]
1264 fn sort_dialog_targets_the_focused_drawer_view() {
1265 let mut cx = ctx();
1266 cx.focused = PanelKind::Drawer;
1267 cx.drawer_view = DrawerView::Find;
1268 let c = classify_it(&ctrl('r'), &cx);
1269 assert_eq!(c.intent, EditorIntent::OpenOverlay(OverlayOpen::SortQuery));
1270
1271 cx.drawer_view = DrawerView::Files;
1272 let c = classify_it(&ctrl('r'), &cx);
1273 assert_eq!(
1274 c.intent,
1275 EditorIntent::OpenOverlay(OverlayOpen::SortSidebar)
1276 );
1277
1278 cx.drawer_view = DrawerView::Tags;
1280 let c = classify_it(&ctrl('r'), &cx);
1281 assert_eq!(c.intent, EditorIntent::Consume);
1282 }
1283
1284 #[test]
1285 fn sort_dialog_chord_falls_through_when_editor_focused() {
1286 let c = classify_it(&ctrl('r'), &ctx());
1288 assert_eq!(
1289 c.intent,
1290 EditorIntent::Panel {
1291 fallback: PanelFallback::None
1292 }
1293 );
1294 }
1295
1296 #[test]
1300 fn sort_dialog_over_a_sortable_note_browser() {
1301 let cx = InputCtx {
1302 overlay: Some(OverlayKind::NoteBrowser),
1303 overlay_sortable: true,
1304 ..ctx()
1305 };
1306 let c = classify_it(&ctrl('r'), &cx);
1307 assert_eq!(
1308 c.intent,
1309 EditorIntent::OpenOverlay(OverlayOpen::SortBrowser)
1310 );
1311
1312 let links = InputCtx {
1313 overlay_sortable: false,
1314 ..cx.clone()
1315 };
1316 let c = classify_it(&ctrl('r'), &links);
1317 assert_eq!(c.intent, EditorIntent::Overlay);
1318
1319 let dialog = InputCtx {
1321 overlay: Some(OverlayKind::Dialog),
1322 ..cx
1323 };
1324 let c = classify_it(&ctrl('r'), &dialog);
1325 assert_eq!(c.intent, EditorIntent::Overlay);
1326 }
1327
1328 #[test]
1329 fn f1_opens_help_or_query_help_by_focus() {
1330 let c = classify_it(&key(KeyCode::F(1), KeyModifiers::NONE), &ctx());
1331 assert_eq!(c.intent, EditorIntent::OpenOverlay(OverlayOpen::Help));
1332
1333 let mut cx = ctx();
1334 cx.focused = PanelKind::Drawer;
1335 cx.drawer_view = DrawerView::Find;
1336 let c = classify_it(&key(KeyCode::F(1), KeyModifiers::NONE), &cx);
1337 assert_eq!(c.intent, EditorIntent::OpenOverlay(OverlayOpen::QueryHelp));
1338 }
1339
1340 #[test]
1341 fn unbound_fkeys_are_sunk_with_a_flash() {
1342 let c = classify_it(&key(KeyCode::F(9), KeyModifiers::NONE), &ctx());
1343 assert_eq!(c.intent, EditorIntent::Consume);
1344 assert!(c.flash.is_some());
1345 }
1346
1347 #[test]
1350 fn a_bound_chord_flashes_the_action_label() {
1351 let c = classify_it(&ctrl('k'), &ctx());
1352 assert_eq!(
1353 c.flash.as_deref(),
1354 Some(ActionShortcuts::SearchNotes.label().as_str())
1355 );
1356 }
1357
1358 #[test]
1361 fn an_unbound_chord_still_flashes_the_raw_chord() {
1362 let c = classify_it(&ctrl('y'), &ctx());
1364 let combo =
1365 key_event_to_combo(&KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL)).unwrap();
1366 assert_eq!(c.flash.as_deref(), Some(combo.to_string().as_str()));
1367 }
1368
1369 #[test]
1370 fn high_fkeys_are_sunk_like_the_rest() {
1371 let c = classify_it(&key(KeyCode::F(13), KeyModifiers::NONE), &ctx());
1376 assert_eq!(c.intent, EditorIntent::Consume);
1377 }
1378
1379 #[test]
1380 fn bound_fkey_dispatches_when_guard_holds_and_sinks_when_not() {
1381 let c = classify_it(&key(KeyCode::F(2), KeyModifiers::NONE), &ctx());
1383 assert_eq!(c.intent, EditorIntent::Op(EditorOp::ShowFileOps));
1384
1385 let mut cx = ctx();
1386 cx.focused = PanelKind::Drawer;
1387 let c = classify_it(&key(KeyCode::F(2), KeyModifiers::NONE), &cx);
1388 assert_eq!(c.intent, EditorIntent::Consume);
1389 }
1390
1391 #[test]
1394 fn overlay_intercepts_unbound_keys() {
1395 let mut cx = ctx();
1396 cx.overlay = Some(OverlayKind::SavedSearches);
1397 let c = classify_it(&plain('x'), &cx);
1398 assert_eq!(c.intent, EditorIntent::Overlay);
1399 }
1400
1401 #[test]
1402 fn mouse_events_take_the_hit_test_path() {
1403 let ev = InputEvent::Mouse(MouseEvent {
1404 kind: MouseEventKind::Moved,
1405 column: 3,
1406 row: 4,
1407 modifiers: KeyModifiers::NONE,
1408 });
1409 let c = classify_it(&ev, &ctx());
1410 assert_eq!(c.intent, EditorIntent::Mouse);
1411 }
1412
1413 #[test]
1414 fn vim_space_starts_leader_only_when_it_leads() {
1415 let mut cx = ctx();
1416 cx.space_leads = true;
1417 let c = classify_it(&plain(' '), &cx);
1418 assert_eq!(c.intent, EditorIntent::LeaderStart);
1419
1420 cx.space_leads = false;
1421 let c = classify_it(&plain(' '), &cx);
1422 assert_eq!(
1423 c.intent,
1424 EditorIntent::Panel {
1425 fallback: PanelFallback::None
1426 }
1427 );
1428 }
1429
1430 #[test]
1431 fn tab_cycles_focus_when_a_non_editor_panel_passes() {
1432 let mut cx = ctx();
1433 cx.focused = PanelKind::Drawer;
1434 let c = classify_it(&key(KeyCode::Tab, KeyModifiers::NONE), &cx);
1435 assert_eq!(
1436 c.intent,
1437 EditorIntent::Panel {
1438 fallback: PanelFallback::FocusCycle(CycleDir::Right)
1439 }
1440 );
1441
1442 let c = classify_it(&key(KeyCode::BackTab, KeyModifiers::SHIFT), &cx);
1443 assert_eq!(
1444 c.intent,
1445 EditorIntent::Panel {
1446 fallback: PanelFallback::FocusCycle(CycleDir::Left)
1447 }
1448 );
1449 }
1450
1451 #[test]
1452 fn editor_keeps_tab_for_indentation() {
1453 let c = classify_it(&key(KeyCode::Tab, KeyModifiers::NONE), &ctx());
1454 assert_eq!(
1455 c.intent,
1456 EditorIntent::Panel {
1457 fallback: PanelFallback::None
1458 }
1459 );
1460 }
1461
1462 #[test]
1463 fn find_view_yields_focus_to_editor_on_unhandled_esc() {
1464 let mut cx = ctx();
1465 cx.focused = PanelKind::Drawer;
1466 cx.drawer_view = DrawerView::Find;
1467 let c = classify_it(&key(KeyCode::Esc, KeyModifiers::NONE), &cx);
1468 assert_eq!(
1469 c.intent,
1470 EditorIntent::Panel {
1471 fallback: PanelFallback::FocusEditor
1472 }
1473 );
1474
1475 let c = classify_it(&plain('x'), &cx);
1477 assert_eq!(
1478 c.intent,
1479 EditorIntent::Panel {
1480 fallback: PanelFallback::None
1481 }
1482 );
1483 }
1484}