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 pub(crate) fn accepts_text_action(&self) -> bool {
79 self.editor_active() && self.claim == EditorClaim::None
80 }
81}
82
83#[derive(Debug, Clone, PartialEq)]
86pub struct Classification {
87 pub flash: Option<String>,
90 pub cancel_leader: bool,
93 pub intent: EditorIntent,
94}
95
96#[derive(Debug, Clone, PartialEq)]
100pub enum EditorIntent {
101 Consume,
103 EditorPaste,
108 ImageProbe,
115 FollowLink,
120 LeaderKey(KeyEvent),
122 LeaderStart,
124 Op(EditorOp),
128 ToggleOverlay {
130 kind: OverlayKind,
131 open: OverlayOpen,
132 },
133 OpenOverlay(OverlayOpen),
136 Overlay,
138 Mouse,
140 Panel { fallback: PanelFallback },
143}
144
145#[derive(Debug, Clone, Copy, PartialEq)]
151pub enum OverlayOpen {
152 SearchBrowser,
153 FileFinder,
154 SavedSearches,
155 CommandPalette,
156 WorkspaceSwitcher,
157 ThemePicker,
158 Help,
160 QueryHelp,
162 Cheatsheet,
164 SortQuery,
165 SortSidebar,
166 QuickNote,
167 PinnedNotes,
169}
170
171#[derive(Debug, Clone, Copy, PartialEq)]
173pub enum PanelFallback {
174 None,
175 FocusCycle(CycleDir),
177 FocusEditor,
179}
180
181#[derive(Debug, Clone, Copy, PartialEq)]
182pub enum CycleDir {
183 Left,
184 Right,
185}
186
187#[derive(Debug, Clone, PartialEq)]
188pub enum EditorOp {
189 ToggleDrawer,
190 FocusLeft,
191 FocusRight,
192 OpenJournal,
193 ShowFileOps,
194 ToggleQueryPanel,
195 OpenFileBrowserReveal,
198 SaveCurrentQuery,
199 FindInBuffer,
200 ReplaceInBuffer,
202 ApplyText(TextAction),
203 OpenAsk,
206}
207
208pub fn classify(event: &InputEvent, bindings: &KeyBindings, ctx: &InputCtx) -> Classification {
216 let mut classification = classify_unclaimed(event, bindings, ctx);
217 let claim = if ctx.editor_active() {
221 ctx.claim
222 } else {
223 EditorClaim::None
224 };
225 let filtered = apply_claim(classification.intent.clone(), event, claim);
226 if filtered != classification.intent {
227 classification.flash = None;
229 }
230 classification.intent = filtered;
231 classification
232}
233
234fn apply_claim(intent: EditorIntent, event: &InputEvent, claim: EditorClaim) -> EditorIntent {
246 if claim == EditorClaim::None {
247 return intent;
248 }
249 let deliver = EditorIntent::Panel {
250 fallback: PanelFallback::None,
251 };
252 let find_bar = claim == EditorClaim::FindBar;
253 match intent {
254 EditorIntent::LeaderKey(_)
259 | EditorIntent::Panel { .. }
260 | EditorIntent::Overlay
261 | EditorIntent::ToggleOverlay { .. }
262 | EditorIntent::OpenOverlay(_)
263 | EditorIntent::Consume => intent,
264
265 EditorIntent::Op(EditorOp::ApplyText(_)) => deliver,
268 EditorIntent::Op(_) => intent,
269
270 EditorIntent::EditorPaste | EditorIntent::ImageProbe => {
273 if find_bar {
274 deliver
275 } else {
276 intent
277 }
278 }
279
280 EditorIntent::FollowLink => match event {
288 InputEvent::Mouse(_) => claimed_mouse(event, find_bar),
289 _ if find_bar => deliver,
290 _ => intent,
291 },
292
293 EditorIntent::LeaderStart => match event {
297 InputEvent::Key(k)
298 if find_bar
299 && k.code == ratatui::crossterm::event::KeyCode::Char(' ')
300 && k.modifiers.is_empty() =>
301 {
302 deliver
303 }
304 _ => intent,
305 },
306
307 EditorIntent::Mouse => claimed_mouse(event, find_bar),
308 }
309}
310
311fn claimed_mouse(event: &InputEvent, find_bar: bool) -> EditorIntent {
319 use ratatui::crossterm::event::MouseEventKind;
320
321 match event {
322 InputEvent::Mouse(m)
323 if find_bar
324 && !matches!(
325 m.kind,
326 MouseEventKind::ScrollUp
327 | MouseEventKind::ScrollDown
328 | MouseEventKind::ScrollLeft
329 | MouseEventKind::ScrollRight
330 ) =>
331 {
332 EditorIntent::Consume
333 }
334 _ => EditorIntent::Mouse,
335 }
336}
337
338fn classify_unclaimed(
339 event: &InputEvent,
340 bindings: &KeyBindings,
341 ctx: &InputCtx,
342) -> Classification {
343 use ratatui::crossterm::event::{KeyCode, KeyModifiers};
344
345 let mut cancel_leader = false;
351 if ctx.leader_pending {
352 match event {
353 InputEvent::Key(key) => {
354 if ctx.overlay.is_some() {
355 cancel_leader = true;
356 } else if matches!(key.code, KeyCode::Char(_))
357 && key.modifiers.contains(KeyModifiers::CONTROL)
358 {
359 cancel_leader = true;
360 } else {
362 return Classification {
363 flash: None,
364 cancel_leader: false,
365 intent: EditorIntent::LeaderKey(*key),
366 };
367 }
368 }
369 InputEvent::Paste(_) => cancel_leader = true,
370 InputEvent::Mouse(_) => {}
372 }
373 }
374
375 if ctx.editor_active() && matches!(event, InputEvent::Paste(_)) {
378 return Classification {
379 flash: None,
380 cancel_leader,
381 intent: EditorIntent::EditorPaste,
382 };
383 }
384
385 if ctx.editor_active()
390 && let InputEvent::Key(key) = event
391 && key.modifiers == KeyModifiers::CONTROL
392 && key.code == KeyCode::Char('v')
393 {
394 return Classification {
395 flash: None,
396 cancel_leader,
397 intent: EditorIntent::ImageProbe,
398 };
399 }
400
401 classify_tail(event, bindings, ctx, cancel_leader)
402}
403
404pub(crate) fn classify_tail(
411 event: &InputEvent,
412 bindings: &KeyBindings,
413 ctx: &InputCtx,
414 cancel_leader: bool,
415) -> Classification {
416 use ratatui::crossterm::event::{KeyCode, KeyModifiers};
417
418 if ctx.editor_active()
421 && let InputEvent::Key(key) = event
422 && key.code == KeyCode::Enter
423 && key.modifiers.contains(KeyModifiers::CONTROL)
424 {
425 return Classification {
426 flash: None,
427 cancel_leader,
428 intent: EditorIntent::FollowLink,
429 };
430 }
431
432 let mut flash = None;
437 let mut shortcut_intent = None;
438 if let InputEvent::Key(key) = event
439 && let Some(combo) = key_event_to_combo(key)
440 {
441 let is_fkey = combo.key.is_fkey();
442 let action = bindings.get_action(&combo);
443 if action != Some(ActionShortcuts::Leader) && (is_fkey || combo.is_letter_chord()) {
456 flash = Some(match &action {
457 Some(a) => a.label(),
458 None => combo.to_string(),
459 });
460 }
461 shortcut_intent = match action {
462 Some(ActionShortcuts::OpenCommandPalette) => Some(EditorIntent::ToggleOverlay {
463 kind: OverlayKind::CommandPalette,
464 open: OverlayOpen::CommandPalette,
465 }),
466 Some(ActionShortcuts::Leader) => {
467 Some(if ctx.overlay.is_none() {
470 EditorIntent::LeaderStart
471 } else {
472 EditorIntent::Consume
473 })
474 }
475 Some(ActionShortcuts::ToggleSidebar) => Some(EditorIntent::Op(EditorOp::ToggleDrawer)),
476 Some(ActionShortcuts::FocusSidebar) => {
477 Some(if ctx.overlay.is_none() {
479 EditorIntent::Op(EditorOp::FocusLeft)
480 } else {
481 EditorIntent::Consume
482 })
483 }
484 Some(ActionShortcuts::FocusEditor) => Some(if ctx.overlay.is_none() {
485 EditorIntent::Op(EditorOp::FocusRight)
486 } else {
487 EditorIntent::Consume
488 }),
489 Some(ActionShortcuts::NewJournal) => Some(EditorIntent::Op(EditorOp::OpenJournal)),
490 Some(ActionShortcuts::SearchNotes) => Some(EditorIntent::ToggleOverlay {
491 kind: OverlayKind::NoteBrowser,
492 open: OverlayOpen::SearchBrowser,
493 }),
494 Some(ActionShortcuts::OpenNote) => Some(EditorIntent::ToggleOverlay {
495 kind: OverlayKind::NoteBrowser,
496 open: OverlayOpen::FileFinder,
497 }),
498 Some(ActionShortcuts::FileOperations) if ctx.editor_active() => {
499 Some(EditorIntent::Op(EditorOp::ShowFileOps))
500 }
501 Some(ActionShortcuts::FollowLink) if ctx.editor_active() => {
502 Some(EditorIntent::FollowLink)
503 }
504 Some(ActionShortcuts::ToggleQueryPanel) => {
505 Some(EditorIntent::Op(EditorOp::ToggleQueryPanel))
506 }
507 Some(ActionShortcuts::OpenFileBrowser) => {
508 Some(EditorIntent::Op(EditorOp::OpenFileBrowserReveal))
512 }
513 Some(ActionShortcuts::OpenSavedSearches) => Some(EditorIntent::ToggleOverlay {
514 kind: OverlayKind::SavedSearches,
515 open: OverlayOpen::SavedSearches,
516 }),
517 Some(ActionShortcuts::OpenAsk) => Some(EditorIntent::Op(EditorOp::OpenAsk)),
518 Some(ActionShortcuts::YankRow) => None,
525 Some(ActionShortcuts::OpenSortDialog) => {
526 if ctx.focused == PanelKind::Drawer && ctx.overlay.is_none() {
531 Some(match ctx.drawer_view {
532 DrawerView::Find => EditorIntent::OpenOverlay(OverlayOpen::SortQuery),
533 DrawerView::Files => EditorIntent::OpenOverlay(OverlayOpen::SortSidebar),
534 _ => EditorIntent::Consume,
535 })
536 } else {
537 None
538 }
539 }
540 Some(ActionShortcuts::SaveCurrentQuery) => {
541 Some(EditorIntent::Op(EditorOp::SaveCurrentQuery))
544 }
545 Some(ActionShortcuts::SwitchWorkspace) => {
546 Some(EditorIntent::OpenOverlay(OverlayOpen::WorkspaceSwitcher))
547 }
548 Some(ActionShortcuts::QuickNote) => Some(if ctx.overlay.is_none() {
549 EditorIntent::OpenOverlay(OverlayOpen::QuickNote)
550 } else {
551 EditorIntent::Consume
552 }),
553 Some(ActionShortcuts::FindInBuffer) if ctx.editor_active() => {
554 Some(EditorIntent::Op(EditorOp::FindInBuffer))
555 }
556 Some(ActionShortcuts::ReplaceInBuffer) if ctx.editor_active() => {
557 Some(EditorIntent::Op(EditorOp::ReplaceInBuffer))
558 }
559 Some(ActionShortcuts::Text(
560 action @ (TextAction::Bold | TextAction::Italic | TextAction::Strikethrough),
561 )) if ctx.editor_active() => Some(EditorIntent::Op(EditorOp::ApplyText(action))),
562 _ => {
563 if is_fkey {
564 if combo.key == KeyStrike::F1 && combo.modifiers.is_empty() {
568 Some(EditorIntent::OpenOverlay(if ctx.find_panel_focused() {
569 OverlayOpen::QueryHelp
570 } else {
571 OverlayOpen::Help
572 }))
573 } else {
574 Some(EditorIntent::Consume)
575 }
576 } else {
577 None
578 }
579 }
580 };
581 }
582
583 let done = move |intent| Classification {
586 flash,
587 cancel_leader,
588 intent,
589 };
590
591 if let Some(intent) = shortcut_intent {
592 return done(intent);
593 }
594
595 if ctx.overlay.is_some() {
597 return done(EditorIntent::Overlay);
598 }
599
600 if matches!(event, InputEvent::Mouse(_)) {
601 if ctx.double_click && ctx.editor_active() {
607 return done(EditorIntent::FollowLink);
608 }
609 return done(EditorIntent::Mouse);
610 }
611
612 if ctx.editor_active()
617 && (!ctx.leader_pending || cancel_leader)
618 && let InputEvent::Key(key) = event
619 && key.code == KeyCode::Char(' ')
620 && key.modifiers.is_empty()
621 && ctx.space_leads
622 {
623 return done(EditorIntent::LeaderStart);
624 }
625
626 if ctx.focused != PanelKind::Editor
630 && let InputEvent::Key(key) = event
631 && matches!(key.code, KeyCode::Tab | KeyCode::BackTab)
632 {
633 return done(EditorIntent::Panel {
634 fallback: PanelFallback::FocusCycle(if key.code == KeyCode::Tab {
635 CycleDir::Right
636 } else {
637 CycleDir::Left
638 }),
639 });
640 }
641
642 if ctx.find_panel_focused()
645 && let InputEvent::Key(key) = event
646 && key.code == KeyCode::Esc
647 {
648 return done(EditorIntent::Panel {
649 fallback: PanelFallback::FocusEditor,
650 });
651 }
652
653 done(EditorIntent::Panel {
654 fallback: PanelFallback::None,
655 })
656}
657
658#[cfg(test)]
659mod tests {
660 use super::*;
661 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind};
662
663 fn bindings() -> KeyBindings {
664 let mut kb = KeyBindings::empty();
665 kb.batch_add()
666 .with_ctrl()
667 .add(KeyStrike::KeyP, ActionShortcuts::OpenCommandPalette)
668 .add(KeyStrike::KeyK, ActionShortcuts::SearchNotes)
669 .add(KeyStrike::KeyG, ActionShortcuts::Leader)
670 .add(KeyStrike::KeyT, ActionShortcuts::ToggleSidebar)
671 .add(KeyStrike::KeyR, ActionShortcuts::OpenSortDialog)
672 .add(KeyStrike::KeyH, ActionShortcuts::FocusSidebar)
673 .add(KeyStrike::KeyB, ActionShortcuts::Text(TextAction::Bold))
674 .add(KeyStrike::KeyN, ActionShortcuts::FollowLink)
675 .add(KeyStrike::KeyW, ActionShortcuts::QuickNote);
676 kb.batch_add()
677 .add(KeyStrike::F2, ActionShortcuts::FileOperations);
678 kb
679 }
680
681 fn ctx() -> InputCtx {
683 InputCtx {
684 overlay: None,
685 leader_pending: false,
686 focused: PanelKind::Editor,
687 drawer_view: DrawerView::Files,
688 space_leads: false,
689 claim: EditorClaim::None,
690 double_click: false,
691 }
692 }
693
694 fn ctx_find_bar() -> InputCtx {
696 InputCtx {
697 claim: EditorClaim::FindBar,
698 ..ctx()
699 }
700 }
701
702 fn ctx_double() -> InputCtx {
705 InputCtx {
706 double_click: true,
707 ..ctx()
708 }
709 }
710
711 fn press() -> InputEvent {
715 use ratatui::crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
716 InputEvent::Mouse(MouseEvent {
717 kind: MouseEventKind::Down(MouseButton::Left),
718 column: 1,
719 row: 1,
720 modifiers: KeyModifiers::NONE,
721 })
722 }
723
724 fn key(code: KeyCode, mods: KeyModifiers) -> InputEvent {
725 InputEvent::Key(KeyEvent::new(code, mods))
726 }
727
728 fn ctrl(c: char) -> InputEvent {
729 key(KeyCode::Char(c), KeyModifiers::CONTROL)
730 }
731
732 fn plain(c: char) -> InputEvent {
733 key(KeyCode::Char(c), KeyModifiers::NONE)
734 }
735
736 fn classify_it(event: &InputEvent, ctx: &InputCtx) -> Classification {
737 classify(event, &bindings(), ctx)
738 }
739
740 #[test]
747 fn a_claim_blocks_a_buffer_edit() {
748 let ev = key(KeyCode::Char('b'), KeyModifiers::CONTROL);
749 assert_eq!(
750 classify_it(&ev, &ctx()).intent,
751 EditorIntent::Op(EditorOp::ApplyText(TextAction::Bold)),
752 "without a claim the chord still bolds"
753 );
754 assert_eq!(
755 classify_it(&ev, &ctx_find_bar()).intent,
756 EditorIntent::Panel {
757 fallback: PanelFallback::None
758 },
759 "under a claim it is delivered to the holder instead"
760 );
761 }
762
763 #[test]
766 fn a_claim_leaves_navigation_alone() {
767 for ev in [
768 key(KeyCode::Char('t'), KeyModifiers::CONTROL),
769 key(KeyCode::Char('p'), KeyModifiers::CONTROL),
770 ] {
771 assert_eq!(
772 classify_it(&ev, &ctx_find_bar()).intent,
773 classify_it(&ev, &ctx()).intent,
774 "a claim must not swallow navigation"
775 );
776 }
777 }
778
779 #[test]
782 fn a_find_bar_claim_blocks_the_paste_tiers() {
783 for ev in [
784 InputEvent::Paste("hi".into()),
785 key(KeyCode::Char('v'), KeyModifiers::CONTROL),
786 key(KeyCode::Enter, KeyModifiers::CONTROL),
787 ] {
788 assert_eq!(
789 classify_it(&ev, &ctx_find_bar()).intent,
790 EditorIntent::Panel {
791 fallback: PanelFallback::None
792 },
793 "paste, image probe and follow-link all belong to the bar"
794 );
795 }
796 }
797
798 #[test]
802 fn a_find_bar_claim_blocks_the_space_leader() {
803 let ev = key(KeyCode::Char(' '), KeyModifiers::NONE);
804 let vim_ctx = InputCtx {
805 space_leads: true,
806 ..ctx()
807 };
808 assert_eq!(classify_it(&ev, &vim_ctx).intent, EditorIntent::LeaderStart);
809 let vim_bar = InputCtx {
810 space_leads: true,
811 ..ctx_find_bar()
812 };
813 assert_eq!(
814 classify_it(&ev, &vim_bar).intent,
815 EditorIntent::Panel {
816 fallback: PanelFallback::None
817 }
818 );
819 }
820
821 #[test]
825 fn a_find_bar_claim_blocks_clicks_but_not_scrolling() {
826 use ratatui::crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
827 let at = |kind| {
828 InputEvent::Mouse(MouseEvent {
829 kind,
830 column: 1,
831 row: 1,
832 modifiers: KeyModifiers::NONE,
833 })
834 };
835 assert_eq!(
836 classify_it(
837 &at(MouseEventKind::Down(MouseButton::Left)),
838 &ctx_find_bar()
839 )
840 .intent,
841 EditorIntent::Consume,
842 "a click reaches nothing while the bar holds the claim"
843 );
844 assert_eq!(
845 classify_it(&at(MouseEventKind::ScrollUp), &ctx_find_bar()).intent,
846 EditorIntent::Mouse,
847 "scrolling still works"
848 );
849 }
850
851 #[test]
857 fn a_single_press_only_reaches_the_editor() {
858 assert_eq!(classify_it(&press(), &ctx()).intent, EditorIntent::Mouse);
859 }
860
861 #[test]
864 fn a_double_click_follows_the_link_under_it() {
865 assert_eq!(
866 classify_it(&press(), &ctx_double()).intent,
867 EditorIntent::FollowLink,
868 "the same intent Ctrl+N produces"
869 );
870 assert_eq!(
871 classify_it(&key(KeyCode::Char('n'), KeyModifiers::CONTROL), &ctx()).intent,
872 EditorIntent::FollowLink,
873 "and Ctrl+N still produces it"
874 );
875 }
876
877 #[test]
881 fn a_double_click_outside_the_editor_is_an_ordinary_click() {
882 let drawer = InputCtx {
883 focused: PanelKind::Drawer,
884 ..ctx_double()
885 };
886 assert_eq!(classify_it(&press(), &drawer).intent, EditorIntent::Mouse);
887 }
888
889 #[test]
892 fn an_overlay_outranks_a_double_click() {
893 let covered = InputCtx {
894 overlay: Some(OverlayKind::NoteBrowser),
895 ..ctx_double()
896 };
897 assert_eq!(
898 classify_it(&press(), &covered).intent,
899 EditorIntent::Overlay
900 );
901 }
902
903 #[test]
907 fn a_find_bar_claim_swallows_a_double_click_but_takes_ctrl_enter() {
908 let bar_double = InputCtx {
909 double_click: true,
910 ..ctx_find_bar()
911 };
912 assert_eq!(
913 classify_it(&press(), &bar_double).intent,
914 EditorIntent::Consume,
915 "a follow that arrived as a click is still a click"
916 );
917 assert_eq!(
918 classify_it(&key(KeyCode::Enter, KeyModifiers::CONTROL), &ctx_find_bar()).intent,
919 EditorIntent::Panel {
920 fallback: PanelFallback::None
921 },
922 "but a follow that arrived as a key is delivered to the bar"
923 );
924 }
925
926 #[test]
932 fn an_autocomplete_claim_turns_a_double_click_back_into_a_press() {
933 let popup = InputCtx {
934 claim: EditorClaim::Autocomplete,
935 double_click: true,
936 ..ctx()
937 };
938 assert_eq!(classify_it(&press(), &popup).intent, EditorIntent::Mouse);
939 assert_eq!(
940 classify_it(&key(KeyCode::Char('n'), KeyModifiers::CONTROL), &popup).intent,
941 EditorIntent::FollowLink,
942 "the popup does not own the keyboard follow"
943 );
944 }
945
946 #[test]
950 fn an_autocomplete_claim_blocks_only_buffer_edits() {
951 let popup = InputCtx {
952 claim: EditorClaim::Autocomplete,
953 ..ctx()
954 };
955 assert_eq!(
956 classify_it(&InputEvent::Paste("hi".into()), &popup).intent,
957 EditorIntent::EditorPaste,
958 "the popup does not own pastes"
959 );
960 assert_eq!(
961 classify_it(&key(KeyCode::Char('b'), KeyModifiers::CONTROL), &popup).intent,
962 EditorIntent::Panel {
963 fallback: PanelFallback::None
964 },
965 "but a buffer edit still loses to any holder"
966 );
967 }
968
969 #[test]
971 fn the_leader_outranks_a_claim() {
972 let ev = key(KeyCode::Char('x'), KeyModifiers::NONE);
973 let pending = InputCtx {
974 leader_pending: true,
975 ..ctx_find_bar()
976 };
977 assert!(matches!(
978 classify_it(&ev, &pending).intent,
979 EditorIntent::LeaderKey(_)
980 ));
981 }
982
983 #[test]
986 fn bracketed_paste_in_editor_is_editor_paste() {
987 let c = classify_it(&InputEvent::Paste("hi".into()), &ctx());
988 assert_eq!(c.intent, EditorIntent::EditorPaste);
989 }
990
991 #[test]
992 fn bracketed_paste_with_overlay_routes_to_overlay() {
993 let mut cx = ctx();
994 cx.overlay = Some(OverlayKind::NoteBrowser);
995 let c = classify_it(&InputEvent::Paste("hi".into()), &cx);
996 assert_eq!(c.intent, EditorIntent::Overlay);
997 }
998
999 #[test]
1000 fn bracketed_paste_drawer_focused_goes_to_panel() {
1001 let mut cx = ctx();
1002 cx.focused = PanelKind::Drawer;
1003 let c = classify_it(&InputEvent::Paste("hi".into()), &cx);
1004 assert_eq!(
1005 c.intent,
1006 EditorIntent::Panel {
1007 fallback: PanelFallback::None
1008 }
1009 );
1010 }
1011
1012 #[test]
1013 fn ctrl_v_in_editor_is_a_bare_image_probe() {
1014 let c = classify_it(&ctrl('v'), &ctx());
1017 assert_eq!(c.intent, EditorIntent::ImageProbe);
1018 assert_eq!(c.flash, None, "flash belongs to the no-image tail only");
1019 }
1020
1021 #[test]
1022 fn ctrl_v_no_image_tail_reaches_panel_with_a_flash() {
1023 let c = classify_tail(&ctrl('v'), &bindings(), &ctx(), false);
1028 assert_eq!(
1029 c.intent,
1030 EditorIntent::Panel {
1031 fallback: PanelFallback::None
1032 }
1033 );
1034 assert!(c.flash.is_some());
1035 assert!(!c.cancel_leader, "the outer probe already owned the cancel");
1036 }
1037
1038 #[test]
1039 fn leader_pending_ctrl_v_cancels_leader_then_probes_image() {
1040 let mut cx = ctx();
1046 cx.leader_pending = true;
1047 let c = classify_it(&ctrl('v'), &cx);
1048 assert!(c.cancel_leader);
1049 assert_eq!(c.intent, EditorIntent::ImageProbe);
1050 }
1051
1052 #[test]
1053 fn leader_pending_bracketed_paste_cancels_leader_then_pastes() {
1054 let mut cx = ctx();
1057 cx.leader_pending = true;
1058 let c = classify_it(&InputEvent::Paste("hi".into()), &cx);
1059 assert!(c.cancel_leader);
1060 assert_eq!(c.intent, EditorIntent::EditorPaste);
1061 }
1062
1063 #[test]
1064 fn leader_pending_ctrl_enter_feeds_leader() {
1065 let mut cx = ctx();
1068 cx.leader_pending = true;
1069 let c = classify_it(&key(KeyCode::Enter, KeyModifiers::CONTROL), &cx);
1070 assert_eq!(
1071 c.intent,
1072 EditorIntent::LeaderKey(KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL))
1073 );
1074 assert!(!c.cancel_leader);
1075 }
1076
1077 #[test]
1078 fn ctrl_enter_in_editor_follows_link() {
1079 let c = classify_it(&key(KeyCode::Enter, KeyModifiers::CONTROL), &ctx());
1080 assert_eq!(c.intent, EditorIntent::FollowLink);
1081 }
1082
1083 #[test]
1086 fn leader_pending_plain_key_feeds_leader() {
1087 let mut cx = ctx();
1088 cx.leader_pending = true;
1089 let c = classify_it(&plain('f'), &cx);
1090 assert_eq!(
1091 c.intent,
1092 EditorIntent::LeaderKey(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE))
1093 );
1094 assert!(!c.cancel_leader);
1095 }
1096
1097 #[test]
1098 fn leader_pending_with_overlay_cancels_and_routes_to_overlay() {
1099 let mut cx = ctx();
1100 cx.leader_pending = true;
1101 cx.overlay = Some(OverlayKind::Dialog);
1102 let c = classify_it(&plain('f'), &cx);
1103 assert!(c.cancel_leader);
1104 assert_eq!(c.intent, EditorIntent::Overlay);
1105 }
1106
1107 #[test]
1108 fn leader_pending_ctrl_chord_cancels_then_dispatches() {
1109 let mut cx = ctx();
1110 cx.leader_pending = true;
1111 let c = classify_it(&ctrl('p'), &cx);
1112 assert!(c.cancel_leader);
1113 assert_eq!(
1114 c.intent,
1115 EditorIntent::ToggleOverlay {
1116 kind: OverlayKind::CommandPalette,
1117 open: OverlayOpen::CommandPalette
1118 }
1119 );
1120 }
1121
1122 #[test]
1125 fn command_palette_chord_toggles_regardless_of_open_state() {
1126 let c = classify_it(&ctrl('p'), &ctx());
1129 assert_eq!(
1130 c.intent,
1131 EditorIntent::ToggleOverlay {
1132 kind: OverlayKind::CommandPalette,
1133 open: OverlayOpen::CommandPalette
1134 }
1135 );
1136 assert!(c.flash.is_some());
1137
1138 let mut cx = ctx();
1139 cx.overlay = Some(OverlayKind::CommandPalette);
1140 let c = classify_it(&ctrl('p'), &cx);
1141 assert_eq!(
1142 c.intent,
1143 EditorIntent::ToggleOverlay {
1144 kind: OverlayKind::CommandPalette,
1145 open: OverlayOpen::CommandPalette
1146 }
1147 );
1148 }
1149
1150 #[test]
1151 fn leader_gateway_starts_sequence_without_flash() {
1152 let c = classify_it(&ctrl('g'), &ctx());
1153 assert_eq!(c.intent, EditorIntent::LeaderStart);
1154 assert_eq!(c.flash, None);
1155 }
1156
1157 #[test]
1158 fn leader_gateway_with_overlay_is_consumed_noop() {
1159 let mut cx = ctx();
1160 cx.overlay = Some(OverlayKind::NoteBrowser);
1161 let c = classify_it(&ctrl('g'), &cx);
1162 assert_eq!(c.intent, EditorIntent::Consume);
1163 }
1164
1165 #[test]
1166 fn focus_sidebar_with_overlay_is_consumed_noop() {
1167 let mut cx = ctx();
1168 cx.overlay = Some(OverlayKind::NoteBrowser);
1169 let c = classify_it(&ctrl('h'), &cx);
1170 assert_eq!(c.intent, EditorIntent::Consume);
1171 }
1172
1173 #[test]
1174 fn toggle_drawer_chord_is_action() {
1175 let c = classify_it(&ctrl('t'), &ctx());
1176 assert_eq!(c.intent, EditorIntent::Op(EditorOp::ToggleDrawer));
1177 }
1178
1179 #[test]
1180 fn quick_note_opens_dialog_only_without_overlay() {
1181 let c = classify_it(&ctrl('w'), &ctx());
1182 assert_eq!(c.intent, EditorIntent::OpenOverlay(OverlayOpen::QuickNote));
1183
1184 let mut cx = ctx();
1185 cx.overlay = Some(OverlayKind::Dialog);
1186 let c = classify_it(&ctrl('w'), &cx);
1187 assert_eq!(c.intent, EditorIntent::Consume);
1188 }
1189
1190 #[test]
1191 fn text_style_chord_needs_active_editor() {
1192 let c = classify_it(&ctrl('b'), &ctx());
1193 assert_eq!(
1194 c.intent,
1195 EditorIntent::Op(EditorOp::ApplyText(TextAction::Bold))
1196 );
1197
1198 let mut cx = ctx();
1201 cx.focused = PanelKind::Drawer;
1202 let c = classify_it(&ctrl('b'), &cx);
1203 assert_eq!(
1204 c.intent,
1205 EditorIntent::Panel {
1206 fallback: PanelFallback::None
1207 }
1208 );
1209 }
1210
1211 #[test]
1212 fn sort_dialog_targets_the_focused_drawer_view() {
1213 let mut cx = ctx();
1214 cx.focused = PanelKind::Drawer;
1215 cx.drawer_view = DrawerView::Find;
1216 let c = classify_it(&ctrl('r'), &cx);
1217 assert_eq!(c.intent, EditorIntent::OpenOverlay(OverlayOpen::SortQuery));
1218
1219 cx.drawer_view = DrawerView::Files;
1220 let c = classify_it(&ctrl('r'), &cx);
1221 assert_eq!(
1222 c.intent,
1223 EditorIntent::OpenOverlay(OverlayOpen::SortSidebar)
1224 );
1225
1226 cx.drawer_view = DrawerView::Tags;
1228 let c = classify_it(&ctrl('r'), &cx);
1229 assert_eq!(c.intent, EditorIntent::Consume);
1230 }
1231
1232 #[test]
1233 fn sort_dialog_chord_falls_through_when_editor_focused() {
1234 let c = classify_it(&ctrl('r'), &ctx());
1236 assert_eq!(
1237 c.intent,
1238 EditorIntent::Panel {
1239 fallback: PanelFallback::None
1240 }
1241 );
1242 }
1243
1244 #[test]
1245 fn f1_opens_help_or_query_help_by_focus() {
1246 let c = classify_it(&key(KeyCode::F(1), KeyModifiers::NONE), &ctx());
1247 assert_eq!(c.intent, EditorIntent::OpenOverlay(OverlayOpen::Help));
1248
1249 let mut cx = ctx();
1250 cx.focused = PanelKind::Drawer;
1251 cx.drawer_view = DrawerView::Find;
1252 let c = classify_it(&key(KeyCode::F(1), KeyModifiers::NONE), &cx);
1253 assert_eq!(c.intent, EditorIntent::OpenOverlay(OverlayOpen::QueryHelp));
1254 }
1255
1256 #[test]
1257 fn unbound_fkeys_are_sunk_with_a_flash() {
1258 let c = classify_it(&key(KeyCode::F(9), KeyModifiers::NONE), &ctx());
1259 assert_eq!(c.intent, EditorIntent::Consume);
1260 assert!(c.flash.is_some());
1261 }
1262
1263 #[test]
1266 fn a_bound_chord_flashes_the_action_label() {
1267 let c = classify_it(&ctrl('k'), &ctx());
1268 assert_eq!(
1269 c.flash.as_deref(),
1270 Some(ActionShortcuts::SearchNotes.label().as_str())
1271 );
1272 }
1273
1274 #[test]
1277 fn an_unbound_chord_still_flashes_the_raw_chord() {
1278 let c = classify_it(&ctrl('y'), &ctx());
1280 let combo =
1281 key_event_to_combo(&KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL)).unwrap();
1282 assert_eq!(c.flash.as_deref(), Some(combo.to_string().as_str()));
1283 }
1284
1285 #[test]
1286 fn high_fkeys_are_sunk_like_the_rest() {
1287 let c = classify_it(&key(KeyCode::F(13), KeyModifiers::NONE), &ctx());
1292 assert_eq!(c.intent, EditorIntent::Consume);
1293 }
1294
1295 #[test]
1296 fn bound_fkey_dispatches_when_guard_holds_and_sinks_when_not() {
1297 let c = classify_it(&key(KeyCode::F(2), KeyModifiers::NONE), &ctx());
1299 assert_eq!(c.intent, EditorIntent::Op(EditorOp::ShowFileOps));
1300
1301 let mut cx = ctx();
1302 cx.focused = PanelKind::Drawer;
1303 let c = classify_it(&key(KeyCode::F(2), KeyModifiers::NONE), &cx);
1304 assert_eq!(c.intent, EditorIntent::Consume);
1305 }
1306
1307 #[test]
1310 fn overlay_intercepts_unbound_keys() {
1311 let mut cx = ctx();
1312 cx.overlay = Some(OverlayKind::SavedSearches);
1313 let c = classify_it(&plain('x'), &cx);
1314 assert_eq!(c.intent, EditorIntent::Overlay);
1315 }
1316
1317 #[test]
1318 fn mouse_events_take_the_hit_test_path() {
1319 let ev = InputEvent::Mouse(MouseEvent {
1320 kind: MouseEventKind::Moved,
1321 column: 3,
1322 row: 4,
1323 modifiers: KeyModifiers::NONE,
1324 });
1325 let c = classify_it(&ev, &ctx());
1326 assert_eq!(c.intent, EditorIntent::Mouse);
1327 }
1328
1329 #[test]
1330 fn vim_space_starts_leader_only_when_it_leads() {
1331 let mut cx = ctx();
1332 cx.space_leads = true;
1333 let c = classify_it(&plain(' '), &cx);
1334 assert_eq!(c.intent, EditorIntent::LeaderStart);
1335
1336 cx.space_leads = false;
1337 let c = classify_it(&plain(' '), &cx);
1338 assert_eq!(
1339 c.intent,
1340 EditorIntent::Panel {
1341 fallback: PanelFallback::None
1342 }
1343 );
1344 }
1345
1346 #[test]
1347 fn tab_cycles_focus_when_a_non_editor_panel_passes() {
1348 let mut cx = ctx();
1349 cx.focused = PanelKind::Drawer;
1350 let c = classify_it(&key(KeyCode::Tab, KeyModifiers::NONE), &cx);
1351 assert_eq!(
1352 c.intent,
1353 EditorIntent::Panel {
1354 fallback: PanelFallback::FocusCycle(CycleDir::Right)
1355 }
1356 );
1357
1358 let c = classify_it(&key(KeyCode::BackTab, KeyModifiers::SHIFT), &cx);
1359 assert_eq!(
1360 c.intent,
1361 EditorIntent::Panel {
1362 fallback: PanelFallback::FocusCycle(CycleDir::Left)
1363 }
1364 );
1365 }
1366
1367 #[test]
1368 fn editor_keeps_tab_for_indentation() {
1369 let c = classify_it(&key(KeyCode::Tab, KeyModifiers::NONE), &ctx());
1370 assert_eq!(
1371 c.intent,
1372 EditorIntent::Panel {
1373 fallback: PanelFallback::None
1374 }
1375 );
1376 }
1377
1378 #[test]
1379 fn find_view_yields_focus_to_editor_on_unhandled_esc() {
1380 let mut cx = ctx();
1381 cx.focused = PanelKind::Drawer;
1382 cx.drawer_view = DrawerView::Find;
1383 let c = classify_it(&key(KeyCode::Esc, KeyModifiers::NONE), &cx);
1384 assert_eq!(
1385 c.intent,
1386 EditorIntent::Panel {
1387 fallback: PanelFallback::FocusEditor
1388 }
1389 );
1390
1391 let c = classify_it(&plain('x'), &cx);
1393 assert_eq!(
1394 c.intent,
1395 EditorIntent::Panel {
1396 fallback: PanelFallback::None
1397 }
1398 );
1399 }
1400}