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}
160
161#[derive(Debug, Clone, Copy, PartialEq)]
163pub enum PanelFallback {
164 None,
165 FocusCycle(CycleDir),
167 FocusEditor,
169}
170
171#[derive(Debug, Clone, Copy, PartialEq)]
172pub enum CycleDir {
173 Left,
174 Right,
175}
176
177#[derive(Debug, Clone, PartialEq)]
178pub enum EditorOp {
179 ToggleDrawer,
180 FocusLeft,
181 FocusRight,
182 OpenJournal,
183 ShowFileOps,
184 ToggleQueryPanel,
185 OpenFileBrowserReveal,
188 SaveCurrentQuery,
189 FindInBuffer,
190 ReplaceInBuffer,
192 ApplyText(TextAction),
193 OpenAsk,
196}
197
198pub fn classify(event: &InputEvent, bindings: &KeyBindings, ctx: &InputCtx) -> Classification {
206 let mut classification = classify_unclaimed(event, bindings, ctx);
207 let claim = if ctx.editor_active() {
211 ctx.claim
212 } else {
213 EditorClaim::None
214 };
215 let filtered = apply_claim(classification.intent.clone(), event, claim);
216 if filtered != classification.intent {
217 classification.flash = None;
219 }
220 classification.intent = filtered;
221 classification
222}
223
224fn apply_claim(intent: EditorIntent, event: &InputEvent, claim: EditorClaim) -> EditorIntent {
236 if claim == EditorClaim::None {
237 return intent;
238 }
239 let deliver = EditorIntent::Panel {
240 fallback: PanelFallback::None,
241 };
242 let find_bar = claim == EditorClaim::FindBar;
243 match intent {
244 EditorIntent::LeaderKey(_)
249 | EditorIntent::Panel { .. }
250 | EditorIntent::Overlay
251 | EditorIntent::ToggleOverlay { .. }
252 | EditorIntent::OpenOverlay(_)
253 | EditorIntent::Consume => intent,
254
255 EditorIntent::Op(EditorOp::ApplyText(_)) => deliver,
258 EditorIntent::Op(_) => intent,
259
260 EditorIntent::EditorPaste | EditorIntent::ImageProbe => {
263 if find_bar {
264 deliver
265 } else {
266 intent
267 }
268 }
269
270 EditorIntent::FollowLink => match event {
278 InputEvent::Mouse(_) => claimed_mouse(event, find_bar),
279 _ if find_bar => deliver,
280 _ => intent,
281 },
282
283 EditorIntent::LeaderStart => match event {
287 InputEvent::Key(k)
288 if find_bar
289 && k.code == ratatui::crossterm::event::KeyCode::Char(' ')
290 && k.modifiers.is_empty() =>
291 {
292 deliver
293 }
294 _ => intent,
295 },
296
297 EditorIntent::Mouse => claimed_mouse(event, find_bar),
298 }
299}
300
301fn claimed_mouse(event: &InputEvent, find_bar: bool) -> EditorIntent {
309 use ratatui::crossterm::event::MouseEventKind;
310
311 match event {
312 InputEvent::Mouse(m)
313 if find_bar
314 && !matches!(
315 m.kind,
316 MouseEventKind::ScrollUp
317 | MouseEventKind::ScrollDown
318 | MouseEventKind::ScrollLeft
319 | MouseEventKind::ScrollRight
320 ) =>
321 {
322 EditorIntent::Consume
323 }
324 _ => EditorIntent::Mouse,
325 }
326}
327
328fn classify_unclaimed(
329 event: &InputEvent,
330 bindings: &KeyBindings,
331 ctx: &InputCtx,
332) -> Classification {
333 use ratatui::crossterm::event::{KeyCode, KeyModifiers};
334
335 let mut cancel_leader = false;
341 if ctx.leader_pending {
342 match event {
343 InputEvent::Key(key) => {
344 if ctx.overlay.is_some() {
345 cancel_leader = true;
346 } else if matches!(key.code, KeyCode::Char(_))
347 && key.modifiers.contains(KeyModifiers::CONTROL)
348 {
349 cancel_leader = true;
350 } else {
352 return Classification {
353 flash: None,
354 cancel_leader: false,
355 intent: EditorIntent::LeaderKey(*key),
356 };
357 }
358 }
359 InputEvent::Paste(_) => cancel_leader = true,
360 InputEvent::Mouse(_) => {}
362 }
363 }
364
365 if ctx.editor_active() && matches!(event, InputEvent::Paste(_)) {
368 return Classification {
369 flash: None,
370 cancel_leader,
371 intent: EditorIntent::EditorPaste,
372 };
373 }
374
375 if ctx.editor_active()
380 && let InputEvent::Key(key) = event
381 && key.modifiers == KeyModifiers::CONTROL
382 && key.code == KeyCode::Char('v')
383 {
384 return Classification {
385 flash: None,
386 cancel_leader,
387 intent: EditorIntent::ImageProbe,
388 };
389 }
390
391 classify_tail(event, bindings, ctx, cancel_leader)
392}
393
394pub(crate) fn classify_tail(
401 event: &InputEvent,
402 bindings: &KeyBindings,
403 ctx: &InputCtx,
404 cancel_leader: bool,
405) -> Classification {
406 use ratatui::crossterm::event::{KeyCode, KeyModifiers};
407
408 if ctx.editor_active()
411 && let InputEvent::Key(key) = event
412 && key.code == KeyCode::Enter
413 && key.modifiers.contains(KeyModifiers::CONTROL)
414 {
415 return Classification {
416 flash: None,
417 cancel_leader,
418 intent: EditorIntent::FollowLink,
419 };
420 }
421
422 let mut flash = None;
427 let mut shortcut_intent = None;
428 if let InputEvent::Key(key) = event
429 && let Some(combo) = key_event_to_combo(key)
430 {
431 let is_fkey = combo.key.is_fkey();
432 let action = bindings.get_action(&combo);
433 if action != Some(ActionShortcuts::Leader) && (is_fkey || combo.is_letter_chord()) {
446 flash = Some(match &action {
447 Some(a) => a.label(),
448 None => combo.to_string(),
449 });
450 }
451 shortcut_intent = match action {
452 Some(ActionShortcuts::OpenCommandPalette) => Some(EditorIntent::ToggleOverlay {
453 kind: OverlayKind::CommandPalette,
454 open: OverlayOpen::CommandPalette,
455 }),
456 Some(ActionShortcuts::Leader) => {
457 Some(if ctx.overlay.is_none() {
460 EditorIntent::LeaderStart
461 } else {
462 EditorIntent::Consume
463 })
464 }
465 Some(ActionShortcuts::ToggleSidebar) => Some(EditorIntent::Op(EditorOp::ToggleDrawer)),
466 Some(ActionShortcuts::FocusSidebar) => {
467 Some(if ctx.overlay.is_none() {
469 EditorIntent::Op(EditorOp::FocusLeft)
470 } else {
471 EditorIntent::Consume
472 })
473 }
474 Some(ActionShortcuts::FocusEditor) => Some(if ctx.overlay.is_none() {
475 EditorIntent::Op(EditorOp::FocusRight)
476 } else {
477 EditorIntent::Consume
478 }),
479 Some(ActionShortcuts::NewJournal) => Some(EditorIntent::Op(EditorOp::OpenJournal)),
480 Some(ActionShortcuts::SearchNotes) => Some(EditorIntent::ToggleOverlay {
481 kind: OverlayKind::NoteBrowser,
482 open: OverlayOpen::SearchBrowser,
483 }),
484 Some(ActionShortcuts::OpenNote) => Some(EditorIntent::ToggleOverlay {
485 kind: OverlayKind::NoteBrowser,
486 open: OverlayOpen::FileFinder,
487 }),
488 Some(ActionShortcuts::FileOperations) if ctx.editor_active() => {
489 Some(EditorIntent::Op(EditorOp::ShowFileOps))
490 }
491 Some(ActionShortcuts::FollowLink) if ctx.editor_active() => {
492 Some(EditorIntent::FollowLink)
493 }
494 Some(ActionShortcuts::ToggleQueryPanel) => {
495 Some(EditorIntent::Op(EditorOp::ToggleQueryPanel))
496 }
497 Some(ActionShortcuts::OpenFileBrowser) => {
498 Some(EditorIntent::Op(EditorOp::OpenFileBrowserReveal))
502 }
503 Some(ActionShortcuts::OpenSavedSearches) => Some(EditorIntent::ToggleOverlay {
504 kind: OverlayKind::SavedSearches,
505 open: OverlayOpen::SavedSearches,
506 }),
507 Some(ActionShortcuts::OpenAsk) => Some(EditorIntent::Op(EditorOp::OpenAsk)),
508 Some(ActionShortcuts::YankRow) => None,
515 Some(ActionShortcuts::OpenSortDialog) => {
516 if ctx.focused == PanelKind::Drawer && ctx.overlay.is_none() {
521 Some(match ctx.drawer_view {
522 DrawerView::Find => EditorIntent::OpenOverlay(OverlayOpen::SortQuery),
523 DrawerView::Files => EditorIntent::OpenOverlay(OverlayOpen::SortSidebar),
524 _ => EditorIntent::Consume,
525 })
526 } else {
527 None
528 }
529 }
530 Some(ActionShortcuts::SaveCurrentQuery) => {
531 Some(EditorIntent::Op(EditorOp::SaveCurrentQuery))
534 }
535 Some(ActionShortcuts::SwitchWorkspace) => {
536 Some(EditorIntent::OpenOverlay(OverlayOpen::WorkspaceSwitcher))
537 }
538 Some(ActionShortcuts::QuickNote) => Some(if ctx.overlay.is_none() {
539 EditorIntent::OpenOverlay(OverlayOpen::QuickNote)
540 } else {
541 EditorIntent::Consume
542 }),
543 Some(ActionShortcuts::FindInBuffer) if ctx.editor_active() => {
544 Some(EditorIntent::Op(EditorOp::FindInBuffer))
545 }
546 Some(ActionShortcuts::ReplaceInBuffer) if ctx.editor_active() => {
547 Some(EditorIntent::Op(EditorOp::ReplaceInBuffer))
548 }
549 Some(ActionShortcuts::Text(
550 action @ (TextAction::Bold | TextAction::Italic | TextAction::Strikethrough),
551 )) if ctx.editor_active() => Some(EditorIntent::Op(EditorOp::ApplyText(action))),
552 _ => {
553 if is_fkey {
554 if combo.key == KeyStrike::F1 && combo.modifiers.is_empty() {
558 Some(EditorIntent::OpenOverlay(if ctx.find_panel_focused() {
559 OverlayOpen::QueryHelp
560 } else {
561 OverlayOpen::Help
562 }))
563 } else {
564 Some(EditorIntent::Consume)
565 }
566 } else {
567 None
568 }
569 }
570 };
571 }
572
573 let done = move |intent| Classification {
576 flash,
577 cancel_leader,
578 intent,
579 };
580
581 if let Some(intent) = shortcut_intent {
582 return done(intent);
583 }
584
585 if ctx.overlay.is_some() {
587 return done(EditorIntent::Overlay);
588 }
589
590 if matches!(event, InputEvent::Mouse(_)) {
591 if ctx.double_click && ctx.editor_active() {
597 return done(EditorIntent::FollowLink);
598 }
599 return done(EditorIntent::Mouse);
600 }
601
602 if ctx.editor_active()
607 && (!ctx.leader_pending || cancel_leader)
608 && let InputEvent::Key(key) = event
609 && key.code == KeyCode::Char(' ')
610 && key.modifiers.is_empty()
611 && ctx.space_leads
612 {
613 return done(EditorIntent::LeaderStart);
614 }
615
616 if ctx.focused != PanelKind::Editor
620 && let InputEvent::Key(key) = event
621 && matches!(key.code, KeyCode::Tab | KeyCode::BackTab)
622 {
623 return done(EditorIntent::Panel {
624 fallback: PanelFallback::FocusCycle(if key.code == KeyCode::Tab {
625 CycleDir::Right
626 } else {
627 CycleDir::Left
628 }),
629 });
630 }
631
632 if ctx.find_panel_focused()
635 && let InputEvent::Key(key) = event
636 && key.code == KeyCode::Esc
637 {
638 return done(EditorIntent::Panel {
639 fallback: PanelFallback::FocusEditor,
640 });
641 }
642
643 done(EditorIntent::Panel {
644 fallback: PanelFallback::None,
645 })
646}
647
648#[cfg(test)]
649mod tests {
650 use super::*;
651 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind};
652
653 fn bindings() -> KeyBindings {
654 let mut kb = KeyBindings::empty();
655 kb.batch_add()
656 .with_ctrl()
657 .add(KeyStrike::KeyP, ActionShortcuts::OpenCommandPalette)
658 .add(KeyStrike::KeyK, ActionShortcuts::SearchNotes)
659 .add(KeyStrike::KeyG, ActionShortcuts::Leader)
660 .add(KeyStrike::KeyT, ActionShortcuts::ToggleSidebar)
661 .add(KeyStrike::KeyR, ActionShortcuts::OpenSortDialog)
662 .add(KeyStrike::KeyH, ActionShortcuts::FocusSidebar)
663 .add(KeyStrike::KeyB, ActionShortcuts::Text(TextAction::Bold))
664 .add(KeyStrike::KeyN, ActionShortcuts::FollowLink)
665 .add(KeyStrike::KeyW, ActionShortcuts::QuickNote);
666 kb.batch_add()
667 .add(KeyStrike::F2, ActionShortcuts::FileOperations);
668 kb
669 }
670
671 fn ctx() -> InputCtx {
673 InputCtx {
674 overlay: None,
675 leader_pending: false,
676 focused: PanelKind::Editor,
677 drawer_view: DrawerView::Files,
678 space_leads: false,
679 claim: EditorClaim::None,
680 double_click: false,
681 }
682 }
683
684 fn ctx_find_bar() -> InputCtx {
686 InputCtx {
687 claim: EditorClaim::FindBar,
688 ..ctx()
689 }
690 }
691
692 fn ctx_double() -> InputCtx {
695 InputCtx {
696 double_click: true,
697 ..ctx()
698 }
699 }
700
701 fn press() -> InputEvent {
705 use ratatui::crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
706 InputEvent::Mouse(MouseEvent {
707 kind: MouseEventKind::Down(MouseButton::Left),
708 column: 1,
709 row: 1,
710 modifiers: KeyModifiers::NONE,
711 })
712 }
713
714 fn key(code: KeyCode, mods: KeyModifiers) -> InputEvent {
715 InputEvent::Key(KeyEvent::new(code, mods))
716 }
717
718 fn ctrl(c: char) -> InputEvent {
719 key(KeyCode::Char(c), KeyModifiers::CONTROL)
720 }
721
722 fn plain(c: char) -> InputEvent {
723 key(KeyCode::Char(c), KeyModifiers::NONE)
724 }
725
726 fn classify_it(event: &InputEvent, ctx: &InputCtx) -> Classification {
727 classify(event, &bindings(), ctx)
728 }
729
730 #[test]
737 fn a_claim_blocks_a_buffer_edit() {
738 let ev = key(KeyCode::Char('b'), KeyModifiers::CONTROL);
739 assert_eq!(
740 classify_it(&ev, &ctx()).intent,
741 EditorIntent::Op(EditorOp::ApplyText(TextAction::Bold)),
742 "without a claim the chord still bolds"
743 );
744 assert_eq!(
745 classify_it(&ev, &ctx_find_bar()).intent,
746 EditorIntent::Panel {
747 fallback: PanelFallback::None
748 },
749 "under a claim it is delivered to the holder instead"
750 );
751 }
752
753 #[test]
756 fn a_claim_leaves_navigation_alone() {
757 for ev in [
758 key(KeyCode::Char('t'), KeyModifiers::CONTROL),
759 key(KeyCode::Char('p'), KeyModifiers::CONTROL),
760 ] {
761 assert_eq!(
762 classify_it(&ev, &ctx_find_bar()).intent,
763 classify_it(&ev, &ctx()).intent,
764 "a claim must not swallow navigation"
765 );
766 }
767 }
768
769 #[test]
772 fn a_find_bar_claim_blocks_the_paste_tiers() {
773 for ev in [
774 InputEvent::Paste("hi".into()),
775 key(KeyCode::Char('v'), KeyModifiers::CONTROL),
776 key(KeyCode::Enter, KeyModifiers::CONTROL),
777 ] {
778 assert_eq!(
779 classify_it(&ev, &ctx_find_bar()).intent,
780 EditorIntent::Panel {
781 fallback: PanelFallback::None
782 },
783 "paste, image probe and follow-link all belong to the bar"
784 );
785 }
786 }
787
788 #[test]
792 fn a_find_bar_claim_blocks_the_space_leader() {
793 let ev = key(KeyCode::Char(' '), KeyModifiers::NONE);
794 let vim_ctx = InputCtx {
795 space_leads: true,
796 ..ctx()
797 };
798 assert_eq!(classify_it(&ev, &vim_ctx).intent, EditorIntent::LeaderStart);
799 let vim_bar = InputCtx {
800 space_leads: true,
801 ..ctx_find_bar()
802 };
803 assert_eq!(
804 classify_it(&ev, &vim_bar).intent,
805 EditorIntent::Panel {
806 fallback: PanelFallback::None
807 }
808 );
809 }
810
811 #[test]
815 fn a_find_bar_claim_blocks_clicks_but_not_scrolling() {
816 use ratatui::crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
817 let at = |kind| {
818 InputEvent::Mouse(MouseEvent {
819 kind,
820 column: 1,
821 row: 1,
822 modifiers: KeyModifiers::NONE,
823 })
824 };
825 assert_eq!(
826 classify_it(
827 &at(MouseEventKind::Down(MouseButton::Left)),
828 &ctx_find_bar()
829 )
830 .intent,
831 EditorIntent::Consume,
832 "a click reaches nothing while the bar holds the claim"
833 );
834 assert_eq!(
835 classify_it(&at(MouseEventKind::ScrollUp), &ctx_find_bar()).intent,
836 EditorIntent::Mouse,
837 "scrolling still works"
838 );
839 }
840
841 #[test]
847 fn a_single_press_only_reaches_the_editor() {
848 assert_eq!(classify_it(&press(), &ctx()).intent, EditorIntent::Mouse);
849 }
850
851 #[test]
854 fn a_double_click_follows_the_link_under_it() {
855 assert_eq!(
856 classify_it(&press(), &ctx_double()).intent,
857 EditorIntent::FollowLink,
858 "the same intent Ctrl+N produces"
859 );
860 assert_eq!(
861 classify_it(&key(KeyCode::Char('n'), KeyModifiers::CONTROL), &ctx()).intent,
862 EditorIntent::FollowLink,
863 "and Ctrl+N still produces it"
864 );
865 }
866
867 #[test]
871 fn a_double_click_outside_the_editor_is_an_ordinary_click() {
872 let drawer = InputCtx {
873 focused: PanelKind::Drawer,
874 ..ctx_double()
875 };
876 assert_eq!(classify_it(&press(), &drawer).intent, EditorIntent::Mouse);
877 }
878
879 #[test]
882 fn an_overlay_outranks_a_double_click() {
883 let covered = InputCtx {
884 overlay: Some(OverlayKind::NoteBrowser),
885 ..ctx_double()
886 };
887 assert_eq!(
888 classify_it(&press(), &covered).intent,
889 EditorIntent::Overlay
890 );
891 }
892
893 #[test]
897 fn a_find_bar_claim_swallows_a_double_click_but_takes_ctrl_enter() {
898 let bar_double = InputCtx {
899 double_click: true,
900 ..ctx_find_bar()
901 };
902 assert_eq!(
903 classify_it(&press(), &bar_double).intent,
904 EditorIntent::Consume,
905 "a follow that arrived as a click is still a click"
906 );
907 assert_eq!(
908 classify_it(&key(KeyCode::Enter, KeyModifiers::CONTROL), &ctx_find_bar()).intent,
909 EditorIntent::Panel {
910 fallback: PanelFallback::None
911 },
912 "but a follow that arrived as a key is delivered to the bar"
913 );
914 }
915
916 #[test]
922 fn an_autocomplete_claim_turns_a_double_click_back_into_a_press() {
923 let popup = InputCtx {
924 claim: EditorClaim::Autocomplete,
925 double_click: true,
926 ..ctx()
927 };
928 assert_eq!(classify_it(&press(), &popup).intent, EditorIntent::Mouse);
929 assert_eq!(
930 classify_it(&key(KeyCode::Char('n'), KeyModifiers::CONTROL), &popup).intent,
931 EditorIntent::FollowLink,
932 "the popup does not own the keyboard follow"
933 );
934 }
935
936 #[test]
940 fn an_autocomplete_claim_blocks_only_buffer_edits() {
941 let popup = InputCtx {
942 claim: EditorClaim::Autocomplete,
943 ..ctx()
944 };
945 assert_eq!(
946 classify_it(&InputEvent::Paste("hi".into()), &popup).intent,
947 EditorIntent::EditorPaste,
948 "the popup does not own pastes"
949 );
950 assert_eq!(
951 classify_it(&key(KeyCode::Char('b'), KeyModifiers::CONTROL), &popup).intent,
952 EditorIntent::Panel {
953 fallback: PanelFallback::None
954 },
955 "but a buffer edit still loses to any holder"
956 );
957 }
958
959 #[test]
961 fn the_leader_outranks_a_claim() {
962 let ev = key(KeyCode::Char('x'), KeyModifiers::NONE);
963 let pending = InputCtx {
964 leader_pending: true,
965 ..ctx_find_bar()
966 };
967 assert!(matches!(
968 classify_it(&ev, &pending).intent,
969 EditorIntent::LeaderKey(_)
970 ));
971 }
972
973 #[test]
976 fn bracketed_paste_in_editor_is_editor_paste() {
977 let c = classify_it(&InputEvent::Paste("hi".into()), &ctx());
978 assert_eq!(c.intent, EditorIntent::EditorPaste);
979 }
980
981 #[test]
982 fn bracketed_paste_with_overlay_routes_to_overlay() {
983 let mut cx = ctx();
984 cx.overlay = Some(OverlayKind::NoteBrowser);
985 let c = classify_it(&InputEvent::Paste("hi".into()), &cx);
986 assert_eq!(c.intent, EditorIntent::Overlay);
987 }
988
989 #[test]
990 fn bracketed_paste_drawer_focused_goes_to_panel() {
991 let mut cx = ctx();
992 cx.focused = PanelKind::Drawer;
993 let c = classify_it(&InputEvent::Paste("hi".into()), &cx);
994 assert_eq!(
995 c.intent,
996 EditorIntent::Panel {
997 fallback: PanelFallback::None
998 }
999 );
1000 }
1001
1002 #[test]
1003 fn ctrl_v_in_editor_is_a_bare_image_probe() {
1004 let c = classify_it(&ctrl('v'), &ctx());
1007 assert_eq!(c.intent, EditorIntent::ImageProbe);
1008 assert_eq!(c.flash, None, "flash belongs to the no-image tail only");
1009 }
1010
1011 #[test]
1012 fn ctrl_v_no_image_tail_reaches_panel_with_a_flash() {
1013 let c = classify_tail(&ctrl('v'), &bindings(), &ctx(), false);
1018 assert_eq!(
1019 c.intent,
1020 EditorIntent::Panel {
1021 fallback: PanelFallback::None
1022 }
1023 );
1024 assert!(c.flash.is_some());
1025 assert!(!c.cancel_leader, "the outer probe already owned the cancel");
1026 }
1027
1028 #[test]
1029 fn leader_pending_ctrl_v_cancels_leader_then_probes_image() {
1030 let mut cx = ctx();
1036 cx.leader_pending = true;
1037 let c = classify_it(&ctrl('v'), &cx);
1038 assert!(c.cancel_leader);
1039 assert_eq!(c.intent, EditorIntent::ImageProbe);
1040 }
1041
1042 #[test]
1043 fn leader_pending_bracketed_paste_cancels_leader_then_pastes() {
1044 let mut cx = ctx();
1047 cx.leader_pending = true;
1048 let c = classify_it(&InputEvent::Paste("hi".into()), &cx);
1049 assert!(c.cancel_leader);
1050 assert_eq!(c.intent, EditorIntent::EditorPaste);
1051 }
1052
1053 #[test]
1054 fn leader_pending_ctrl_enter_feeds_leader() {
1055 let mut cx = ctx();
1058 cx.leader_pending = true;
1059 let c = classify_it(&key(KeyCode::Enter, KeyModifiers::CONTROL), &cx);
1060 assert_eq!(
1061 c.intent,
1062 EditorIntent::LeaderKey(KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL))
1063 );
1064 assert!(!c.cancel_leader);
1065 }
1066
1067 #[test]
1068 fn ctrl_enter_in_editor_follows_link() {
1069 let c = classify_it(&key(KeyCode::Enter, KeyModifiers::CONTROL), &ctx());
1070 assert_eq!(c.intent, EditorIntent::FollowLink);
1071 }
1072
1073 #[test]
1076 fn leader_pending_plain_key_feeds_leader() {
1077 let mut cx = ctx();
1078 cx.leader_pending = true;
1079 let c = classify_it(&plain('f'), &cx);
1080 assert_eq!(
1081 c.intent,
1082 EditorIntent::LeaderKey(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE))
1083 );
1084 assert!(!c.cancel_leader);
1085 }
1086
1087 #[test]
1088 fn leader_pending_with_overlay_cancels_and_routes_to_overlay() {
1089 let mut cx = ctx();
1090 cx.leader_pending = true;
1091 cx.overlay = Some(OverlayKind::Dialog);
1092 let c = classify_it(&plain('f'), &cx);
1093 assert!(c.cancel_leader);
1094 assert_eq!(c.intent, EditorIntent::Overlay);
1095 }
1096
1097 #[test]
1098 fn leader_pending_ctrl_chord_cancels_then_dispatches() {
1099 let mut cx = ctx();
1100 cx.leader_pending = true;
1101 let c = classify_it(&ctrl('p'), &cx);
1102 assert!(c.cancel_leader);
1103 assert_eq!(
1104 c.intent,
1105 EditorIntent::ToggleOverlay {
1106 kind: OverlayKind::CommandPalette,
1107 open: OverlayOpen::CommandPalette
1108 }
1109 );
1110 }
1111
1112 #[test]
1115 fn command_palette_chord_toggles_regardless_of_open_state() {
1116 let c = classify_it(&ctrl('p'), &ctx());
1119 assert_eq!(
1120 c.intent,
1121 EditorIntent::ToggleOverlay {
1122 kind: OverlayKind::CommandPalette,
1123 open: OverlayOpen::CommandPalette
1124 }
1125 );
1126 assert!(c.flash.is_some());
1127
1128 let mut cx = ctx();
1129 cx.overlay = Some(OverlayKind::CommandPalette);
1130 let c = classify_it(&ctrl('p'), &cx);
1131 assert_eq!(
1132 c.intent,
1133 EditorIntent::ToggleOverlay {
1134 kind: OverlayKind::CommandPalette,
1135 open: OverlayOpen::CommandPalette
1136 }
1137 );
1138 }
1139
1140 #[test]
1141 fn leader_gateway_starts_sequence_without_flash() {
1142 let c = classify_it(&ctrl('g'), &ctx());
1143 assert_eq!(c.intent, EditorIntent::LeaderStart);
1144 assert_eq!(c.flash, None);
1145 }
1146
1147 #[test]
1148 fn leader_gateway_with_overlay_is_consumed_noop() {
1149 let mut cx = ctx();
1150 cx.overlay = Some(OverlayKind::NoteBrowser);
1151 let c = classify_it(&ctrl('g'), &cx);
1152 assert_eq!(c.intent, EditorIntent::Consume);
1153 }
1154
1155 #[test]
1156 fn focus_sidebar_with_overlay_is_consumed_noop() {
1157 let mut cx = ctx();
1158 cx.overlay = Some(OverlayKind::NoteBrowser);
1159 let c = classify_it(&ctrl('h'), &cx);
1160 assert_eq!(c.intent, EditorIntent::Consume);
1161 }
1162
1163 #[test]
1164 fn toggle_drawer_chord_is_action() {
1165 let c = classify_it(&ctrl('t'), &ctx());
1166 assert_eq!(c.intent, EditorIntent::Op(EditorOp::ToggleDrawer));
1167 }
1168
1169 #[test]
1170 fn quick_note_opens_dialog_only_without_overlay() {
1171 let c = classify_it(&ctrl('w'), &ctx());
1172 assert_eq!(c.intent, EditorIntent::OpenOverlay(OverlayOpen::QuickNote));
1173
1174 let mut cx = ctx();
1175 cx.overlay = Some(OverlayKind::Dialog);
1176 let c = classify_it(&ctrl('w'), &cx);
1177 assert_eq!(c.intent, EditorIntent::Consume);
1178 }
1179
1180 #[test]
1181 fn text_style_chord_needs_active_editor() {
1182 let c = classify_it(&ctrl('b'), &ctx());
1183 assert_eq!(
1184 c.intent,
1185 EditorIntent::Op(EditorOp::ApplyText(TextAction::Bold))
1186 );
1187
1188 let mut cx = ctx();
1191 cx.focused = PanelKind::Drawer;
1192 let c = classify_it(&ctrl('b'), &cx);
1193 assert_eq!(
1194 c.intent,
1195 EditorIntent::Panel {
1196 fallback: PanelFallback::None
1197 }
1198 );
1199 }
1200
1201 #[test]
1202 fn sort_dialog_targets_the_focused_drawer_view() {
1203 let mut cx = ctx();
1204 cx.focused = PanelKind::Drawer;
1205 cx.drawer_view = DrawerView::Find;
1206 let c = classify_it(&ctrl('r'), &cx);
1207 assert_eq!(c.intent, EditorIntent::OpenOverlay(OverlayOpen::SortQuery));
1208
1209 cx.drawer_view = DrawerView::Files;
1210 let c = classify_it(&ctrl('r'), &cx);
1211 assert_eq!(
1212 c.intent,
1213 EditorIntent::OpenOverlay(OverlayOpen::SortSidebar)
1214 );
1215
1216 cx.drawer_view = DrawerView::Tags;
1218 let c = classify_it(&ctrl('r'), &cx);
1219 assert_eq!(c.intent, EditorIntent::Consume);
1220 }
1221
1222 #[test]
1223 fn sort_dialog_chord_falls_through_when_editor_focused() {
1224 let c = classify_it(&ctrl('r'), &ctx());
1226 assert_eq!(
1227 c.intent,
1228 EditorIntent::Panel {
1229 fallback: PanelFallback::None
1230 }
1231 );
1232 }
1233
1234 #[test]
1235 fn f1_opens_help_or_query_help_by_focus() {
1236 let c = classify_it(&key(KeyCode::F(1), KeyModifiers::NONE), &ctx());
1237 assert_eq!(c.intent, EditorIntent::OpenOverlay(OverlayOpen::Help));
1238
1239 let mut cx = ctx();
1240 cx.focused = PanelKind::Drawer;
1241 cx.drawer_view = DrawerView::Find;
1242 let c = classify_it(&key(KeyCode::F(1), KeyModifiers::NONE), &cx);
1243 assert_eq!(c.intent, EditorIntent::OpenOverlay(OverlayOpen::QueryHelp));
1244 }
1245
1246 #[test]
1247 fn unbound_fkeys_are_sunk_with_a_flash() {
1248 let c = classify_it(&key(KeyCode::F(9), KeyModifiers::NONE), &ctx());
1249 assert_eq!(c.intent, EditorIntent::Consume);
1250 assert!(c.flash.is_some());
1251 }
1252
1253 #[test]
1256 fn a_bound_chord_flashes_the_action_label() {
1257 let c = classify_it(&ctrl('k'), &ctx());
1258 assert_eq!(
1259 c.flash.as_deref(),
1260 Some(ActionShortcuts::SearchNotes.label().as_str())
1261 );
1262 }
1263
1264 #[test]
1267 fn an_unbound_chord_still_flashes_the_raw_chord() {
1268 let c = classify_it(&ctrl('y'), &ctx());
1270 let combo =
1271 key_event_to_combo(&KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL)).unwrap();
1272 assert_eq!(c.flash.as_deref(), Some(combo.to_string().as_str()));
1273 }
1274
1275 #[test]
1276 fn high_fkeys_are_sunk_like_the_rest() {
1277 let c = classify_it(&key(KeyCode::F(13), KeyModifiers::NONE), &ctx());
1282 assert_eq!(c.intent, EditorIntent::Consume);
1283 }
1284
1285 #[test]
1286 fn bound_fkey_dispatches_when_guard_holds_and_sinks_when_not() {
1287 let c = classify_it(&key(KeyCode::F(2), KeyModifiers::NONE), &ctx());
1289 assert_eq!(c.intent, EditorIntent::Op(EditorOp::ShowFileOps));
1290
1291 let mut cx = ctx();
1292 cx.focused = PanelKind::Drawer;
1293 let c = classify_it(&key(KeyCode::F(2), KeyModifiers::NONE), &cx);
1294 assert_eq!(c.intent, EditorIntent::Consume);
1295 }
1296
1297 #[test]
1300 fn overlay_intercepts_unbound_keys() {
1301 let mut cx = ctx();
1302 cx.overlay = Some(OverlayKind::SavedSearches);
1303 let c = classify_it(&plain('x'), &cx);
1304 assert_eq!(c.intent, EditorIntent::Overlay);
1305 }
1306
1307 #[test]
1308 fn mouse_events_take_the_hit_test_path() {
1309 let ev = InputEvent::Mouse(MouseEvent {
1310 kind: MouseEventKind::Moved,
1311 column: 3,
1312 row: 4,
1313 modifiers: KeyModifiers::NONE,
1314 });
1315 let c = classify_it(&ev, &ctx());
1316 assert_eq!(c.intent, EditorIntent::Mouse);
1317 }
1318
1319 #[test]
1320 fn vim_space_starts_leader_only_when_it_leads() {
1321 let mut cx = ctx();
1322 cx.space_leads = true;
1323 let c = classify_it(&plain(' '), &cx);
1324 assert_eq!(c.intent, EditorIntent::LeaderStart);
1325
1326 cx.space_leads = false;
1327 let c = classify_it(&plain(' '), &cx);
1328 assert_eq!(
1329 c.intent,
1330 EditorIntent::Panel {
1331 fallback: PanelFallback::None
1332 }
1333 );
1334 }
1335
1336 #[test]
1337 fn tab_cycles_focus_when_a_non_editor_panel_passes() {
1338 let mut cx = ctx();
1339 cx.focused = PanelKind::Drawer;
1340 let c = classify_it(&key(KeyCode::Tab, KeyModifiers::NONE), &cx);
1341 assert_eq!(
1342 c.intent,
1343 EditorIntent::Panel {
1344 fallback: PanelFallback::FocusCycle(CycleDir::Right)
1345 }
1346 );
1347
1348 let c = classify_it(&key(KeyCode::BackTab, KeyModifiers::SHIFT), &cx);
1349 assert_eq!(
1350 c.intent,
1351 EditorIntent::Panel {
1352 fallback: PanelFallback::FocusCycle(CycleDir::Left)
1353 }
1354 );
1355 }
1356
1357 #[test]
1358 fn editor_keeps_tab_for_indentation() {
1359 let c = classify_it(&key(KeyCode::Tab, KeyModifiers::NONE), &ctx());
1360 assert_eq!(
1361 c.intent,
1362 EditorIntent::Panel {
1363 fallback: PanelFallback::None
1364 }
1365 );
1366 }
1367
1368 #[test]
1369 fn find_view_yields_focus_to_editor_on_unhandled_esc() {
1370 let mut cx = ctx();
1371 cx.focused = PanelKind::Drawer;
1372 cx.drawer_view = DrawerView::Find;
1373 let c = classify_it(&key(KeyCode::Esc, KeyModifiers::NONE), &cx);
1374 assert_eq!(
1375 c.intent,
1376 EditorIntent::Panel {
1377 fallback: PanelFallback::FocusEditor
1378 }
1379 );
1380
1381 let c = classify_it(&plain('x'), &cx);
1383 assert_eq!(
1384 c.intent,
1385 EditorIntent::Panel {
1386 fallback: PanelFallback::None
1387 }
1388 );
1389 }
1390}