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}
62
63impl InputCtx {
64 fn editor_active(&self) -> bool {
67 self.focused == PanelKind::Editor && self.overlay.is_none()
68 }
69
70 fn find_panel_focused(&self) -> bool {
71 self.focused == PanelKind::Drawer && self.drawer_view == DrawerView::Find
72 }
73}
74
75#[derive(Debug, Clone, PartialEq)]
78pub struct Classification {
79 pub flash: Option<String>,
82 pub cancel_leader: bool,
85 pub intent: EditorIntent,
86}
87
88#[derive(Debug, Clone, PartialEq)]
92pub enum EditorIntent {
93 Consume,
95 EditorPaste,
100 ImageProbe,
107 FollowLink,
112 LeaderKey(KeyEvent),
114 LeaderStart,
116 Op(EditorOp),
120 ToggleOverlay {
122 kind: OverlayKind,
123 open: OverlayOpen,
124 },
125 OpenOverlay(OverlayOpen),
128 Overlay,
130 Mouse,
132 Panel { fallback: PanelFallback },
135}
136
137#[derive(Debug, Clone, Copy, PartialEq)]
143pub enum OverlayOpen {
144 SearchBrowser,
145 FileFinder,
146 SavedSearches,
147 CommandPalette,
148 WorkspaceSwitcher,
149 ThemePicker,
150 Help,
152 QueryHelp,
154 Cheatsheet,
156 SortQuery,
157 SortSidebar,
158 QuickNote,
159 PinnedNotes,
161}
162
163#[derive(Debug, Clone, Copy, PartialEq)]
165pub enum PanelFallback {
166 None,
167 FocusCycle(CycleDir),
169 FocusEditor,
171}
172
173#[derive(Debug, Clone, Copy, PartialEq)]
174pub enum CycleDir {
175 Left,
176 Right,
177}
178
179#[derive(Debug, Clone, PartialEq)]
180pub enum EditorOp {
181 ToggleDrawer,
182 FocusLeft,
183 FocusRight,
184 OpenJournal,
185 ShowFileOps,
186 ToggleQueryPanel,
187 OpenFileBrowserReveal,
190 SaveCurrentQuery,
191 FindInBuffer,
192 ReplaceInBuffer,
194 ApplyText(TextAction),
195 OpenAsk,
198}
199
200pub fn classify(event: &InputEvent, bindings: &KeyBindings, ctx: &InputCtx) -> Classification {
208 let mut classification = classify_unclaimed(event, bindings, ctx);
209 let claim = if ctx.editor_active() {
213 ctx.claim
214 } else {
215 EditorClaim::None
216 };
217 let filtered = apply_claim(classification.intent.clone(), event, claim);
218 if filtered != classification.intent {
219 classification.flash = None;
221 }
222 classification.intent = filtered;
223 classification
224}
225
226fn apply_claim(intent: EditorIntent, event: &InputEvent, claim: EditorClaim) -> EditorIntent {
238 if claim == EditorClaim::None {
239 return intent;
240 }
241 let deliver = EditorIntent::Panel {
242 fallback: PanelFallback::None,
243 };
244 let find_bar = claim == EditorClaim::FindBar;
245 match intent {
246 EditorIntent::LeaderKey(_)
251 | EditorIntent::Panel { .. }
252 | EditorIntent::Overlay
253 | EditorIntent::ToggleOverlay { .. }
254 | EditorIntent::OpenOverlay(_)
255 | EditorIntent::Consume => intent,
256
257 EditorIntent::Op(EditorOp::ApplyText(_)) => deliver,
260 EditorIntent::Op(_) => intent,
261
262 EditorIntent::EditorPaste | EditorIntent::ImageProbe => {
265 if find_bar {
266 deliver
267 } else {
268 intent
269 }
270 }
271
272 EditorIntent::FollowLink => match event {
280 InputEvent::Mouse(_) => claimed_mouse(event, find_bar),
281 _ if find_bar => deliver,
282 _ => intent,
283 },
284
285 EditorIntent::LeaderStart => match event {
289 InputEvent::Key(k)
290 if find_bar
291 && k.code == ratatui::crossterm::event::KeyCode::Char(' ')
292 && k.modifiers.is_empty() =>
293 {
294 deliver
295 }
296 _ => intent,
297 },
298
299 EditorIntent::Mouse => claimed_mouse(event, find_bar),
300 }
301}
302
303fn claimed_mouse(event: &InputEvent, find_bar: bool) -> EditorIntent {
311 use ratatui::crossterm::event::MouseEventKind;
312
313 match event {
314 InputEvent::Mouse(m)
315 if find_bar
316 && !matches!(
317 m.kind,
318 MouseEventKind::ScrollUp
319 | MouseEventKind::ScrollDown
320 | MouseEventKind::ScrollLeft
321 | MouseEventKind::ScrollRight
322 ) =>
323 {
324 EditorIntent::Consume
325 }
326 _ => EditorIntent::Mouse,
327 }
328}
329
330fn classify_unclaimed(
331 event: &InputEvent,
332 bindings: &KeyBindings,
333 ctx: &InputCtx,
334) -> Classification {
335 use ratatui::crossterm::event::{KeyCode, KeyModifiers};
336
337 let mut cancel_leader = false;
343 if ctx.leader_pending {
344 match event {
345 InputEvent::Key(key) => {
346 if ctx.overlay.is_some() {
347 cancel_leader = true;
348 } else if matches!(key.code, KeyCode::Char(_))
349 && key.modifiers.contains(KeyModifiers::CONTROL)
350 {
351 cancel_leader = true;
352 } else {
354 return Classification {
355 flash: None,
356 cancel_leader: false,
357 intent: EditorIntent::LeaderKey(*key),
358 };
359 }
360 }
361 InputEvent::Paste(_) => cancel_leader = true,
362 InputEvent::Mouse(_) => {}
364 }
365 }
366
367 if ctx.editor_active() && matches!(event, InputEvent::Paste(_)) {
370 return Classification {
371 flash: None,
372 cancel_leader,
373 intent: EditorIntent::EditorPaste,
374 };
375 }
376
377 if ctx.editor_active()
382 && let InputEvent::Key(key) = event
383 && key.modifiers == KeyModifiers::CONTROL
384 && key.code == KeyCode::Char('v')
385 {
386 return Classification {
387 flash: None,
388 cancel_leader,
389 intent: EditorIntent::ImageProbe,
390 };
391 }
392
393 classify_tail(event, bindings, ctx, cancel_leader)
394}
395
396pub(crate) fn classify_tail(
403 event: &InputEvent,
404 bindings: &KeyBindings,
405 ctx: &InputCtx,
406 cancel_leader: bool,
407) -> Classification {
408 use ratatui::crossterm::event::{KeyCode, KeyModifiers};
409
410 if ctx.editor_active()
413 && let InputEvent::Key(key) = event
414 && key.code == KeyCode::Enter
415 && key.modifiers.contains(KeyModifiers::CONTROL)
416 {
417 return Classification {
418 flash: None,
419 cancel_leader,
420 intent: EditorIntent::FollowLink,
421 };
422 }
423
424 let mut flash = None;
429 let mut shortcut_intent = None;
430 if let InputEvent::Key(key) = event
431 && let Some(combo) = key_event_to_combo(key)
432 {
433 let is_fkey = combo.key.is_fkey();
434 let action = bindings.get_action(&combo);
435 if action != Some(ActionShortcuts::Leader) && (is_fkey || combo.is_letter_chord()) {
448 flash = Some(match &action {
449 Some(a) => a.label(),
450 None => combo.to_string(),
451 });
452 }
453 shortcut_intent = match action {
454 Some(ActionShortcuts::OpenCommandPalette) => Some(EditorIntent::ToggleOverlay {
455 kind: OverlayKind::CommandPalette,
456 open: OverlayOpen::CommandPalette,
457 }),
458 Some(ActionShortcuts::Leader) => {
459 Some(if ctx.overlay.is_none() {
462 EditorIntent::LeaderStart
463 } else {
464 EditorIntent::Consume
465 })
466 }
467 Some(ActionShortcuts::ToggleSidebar) => Some(EditorIntent::Op(EditorOp::ToggleDrawer)),
468 Some(ActionShortcuts::FocusSidebar) => {
469 Some(if ctx.overlay.is_none() {
471 EditorIntent::Op(EditorOp::FocusLeft)
472 } else {
473 EditorIntent::Consume
474 })
475 }
476 Some(ActionShortcuts::FocusEditor) => Some(if ctx.overlay.is_none() {
477 EditorIntent::Op(EditorOp::FocusRight)
478 } else {
479 EditorIntent::Consume
480 }),
481 Some(ActionShortcuts::NewJournal) => Some(EditorIntent::Op(EditorOp::OpenJournal)),
482 Some(ActionShortcuts::SearchNotes) => Some(EditorIntent::ToggleOverlay {
483 kind: OverlayKind::NoteBrowser,
484 open: OverlayOpen::SearchBrowser,
485 }),
486 Some(ActionShortcuts::OpenNote) => Some(EditorIntent::ToggleOverlay {
487 kind: OverlayKind::NoteBrowser,
488 open: OverlayOpen::FileFinder,
489 }),
490 Some(ActionShortcuts::FileOperations) if ctx.editor_active() => {
491 Some(EditorIntent::Op(EditorOp::ShowFileOps))
492 }
493 Some(ActionShortcuts::FollowLink) if ctx.editor_active() => {
494 Some(EditorIntent::FollowLink)
495 }
496 Some(ActionShortcuts::ToggleQueryPanel) => {
497 Some(EditorIntent::Op(EditorOp::ToggleQueryPanel))
498 }
499 Some(ActionShortcuts::OpenFileBrowser) => {
500 Some(EditorIntent::Op(EditorOp::OpenFileBrowserReveal))
504 }
505 Some(ActionShortcuts::OpenSavedSearches) => Some(EditorIntent::ToggleOverlay {
506 kind: OverlayKind::SavedSearches,
507 open: OverlayOpen::SavedSearches,
508 }),
509 Some(ActionShortcuts::OpenAsk) => Some(EditorIntent::Op(EditorOp::OpenAsk)),
510 Some(ActionShortcuts::YankRow) => None,
517 Some(ActionShortcuts::OpenSortDialog) => {
518 if ctx.focused == PanelKind::Drawer && ctx.overlay.is_none() {
523 Some(match ctx.drawer_view {
524 DrawerView::Find => EditorIntent::OpenOverlay(OverlayOpen::SortQuery),
525 DrawerView::Files => EditorIntent::OpenOverlay(OverlayOpen::SortSidebar),
526 _ => EditorIntent::Consume,
527 })
528 } else {
529 None
530 }
531 }
532 Some(ActionShortcuts::SaveCurrentQuery) => {
533 Some(EditorIntent::Op(EditorOp::SaveCurrentQuery))
536 }
537 Some(ActionShortcuts::SwitchWorkspace) => {
538 Some(EditorIntent::OpenOverlay(OverlayOpen::WorkspaceSwitcher))
539 }
540 Some(ActionShortcuts::QuickNote) => Some(if ctx.overlay.is_none() {
541 EditorIntent::OpenOverlay(OverlayOpen::QuickNote)
542 } else {
543 EditorIntent::Consume
544 }),
545 Some(ActionShortcuts::FindInBuffer) if ctx.editor_active() => {
546 Some(EditorIntent::Op(EditorOp::FindInBuffer))
547 }
548 Some(ActionShortcuts::ReplaceInBuffer) if ctx.editor_active() => {
549 Some(EditorIntent::Op(EditorOp::ReplaceInBuffer))
550 }
551 Some(ActionShortcuts::Text(
552 action @ (TextAction::Bold | TextAction::Italic | TextAction::Strikethrough),
553 )) if ctx.editor_active() => Some(EditorIntent::Op(EditorOp::ApplyText(action))),
554 _ => {
555 if is_fkey {
556 if combo.key == KeyStrike::F1 && combo.modifiers.is_empty() {
560 Some(EditorIntent::OpenOverlay(if ctx.find_panel_focused() {
561 OverlayOpen::QueryHelp
562 } else {
563 OverlayOpen::Help
564 }))
565 } else {
566 Some(EditorIntent::Consume)
567 }
568 } else {
569 None
570 }
571 }
572 };
573 }
574
575 let done = move |intent| Classification {
578 flash,
579 cancel_leader,
580 intent,
581 };
582
583 if let Some(intent) = shortcut_intent {
584 return done(intent);
585 }
586
587 if ctx.overlay.is_some() {
589 return done(EditorIntent::Overlay);
590 }
591
592 if matches!(event, InputEvent::Mouse(_)) {
593 if ctx.double_click && ctx.editor_active() {
599 return done(EditorIntent::FollowLink);
600 }
601 return done(EditorIntent::Mouse);
602 }
603
604 if ctx.editor_active()
609 && (!ctx.leader_pending || cancel_leader)
610 && let InputEvent::Key(key) = event
611 && key.code == KeyCode::Char(' ')
612 && key.modifiers.is_empty()
613 && ctx.space_leads
614 {
615 return done(EditorIntent::LeaderStart);
616 }
617
618 if ctx.focused != PanelKind::Editor
622 && let InputEvent::Key(key) = event
623 && matches!(key.code, KeyCode::Tab | KeyCode::BackTab)
624 {
625 return done(EditorIntent::Panel {
626 fallback: PanelFallback::FocusCycle(if key.code == KeyCode::Tab {
627 CycleDir::Right
628 } else {
629 CycleDir::Left
630 }),
631 });
632 }
633
634 if ctx.find_panel_focused()
637 && let InputEvent::Key(key) = event
638 && key.code == KeyCode::Esc
639 {
640 return done(EditorIntent::Panel {
641 fallback: PanelFallback::FocusEditor,
642 });
643 }
644
645 done(EditorIntent::Panel {
646 fallback: PanelFallback::None,
647 })
648}
649
650#[cfg(test)]
651mod tests {
652 use super::*;
653 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind};
654
655 fn bindings() -> KeyBindings {
656 let mut kb = KeyBindings::empty();
657 kb.batch_add()
658 .with_ctrl()
659 .add(KeyStrike::KeyP, ActionShortcuts::OpenCommandPalette)
660 .add(KeyStrike::KeyK, ActionShortcuts::SearchNotes)
661 .add(KeyStrike::KeyG, ActionShortcuts::Leader)
662 .add(KeyStrike::KeyT, ActionShortcuts::ToggleSidebar)
663 .add(KeyStrike::KeyR, ActionShortcuts::OpenSortDialog)
664 .add(KeyStrike::KeyH, ActionShortcuts::FocusSidebar)
665 .add(KeyStrike::KeyB, ActionShortcuts::Text(TextAction::Bold))
666 .add(KeyStrike::KeyN, ActionShortcuts::FollowLink)
667 .add(KeyStrike::KeyW, ActionShortcuts::QuickNote);
668 kb.batch_add()
669 .add(KeyStrike::F2, ActionShortcuts::FileOperations);
670 kb
671 }
672
673 fn ctx() -> InputCtx {
675 InputCtx {
676 overlay: None,
677 leader_pending: false,
678 focused: PanelKind::Editor,
679 drawer_view: DrawerView::Files,
680 space_leads: false,
681 claim: EditorClaim::None,
682 double_click: false,
683 }
684 }
685
686 fn ctx_find_bar() -> InputCtx {
688 InputCtx {
689 claim: EditorClaim::FindBar,
690 ..ctx()
691 }
692 }
693
694 fn ctx_double() -> InputCtx {
697 InputCtx {
698 double_click: true,
699 ..ctx()
700 }
701 }
702
703 fn press() -> InputEvent {
707 use ratatui::crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
708 InputEvent::Mouse(MouseEvent {
709 kind: MouseEventKind::Down(MouseButton::Left),
710 column: 1,
711 row: 1,
712 modifiers: KeyModifiers::NONE,
713 })
714 }
715
716 fn key(code: KeyCode, mods: KeyModifiers) -> InputEvent {
717 InputEvent::Key(KeyEvent::new(code, mods))
718 }
719
720 fn ctrl(c: char) -> InputEvent {
721 key(KeyCode::Char(c), KeyModifiers::CONTROL)
722 }
723
724 fn plain(c: char) -> InputEvent {
725 key(KeyCode::Char(c), KeyModifiers::NONE)
726 }
727
728 fn classify_it(event: &InputEvent, ctx: &InputCtx) -> Classification {
729 classify(event, &bindings(), ctx)
730 }
731
732 #[test]
739 fn a_claim_blocks_a_buffer_edit() {
740 let ev = key(KeyCode::Char('b'), KeyModifiers::CONTROL);
741 assert_eq!(
742 classify_it(&ev, &ctx()).intent,
743 EditorIntent::Op(EditorOp::ApplyText(TextAction::Bold)),
744 "without a claim the chord still bolds"
745 );
746 assert_eq!(
747 classify_it(&ev, &ctx_find_bar()).intent,
748 EditorIntent::Panel {
749 fallback: PanelFallback::None
750 },
751 "under a claim it is delivered to the holder instead"
752 );
753 }
754
755 #[test]
758 fn a_claim_leaves_navigation_alone() {
759 for ev in [
760 key(KeyCode::Char('t'), KeyModifiers::CONTROL),
761 key(KeyCode::Char('p'), KeyModifiers::CONTROL),
762 ] {
763 assert_eq!(
764 classify_it(&ev, &ctx_find_bar()).intent,
765 classify_it(&ev, &ctx()).intent,
766 "a claim must not swallow navigation"
767 );
768 }
769 }
770
771 #[test]
774 fn a_find_bar_claim_blocks_the_paste_tiers() {
775 for ev in [
776 InputEvent::Paste("hi".into()),
777 key(KeyCode::Char('v'), KeyModifiers::CONTROL),
778 key(KeyCode::Enter, KeyModifiers::CONTROL),
779 ] {
780 assert_eq!(
781 classify_it(&ev, &ctx_find_bar()).intent,
782 EditorIntent::Panel {
783 fallback: PanelFallback::None
784 },
785 "paste, image probe and follow-link all belong to the bar"
786 );
787 }
788 }
789
790 #[test]
794 fn a_find_bar_claim_blocks_the_space_leader() {
795 let ev = key(KeyCode::Char(' '), KeyModifiers::NONE);
796 let vim_ctx = InputCtx {
797 space_leads: true,
798 ..ctx()
799 };
800 assert_eq!(classify_it(&ev, &vim_ctx).intent, EditorIntent::LeaderStart);
801 let vim_bar = InputCtx {
802 space_leads: true,
803 ..ctx_find_bar()
804 };
805 assert_eq!(
806 classify_it(&ev, &vim_bar).intent,
807 EditorIntent::Panel {
808 fallback: PanelFallback::None
809 }
810 );
811 }
812
813 #[test]
817 fn a_find_bar_claim_blocks_clicks_but_not_scrolling() {
818 use ratatui::crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
819 let at = |kind| {
820 InputEvent::Mouse(MouseEvent {
821 kind,
822 column: 1,
823 row: 1,
824 modifiers: KeyModifiers::NONE,
825 })
826 };
827 assert_eq!(
828 classify_it(
829 &at(MouseEventKind::Down(MouseButton::Left)),
830 &ctx_find_bar()
831 )
832 .intent,
833 EditorIntent::Consume,
834 "a click reaches nothing while the bar holds the claim"
835 );
836 assert_eq!(
837 classify_it(&at(MouseEventKind::ScrollUp), &ctx_find_bar()).intent,
838 EditorIntent::Mouse,
839 "scrolling still works"
840 );
841 }
842
843 #[test]
849 fn a_single_press_only_reaches_the_editor() {
850 assert_eq!(classify_it(&press(), &ctx()).intent, EditorIntent::Mouse);
851 }
852
853 #[test]
856 fn a_double_click_follows_the_link_under_it() {
857 assert_eq!(
858 classify_it(&press(), &ctx_double()).intent,
859 EditorIntent::FollowLink,
860 "the same intent Ctrl+N produces"
861 );
862 assert_eq!(
863 classify_it(&key(KeyCode::Char('n'), KeyModifiers::CONTROL), &ctx()).intent,
864 EditorIntent::FollowLink,
865 "and Ctrl+N still produces it"
866 );
867 }
868
869 #[test]
873 fn a_double_click_outside_the_editor_is_an_ordinary_click() {
874 let drawer = InputCtx {
875 focused: PanelKind::Drawer,
876 ..ctx_double()
877 };
878 assert_eq!(classify_it(&press(), &drawer).intent, EditorIntent::Mouse);
879 }
880
881 #[test]
884 fn an_overlay_outranks_a_double_click() {
885 let covered = InputCtx {
886 overlay: Some(OverlayKind::NoteBrowser),
887 ..ctx_double()
888 };
889 assert_eq!(
890 classify_it(&press(), &covered).intent,
891 EditorIntent::Overlay
892 );
893 }
894
895 #[test]
899 fn a_find_bar_claim_swallows_a_double_click_but_takes_ctrl_enter() {
900 let bar_double = InputCtx {
901 double_click: true,
902 ..ctx_find_bar()
903 };
904 assert_eq!(
905 classify_it(&press(), &bar_double).intent,
906 EditorIntent::Consume,
907 "a follow that arrived as a click is still a click"
908 );
909 assert_eq!(
910 classify_it(&key(KeyCode::Enter, KeyModifiers::CONTROL), &ctx_find_bar()).intent,
911 EditorIntent::Panel {
912 fallback: PanelFallback::None
913 },
914 "but a follow that arrived as a key is delivered to the bar"
915 );
916 }
917
918 #[test]
924 fn an_autocomplete_claim_turns_a_double_click_back_into_a_press() {
925 let popup = InputCtx {
926 claim: EditorClaim::Autocomplete,
927 double_click: true,
928 ..ctx()
929 };
930 assert_eq!(classify_it(&press(), &popup).intent, EditorIntent::Mouse);
931 assert_eq!(
932 classify_it(&key(KeyCode::Char('n'), KeyModifiers::CONTROL), &popup).intent,
933 EditorIntent::FollowLink,
934 "the popup does not own the keyboard follow"
935 );
936 }
937
938 #[test]
942 fn an_autocomplete_claim_blocks_only_buffer_edits() {
943 let popup = InputCtx {
944 claim: EditorClaim::Autocomplete,
945 ..ctx()
946 };
947 assert_eq!(
948 classify_it(&InputEvent::Paste("hi".into()), &popup).intent,
949 EditorIntent::EditorPaste,
950 "the popup does not own pastes"
951 );
952 assert_eq!(
953 classify_it(&key(KeyCode::Char('b'), KeyModifiers::CONTROL), &popup).intent,
954 EditorIntent::Panel {
955 fallback: PanelFallback::None
956 },
957 "but a buffer edit still loses to any holder"
958 );
959 }
960
961 #[test]
963 fn the_leader_outranks_a_claim() {
964 let ev = key(KeyCode::Char('x'), KeyModifiers::NONE);
965 let pending = InputCtx {
966 leader_pending: true,
967 ..ctx_find_bar()
968 };
969 assert!(matches!(
970 classify_it(&ev, &pending).intent,
971 EditorIntent::LeaderKey(_)
972 ));
973 }
974
975 #[test]
978 fn bracketed_paste_in_editor_is_editor_paste() {
979 let c = classify_it(&InputEvent::Paste("hi".into()), &ctx());
980 assert_eq!(c.intent, EditorIntent::EditorPaste);
981 }
982
983 #[test]
984 fn bracketed_paste_with_overlay_routes_to_overlay() {
985 let mut cx = ctx();
986 cx.overlay = Some(OverlayKind::NoteBrowser);
987 let c = classify_it(&InputEvent::Paste("hi".into()), &cx);
988 assert_eq!(c.intent, EditorIntent::Overlay);
989 }
990
991 #[test]
992 fn bracketed_paste_drawer_focused_goes_to_panel() {
993 let mut cx = ctx();
994 cx.focused = PanelKind::Drawer;
995 let c = classify_it(&InputEvent::Paste("hi".into()), &cx);
996 assert_eq!(
997 c.intent,
998 EditorIntent::Panel {
999 fallback: PanelFallback::None
1000 }
1001 );
1002 }
1003
1004 #[test]
1005 fn ctrl_v_in_editor_is_a_bare_image_probe() {
1006 let c = classify_it(&ctrl('v'), &ctx());
1009 assert_eq!(c.intent, EditorIntent::ImageProbe);
1010 assert_eq!(c.flash, None, "flash belongs to the no-image tail only");
1011 }
1012
1013 #[test]
1014 fn ctrl_v_no_image_tail_reaches_panel_with_a_flash() {
1015 let c = classify_tail(&ctrl('v'), &bindings(), &ctx(), false);
1020 assert_eq!(
1021 c.intent,
1022 EditorIntent::Panel {
1023 fallback: PanelFallback::None
1024 }
1025 );
1026 assert!(c.flash.is_some());
1027 assert!(!c.cancel_leader, "the outer probe already owned the cancel");
1028 }
1029
1030 #[test]
1031 fn leader_pending_ctrl_v_cancels_leader_then_probes_image() {
1032 let mut cx = ctx();
1038 cx.leader_pending = true;
1039 let c = classify_it(&ctrl('v'), &cx);
1040 assert!(c.cancel_leader);
1041 assert_eq!(c.intent, EditorIntent::ImageProbe);
1042 }
1043
1044 #[test]
1045 fn leader_pending_bracketed_paste_cancels_leader_then_pastes() {
1046 let mut cx = ctx();
1049 cx.leader_pending = true;
1050 let c = classify_it(&InputEvent::Paste("hi".into()), &cx);
1051 assert!(c.cancel_leader);
1052 assert_eq!(c.intent, EditorIntent::EditorPaste);
1053 }
1054
1055 #[test]
1056 fn leader_pending_ctrl_enter_feeds_leader() {
1057 let mut cx = ctx();
1060 cx.leader_pending = true;
1061 let c = classify_it(&key(KeyCode::Enter, KeyModifiers::CONTROL), &cx);
1062 assert_eq!(
1063 c.intent,
1064 EditorIntent::LeaderKey(KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL))
1065 );
1066 assert!(!c.cancel_leader);
1067 }
1068
1069 #[test]
1070 fn ctrl_enter_in_editor_follows_link() {
1071 let c = classify_it(&key(KeyCode::Enter, KeyModifiers::CONTROL), &ctx());
1072 assert_eq!(c.intent, EditorIntent::FollowLink);
1073 }
1074
1075 #[test]
1078 fn leader_pending_plain_key_feeds_leader() {
1079 let mut cx = ctx();
1080 cx.leader_pending = true;
1081 let c = classify_it(&plain('f'), &cx);
1082 assert_eq!(
1083 c.intent,
1084 EditorIntent::LeaderKey(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE))
1085 );
1086 assert!(!c.cancel_leader);
1087 }
1088
1089 #[test]
1090 fn leader_pending_with_overlay_cancels_and_routes_to_overlay() {
1091 let mut cx = ctx();
1092 cx.leader_pending = true;
1093 cx.overlay = Some(OverlayKind::Dialog);
1094 let c = classify_it(&plain('f'), &cx);
1095 assert!(c.cancel_leader);
1096 assert_eq!(c.intent, EditorIntent::Overlay);
1097 }
1098
1099 #[test]
1100 fn leader_pending_ctrl_chord_cancels_then_dispatches() {
1101 let mut cx = ctx();
1102 cx.leader_pending = true;
1103 let c = classify_it(&ctrl('p'), &cx);
1104 assert!(c.cancel_leader);
1105 assert_eq!(
1106 c.intent,
1107 EditorIntent::ToggleOverlay {
1108 kind: OverlayKind::CommandPalette,
1109 open: OverlayOpen::CommandPalette
1110 }
1111 );
1112 }
1113
1114 #[test]
1117 fn command_palette_chord_toggles_regardless_of_open_state() {
1118 let c = classify_it(&ctrl('p'), &ctx());
1121 assert_eq!(
1122 c.intent,
1123 EditorIntent::ToggleOverlay {
1124 kind: OverlayKind::CommandPalette,
1125 open: OverlayOpen::CommandPalette
1126 }
1127 );
1128 assert!(c.flash.is_some());
1129
1130 let mut cx = ctx();
1131 cx.overlay = Some(OverlayKind::CommandPalette);
1132 let c = classify_it(&ctrl('p'), &cx);
1133 assert_eq!(
1134 c.intent,
1135 EditorIntent::ToggleOverlay {
1136 kind: OverlayKind::CommandPalette,
1137 open: OverlayOpen::CommandPalette
1138 }
1139 );
1140 }
1141
1142 #[test]
1143 fn leader_gateway_starts_sequence_without_flash() {
1144 let c = classify_it(&ctrl('g'), &ctx());
1145 assert_eq!(c.intent, EditorIntent::LeaderStart);
1146 assert_eq!(c.flash, None);
1147 }
1148
1149 #[test]
1150 fn leader_gateway_with_overlay_is_consumed_noop() {
1151 let mut cx = ctx();
1152 cx.overlay = Some(OverlayKind::NoteBrowser);
1153 let c = classify_it(&ctrl('g'), &cx);
1154 assert_eq!(c.intent, EditorIntent::Consume);
1155 }
1156
1157 #[test]
1158 fn focus_sidebar_with_overlay_is_consumed_noop() {
1159 let mut cx = ctx();
1160 cx.overlay = Some(OverlayKind::NoteBrowser);
1161 let c = classify_it(&ctrl('h'), &cx);
1162 assert_eq!(c.intent, EditorIntent::Consume);
1163 }
1164
1165 #[test]
1166 fn toggle_drawer_chord_is_action() {
1167 let c = classify_it(&ctrl('t'), &ctx());
1168 assert_eq!(c.intent, EditorIntent::Op(EditorOp::ToggleDrawer));
1169 }
1170
1171 #[test]
1172 fn quick_note_opens_dialog_only_without_overlay() {
1173 let c = classify_it(&ctrl('w'), &ctx());
1174 assert_eq!(c.intent, EditorIntent::OpenOverlay(OverlayOpen::QuickNote));
1175
1176 let mut cx = ctx();
1177 cx.overlay = Some(OverlayKind::Dialog);
1178 let c = classify_it(&ctrl('w'), &cx);
1179 assert_eq!(c.intent, EditorIntent::Consume);
1180 }
1181
1182 #[test]
1183 fn text_style_chord_needs_active_editor() {
1184 let c = classify_it(&ctrl('b'), &ctx());
1185 assert_eq!(
1186 c.intent,
1187 EditorIntent::Op(EditorOp::ApplyText(TextAction::Bold))
1188 );
1189
1190 let mut cx = ctx();
1193 cx.focused = PanelKind::Drawer;
1194 let c = classify_it(&ctrl('b'), &cx);
1195 assert_eq!(
1196 c.intent,
1197 EditorIntent::Panel {
1198 fallback: PanelFallback::None
1199 }
1200 );
1201 }
1202
1203 #[test]
1204 fn sort_dialog_targets_the_focused_drawer_view() {
1205 let mut cx = ctx();
1206 cx.focused = PanelKind::Drawer;
1207 cx.drawer_view = DrawerView::Find;
1208 let c = classify_it(&ctrl('r'), &cx);
1209 assert_eq!(c.intent, EditorIntent::OpenOverlay(OverlayOpen::SortQuery));
1210
1211 cx.drawer_view = DrawerView::Files;
1212 let c = classify_it(&ctrl('r'), &cx);
1213 assert_eq!(
1214 c.intent,
1215 EditorIntent::OpenOverlay(OverlayOpen::SortSidebar)
1216 );
1217
1218 cx.drawer_view = DrawerView::Tags;
1220 let c = classify_it(&ctrl('r'), &cx);
1221 assert_eq!(c.intent, EditorIntent::Consume);
1222 }
1223
1224 #[test]
1225 fn sort_dialog_chord_falls_through_when_editor_focused() {
1226 let c = classify_it(&ctrl('r'), &ctx());
1228 assert_eq!(
1229 c.intent,
1230 EditorIntent::Panel {
1231 fallback: PanelFallback::None
1232 }
1233 );
1234 }
1235
1236 #[test]
1237 fn f1_opens_help_or_query_help_by_focus() {
1238 let c = classify_it(&key(KeyCode::F(1), KeyModifiers::NONE), &ctx());
1239 assert_eq!(c.intent, EditorIntent::OpenOverlay(OverlayOpen::Help));
1240
1241 let mut cx = ctx();
1242 cx.focused = PanelKind::Drawer;
1243 cx.drawer_view = DrawerView::Find;
1244 let c = classify_it(&key(KeyCode::F(1), KeyModifiers::NONE), &cx);
1245 assert_eq!(c.intent, EditorIntent::OpenOverlay(OverlayOpen::QueryHelp));
1246 }
1247
1248 #[test]
1249 fn unbound_fkeys_are_sunk_with_a_flash() {
1250 let c = classify_it(&key(KeyCode::F(9), KeyModifiers::NONE), &ctx());
1251 assert_eq!(c.intent, EditorIntent::Consume);
1252 assert!(c.flash.is_some());
1253 }
1254
1255 #[test]
1258 fn a_bound_chord_flashes_the_action_label() {
1259 let c = classify_it(&ctrl('k'), &ctx());
1260 assert_eq!(
1261 c.flash.as_deref(),
1262 Some(ActionShortcuts::SearchNotes.label().as_str())
1263 );
1264 }
1265
1266 #[test]
1269 fn an_unbound_chord_still_flashes_the_raw_chord() {
1270 let c = classify_it(&ctrl('y'), &ctx());
1272 let combo =
1273 key_event_to_combo(&KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL)).unwrap();
1274 assert_eq!(c.flash.as_deref(), Some(combo.to_string().as_str()));
1275 }
1276
1277 #[test]
1278 fn high_fkeys_are_sunk_like_the_rest() {
1279 let c = classify_it(&key(KeyCode::F(13), KeyModifiers::NONE), &ctx());
1284 assert_eq!(c.intent, EditorIntent::Consume);
1285 }
1286
1287 #[test]
1288 fn bound_fkey_dispatches_when_guard_holds_and_sinks_when_not() {
1289 let c = classify_it(&key(KeyCode::F(2), KeyModifiers::NONE), &ctx());
1291 assert_eq!(c.intent, EditorIntent::Op(EditorOp::ShowFileOps));
1292
1293 let mut cx = ctx();
1294 cx.focused = PanelKind::Drawer;
1295 let c = classify_it(&key(KeyCode::F(2), KeyModifiers::NONE), &cx);
1296 assert_eq!(c.intent, EditorIntent::Consume);
1297 }
1298
1299 #[test]
1302 fn overlay_intercepts_unbound_keys() {
1303 let mut cx = ctx();
1304 cx.overlay = Some(OverlayKind::SavedSearches);
1305 let c = classify_it(&plain('x'), &cx);
1306 assert_eq!(c.intent, EditorIntent::Overlay);
1307 }
1308
1309 #[test]
1310 fn mouse_events_take_the_hit_test_path() {
1311 let ev = InputEvent::Mouse(MouseEvent {
1312 kind: MouseEventKind::Moved,
1313 column: 3,
1314 row: 4,
1315 modifiers: KeyModifiers::NONE,
1316 });
1317 let c = classify_it(&ev, &ctx());
1318 assert_eq!(c.intent, EditorIntent::Mouse);
1319 }
1320
1321 #[test]
1322 fn vim_space_starts_leader_only_when_it_leads() {
1323 let mut cx = ctx();
1324 cx.space_leads = true;
1325 let c = classify_it(&plain(' '), &cx);
1326 assert_eq!(c.intent, EditorIntent::LeaderStart);
1327
1328 cx.space_leads = false;
1329 let c = classify_it(&plain(' '), &cx);
1330 assert_eq!(
1331 c.intent,
1332 EditorIntent::Panel {
1333 fallback: PanelFallback::None
1334 }
1335 );
1336 }
1337
1338 #[test]
1339 fn tab_cycles_focus_when_a_non_editor_panel_passes() {
1340 let mut cx = ctx();
1341 cx.focused = PanelKind::Drawer;
1342 let c = classify_it(&key(KeyCode::Tab, KeyModifiers::NONE), &cx);
1343 assert_eq!(
1344 c.intent,
1345 EditorIntent::Panel {
1346 fallback: PanelFallback::FocusCycle(CycleDir::Right)
1347 }
1348 );
1349
1350 let c = classify_it(&key(KeyCode::BackTab, KeyModifiers::SHIFT), &cx);
1351 assert_eq!(
1352 c.intent,
1353 EditorIntent::Panel {
1354 fallback: PanelFallback::FocusCycle(CycleDir::Left)
1355 }
1356 );
1357 }
1358
1359 #[test]
1360 fn editor_keeps_tab_for_indentation() {
1361 let c = classify_it(&key(KeyCode::Tab, KeyModifiers::NONE), &ctx());
1362 assert_eq!(
1363 c.intent,
1364 EditorIntent::Panel {
1365 fallback: PanelFallback::None
1366 }
1367 );
1368 }
1369
1370 #[test]
1371 fn find_view_yields_focus_to_editor_on_unhandled_esc() {
1372 let mut cx = ctx();
1373 cx.focused = PanelKind::Drawer;
1374 cx.drawer_view = DrawerView::Find;
1375 let c = classify_it(&key(KeyCode::Esc, KeyModifiers::NONE), &cx);
1376 assert_eq!(
1377 c.intent,
1378 EditorIntent::Panel {
1379 fallback: PanelFallback::FocusEditor
1380 }
1381 );
1382
1383 let c = classify_it(&plain('x'), &cx);
1385 assert_eq!(
1386 c.intent,
1387 EditorIntent::Panel {
1388 fallback: PanelFallback::None
1389 }
1390 );
1391 }
1392}