use ratatui::crossterm::event::KeyEvent;
use crate::components::drawer::DrawerView;
use crate::components::events::InputEvent;
use crate::components::overlay::OverlayKind;
use crate::components::panel::PanelKind;
use crate::keys::action_shortcuts::{ActionShortcuts, TextAction};
use crate::keys::key_strike::KeyStrike;
use crate::keys::{KeyBindings, key_event_to_combo};
#[derive(Debug, Clone, PartialEq)]
pub struct InputCtx {
pub overlay: Option<OverlayKind>,
pub leader_pending: bool,
pub focused: PanelKind,
pub drawer_view: DrawerView,
pub vim_space_leads: bool,
}
impl InputCtx {
fn editor_active(&self) -> bool {
self.focused == PanelKind::Editor && self.overlay.is_none()
}
fn find_panel_focused(&self) -> bool {
self.focused == PanelKind::Drawer && self.drawer_view == DrawerView::Find
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Classification {
pub flash: Option<String>,
pub cancel_leader: bool,
pub intent: EditorIntent,
}
#[derive(Debug, Clone, PartialEq)]
pub enum EditorIntent {
Consume,
EditorPaste,
ImageProbe,
FollowLink,
LeaderKey(KeyEvent),
LeaderStart,
Op(EditorOp),
ToggleOverlay {
kind: OverlayKind,
open: OverlayOpen,
},
OpenDialog(DialogRequest),
Overlay,
Mouse,
Panel { fallback: PanelFallback },
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum OverlayOpen {
SearchBrowser,
FileFinder,
SavedSearches,
RagAnswer,
CommandPalette,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum DialogRequest {
SortQuery,
SortSidebar,
QuickNote,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum PanelFallback {
None,
FocusCycle(CycleDir),
FocusEditor,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum CycleDir {
Left,
Right,
}
#[derive(Debug, Clone, PartialEq)]
pub enum EditorOp {
ToggleDrawer,
FocusLeft,
FocusRight,
OpenJournal,
ShowFileOps,
ToggleQueryPanel,
OpenFileBrowserReveal,
SaveCurrentQuery,
OpenWorkspaceSwitcher,
FindInBuffer,
ApplyText(TextAction),
OpenHelp,
OpenQueryHelp,
}
pub fn classify(event: &InputEvent, bindings: &KeyBindings, ctx: &InputCtx) -> Classification {
use ratatui::crossterm::event::{KeyCode, KeyModifiers};
let mut cancel_leader = false;
if ctx.leader_pending {
match event {
InputEvent::Key(key) => {
if ctx.overlay.is_some() {
cancel_leader = true;
} else if matches!(key.code, KeyCode::Char(_))
&& key.modifiers.contains(KeyModifiers::CONTROL)
{
cancel_leader = true;
} else {
return Classification {
flash: None,
cancel_leader: false,
intent: EditorIntent::LeaderKey(*key),
};
}
}
InputEvent::Paste(_) => cancel_leader = true,
InputEvent::Mouse(_) => {}
}
}
if ctx.editor_active() && matches!(event, InputEvent::Paste(_)) {
return Classification {
flash: None,
cancel_leader,
intent: EditorIntent::EditorPaste,
};
}
if ctx.editor_active()
&& let InputEvent::Key(key) = event
&& key.modifiers == KeyModifiers::CONTROL
&& key.code == KeyCode::Char('v')
{
return Classification {
flash: None,
cancel_leader,
intent: EditorIntent::ImageProbe,
};
}
classify_tail(event, bindings, ctx, cancel_leader)
}
pub(crate) fn classify_tail(
event: &InputEvent,
bindings: &KeyBindings,
ctx: &InputCtx,
cancel_leader: bool,
) -> Classification {
use ratatui::crossterm::event::{KeyCode, KeyModifiers};
if ctx.editor_active()
&& let InputEvent::Key(key) = event
&& key.code == KeyCode::Enter
&& key.modifiers.contains(KeyModifiers::CONTROL)
{
return Classification {
flash: None,
cancel_leader,
intent: EditorIntent::FollowLink,
};
}
let mut flash = None;
let mut shortcut_intent = None;
if let InputEvent::Key(key) = event
&& let Some(combo) = key_event_to_combo(key)
{
let is_fkey = combo.key.is_fkey();
let action = bindings.get_action(&combo);
if action != Some(ActionShortcuts::Leader) && (is_fkey || combo.is_letter_chord()) {
flash = Some(combo.to_string());
}
shortcut_intent = match action {
Some(ActionShortcuts::OpenCommandPalette) => Some(EditorIntent::ToggleOverlay {
kind: OverlayKind::CommandPalette,
open: OverlayOpen::CommandPalette,
}),
Some(ActionShortcuts::Leader) => {
Some(if ctx.overlay.is_none() {
EditorIntent::LeaderStart
} else {
EditorIntent::Consume
})
}
Some(ActionShortcuts::ToggleSidebar) => Some(EditorIntent::Op(EditorOp::ToggleDrawer)),
Some(ActionShortcuts::FocusSidebar) => {
Some(if ctx.overlay.is_none() {
EditorIntent::Op(EditorOp::FocusLeft)
} else {
EditorIntent::Consume
})
}
Some(ActionShortcuts::FocusEditor) => Some(if ctx.overlay.is_none() {
EditorIntent::Op(EditorOp::FocusRight)
} else {
EditorIntent::Consume
}),
Some(ActionShortcuts::NewJournal) => Some(EditorIntent::Op(EditorOp::OpenJournal)),
Some(ActionShortcuts::SearchNotes) => Some(EditorIntent::ToggleOverlay {
kind: OverlayKind::NoteBrowser,
open: OverlayOpen::SearchBrowser,
}),
Some(ActionShortcuts::OpenNote) => Some(EditorIntent::ToggleOverlay {
kind: OverlayKind::NoteBrowser,
open: OverlayOpen::FileFinder,
}),
Some(ActionShortcuts::FileOperations) if ctx.editor_active() => {
Some(EditorIntent::Op(EditorOp::ShowFileOps))
}
Some(ActionShortcuts::FollowLink) if ctx.editor_active() => {
Some(EditorIntent::FollowLink)
}
Some(ActionShortcuts::ToggleQueryPanel) => {
Some(EditorIntent::Op(EditorOp::ToggleQueryPanel))
}
Some(ActionShortcuts::OpenFileBrowser) => {
Some(EditorIntent::Op(EditorOp::OpenFileBrowserReveal))
}
Some(ActionShortcuts::OpenSavedSearches) => Some(EditorIntent::ToggleOverlay {
kind: OverlayKind::SavedSearches,
open: OverlayOpen::SavedSearches,
}),
Some(ActionShortcuts::OpenRagAnswer) => Some(EditorIntent::ToggleOverlay {
kind: OverlayKind::RagAnswer,
open: OverlayOpen::RagAnswer,
}),
Some(ActionShortcuts::OpenSortDialog) => {
if ctx.focused == PanelKind::Drawer && ctx.overlay.is_none() {
Some(match ctx.drawer_view {
DrawerView::Find => EditorIntent::OpenDialog(DialogRequest::SortQuery),
DrawerView::Files => EditorIntent::OpenDialog(DialogRequest::SortSidebar),
_ => EditorIntent::Consume,
})
} else {
None
}
}
Some(ActionShortcuts::SaveCurrentQuery) => {
Some(EditorIntent::Op(EditorOp::SaveCurrentQuery))
}
Some(ActionShortcuts::SwitchWorkspace) => {
Some(EditorIntent::Op(EditorOp::OpenWorkspaceSwitcher))
}
Some(ActionShortcuts::QuickNote) => Some(if ctx.overlay.is_none() {
EditorIntent::OpenDialog(DialogRequest::QuickNote)
} else {
EditorIntent::Consume
}),
Some(ActionShortcuts::FindInBuffer) if ctx.editor_active() => {
Some(EditorIntent::Op(EditorOp::FindInBuffer))
}
Some(ActionShortcuts::Text(
action @ (TextAction::Bold | TextAction::Italic | TextAction::Strikethrough),
)) if ctx.editor_active() => Some(EditorIntent::Op(EditorOp::ApplyText(action))),
_ => {
if is_fkey {
if combo.key == KeyStrike::F1 && combo.modifiers.is_empty() {
Some(EditorIntent::Op(if ctx.find_panel_focused() {
EditorOp::OpenQueryHelp
} else {
EditorOp::OpenHelp
}))
} else {
Some(EditorIntent::Consume)
}
} else {
None
}
}
};
}
let done = move |intent| Classification {
flash,
cancel_leader,
intent,
};
if let Some(intent) = shortcut_intent {
return done(intent);
}
if ctx.overlay.is_some() {
return done(EditorIntent::Overlay);
}
if matches!(event, InputEvent::Mouse(_)) {
return done(EditorIntent::Mouse);
}
if ctx.editor_active()
&& (!ctx.leader_pending || cancel_leader)
&& let InputEvent::Key(key) = event
&& key.code == KeyCode::Char(' ')
&& key.modifiers.is_empty()
&& ctx.vim_space_leads
{
return done(EditorIntent::LeaderStart);
}
if ctx.focused != PanelKind::Editor
&& let InputEvent::Key(key) = event
&& matches!(key.code, KeyCode::Tab | KeyCode::BackTab)
{
return done(EditorIntent::Panel {
fallback: PanelFallback::FocusCycle(if key.code == KeyCode::Tab {
CycleDir::Right
} else {
CycleDir::Left
}),
});
}
if ctx.find_panel_focused()
&& let InputEvent::Key(key) = event
&& key.code == KeyCode::Esc
{
return done(EditorIntent::Panel {
fallback: PanelFallback::FocusEditor,
});
}
done(EditorIntent::Panel {
fallback: PanelFallback::None,
})
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind};
fn bindings() -> KeyBindings {
let mut kb = KeyBindings::empty();
kb.batch_add()
.with_ctrl()
.add(KeyStrike::KeyP, ActionShortcuts::OpenCommandPalette)
.add(KeyStrike::KeyK, ActionShortcuts::SearchNotes)
.add(KeyStrike::KeyG, ActionShortcuts::Leader)
.add(KeyStrike::KeyT, ActionShortcuts::ToggleSidebar)
.add(KeyStrike::KeyR, ActionShortcuts::OpenSortDialog)
.add(KeyStrike::KeyH, ActionShortcuts::FocusSidebar)
.add(KeyStrike::KeyB, ActionShortcuts::Text(TextAction::Bold))
.add(KeyStrike::KeyN, ActionShortcuts::FollowLink)
.add(KeyStrike::KeyW, ActionShortcuts::QuickNote);
kb.batch_add()
.add(KeyStrike::F2, ActionShortcuts::FileOperations);
kb
}
fn ctx() -> InputCtx {
InputCtx {
overlay: None,
leader_pending: false,
focused: PanelKind::Editor,
drawer_view: DrawerView::Files,
vim_space_leads: false,
}
}
fn key(code: KeyCode, mods: KeyModifiers) -> InputEvent {
InputEvent::Key(KeyEvent::new(code, mods))
}
fn ctrl(c: char) -> InputEvent {
key(KeyCode::Char(c), KeyModifiers::CONTROL)
}
fn plain(c: char) -> InputEvent {
key(KeyCode::Char(c), KeyModifiers::NONE)
}
fn classify_it(event: &InputEvent, ctx: &InputCtx) -> Classification {
classify(event, &bindings(), ctx)
}
#[test]
fn bracketed_paste_in_editor_is_editor_paste() {
let c = classify_it(&InputEvent::Paste("hi".into()), &ctx());
assert_eq!(c.intent, EditorIntent::EditorPaste);
}
#[test]
fn bracketed_paste_with_overlay_routes_to_overlay() {
let mut cx = ctx();
cx.overlay = Some(OverlayKind::NoteBrowser);
let c = classify_it(&InputEvent::Paste("hi".into()), &cx);
assert_eq!(c.intent, EditorIntent::Overlay);
}
#[test]
fn bracketed_paste_drawer_focused_goes_to_panel() {
let mut cx = ctx();
cx.focused = PanelKind::Drawer;
let c = classify_it(&InputEvent::Paste("hi".into()), &cx);
assert_eq!(
c.intent,
EditorIntent::Panel {
fallback: PanelFallback::None
}
);
}
#[test]
fn ctrl_v_in_editor_is_a_bare_image_probe() {
let c = classify_it(&ctrl('v'), &ctx());
assert_eq!(c.intent, EditorIntent::ImageProbe);
assert_eq!(c.flash, None, "flash belongs to the no-image tail only");
}
#[test]
fn ctrl_v_no_image_tail_reaches_panel_with_a_flash() {
let c = classify_tail(&ctrl('v'), &bindings(), &ctx(), false);
assert_eq!(
c.intent,
EditorIntent::Panel {
fallback: PanelFallback::None
}
);
assert!(c.flash.is_some());
assert!(!c.cancel_leader, "the outer probe already owned the cancel");
}
#[test]
fn leader_pending_ctrl_v_cancels_leader_then_probes_image() {
let mut cx = ctx();
cx.leader_pending = true;
let c = classify_it(&ctrl('v'), &cx);
assert!(c.cancel_leader);
assert_eq!(c.intent, EditorIntent::ImageProbe);
}
#[test]
fn leader_pending_bracketed_paste_cancels_leader_then_pastes() {
let mut cx = ctx();
cx.leader_pending = true;
let c = classify_it(&InputEvent::Paste("hi".into()), &cx);
assert!(c.cancel_leader);
assert_eq!(c.intent, EditorIntent::EditorPaste);
}
#[test]
fn leader_pending_ctrl_enter_feeds_leader() {
let mut cx = ctx();
cx.leader_pending = true;
let c = classify_it(&key(KeyCode::Enter, KeyModifiers::CONTROL), &cx);
assert_eq!(
c.intent,
EditorIntent::LeaderKey(KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL))
);
assert!(!c.cancel_leader);
}
#[test]
fn ctrl_enter_in_editor_follows_link() {
let c = classify_it(&key(KeyCode::Enter, KeyModifiers::CONTROL), &ctx());
assert_eq!(c.intent, EditorIntent::FollowLink);
}
#[test]
fn leader_pending_plain_key_feeds_leader() {
let mut cx = ctx();
cx.leader_pending = true;
let c = classify_it(&plain('f'), &cx);
assert_eq!(
c.intent,
EditorIntent::LeaderKey(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE))
);
assert!(!c.cancel_leader);
}
#[test]
fn leader_pending_with_overlay_cancels_and_routes_to_overlay() {
let mut cx = ctx();
cx.leader_pending = true;
cx.overlay = Some(OverlayKind::Dialog);
let c = classify_it(&plain('f'), &cx);
assert!(c.cancel_leader);
assert_eq!(c.intent, EditorIntent::Overlay);
}
#[test]
fn leader_pending_ctrl_chord_cancels_then_dispatches() {
let mut cx = ctx();
cx.leader_pending = true;
let c = classify_it(&ctrl('p'), &cx);
assert!(c.cancel_leader);
assert_eq!(
c.intent,
EditorIntent::ToggleOverlay {
kind: OverlayKind::CommandPalette,
open: OverlayOpen::CommandPalette
}
);
}
#[test]
fn command_palette_chord_toggles_regardless_of_open_state() {
let c = classify_it(&ctrl('p'), &ctx());
assert_eq!(
c.intent,
EditorIntent::ToggleOverlay {
kind: OverlayKind::CommandPalette,
open: OverlayOpen::CommandPalette
}
);
assert!(c.flash.is_some());
let mut cx = ctx();
cx.overlay = Some(OverlayKind::CommandPalette);
let c = classify_it(&ctrl('p'), &cx);
assert_eq!(
c.intent,
EditorIntent::ToggleOverlay {
kind: OverlayKind::CommandPalette,
open: OverlayOpen::CommandPalette
}
);
}
#[test]
fn leader_gateway_starts_sequence_without_flash() {
let c = classify_it(&ctrl('g'), &ctx());
assert_eq!(c.intent, EditorIntent::LeaderStart);
assert_eq!(c.flash, None);
}
#[test]
fn leader_gateway_with_overlay_is_consumed_noop() {
let mut cx = ctx();
cx.overlay = Some(OverlayKind::NoteBrowser);
let c = classify_it(&ctrl('g'), &cx);
assert_eq!(c.intent, EditorIntent::Consume);
}
#[test]
fn focus_sidebar_with_overlay_is_consumed_noop() {
let mut cx = ctx();
cx.overlay = Some(OverlayKind::NoteBrowser);
let c = classify_it(&ctrl('h'), &cx);
assert_eq!(c.intent, EditorIntent::Consume);
}
#[test]
fn toggle_drawer_chord_is_action() {
let c = classify_it(&ctrl('t'), &ctx());
assert_eq!(c.intent, EditorIntent::Op(EditorOp::ToggleDrawer));
}
#[test]
fn quick_note_opens_dialog_only_without_overlay() {
let c = classify_it(&ctrl('w'), &ctx());
assert_eq!(c.intent, EditorIntent::OpenDialog(DialogRequest::QuickNote));
let mut cx = ctx();
cx.overlay = Some(OverlayKind::Dialog);
let c = classify_it(&ctrl('w'), &cx);
assert_eq!(c.intent, EditorIntent::Consume);
}
#[test]
fn text_style_chord_needs_active_editor() {
let c = classify_it(&ctrl('b'), &ctx());
assert_eq!(
c.intent,
EditorIntent::Op(EditorOp::ApplyText(TextAction::Bold))
);
let mut cx = ctx();
cx.focused = PanelKind::Drawer;
let c = classify_it(&ctrl('b'), &cx);
assert_eq!(
c.intent,
EditorIntent::Panel {
fallback: PanelFallback::None
}
);
}
#[test]
fn sort_dialog_targets_the_focused_drawer_view() {
let mut cx = ctx();
cx.focused = PanelKind::Drawer;
cx.drawer_view = DrawerView::Find;
let c = classify_it(&ctrl('r'), &cx);
assert_eq!(c.intent, EditorIntent::OpenDialog(DialogRequest::SortQuery));
cx.drawer_view = DrawerView::Files;
let c = classify_it(&ctrl('r'), &cx);
assert_eq!(
c.intent,
EditorIntent::OpenDialog(DialogRequest::SortSidebar)
);
cx.drawer_view = DrawerView::Tags;
let c = classify_it(&ctrl('r'), &cx);
assert_eq!(c.intent, EditorIntent::Consume);
}
#[test]
fn sort_dialog_chord_falls_through_when_editor_focused() {
let c = classify_it(&ctrl('r'), &ctx());
assert_eq!(
c.intent,
EditorIntent::Panel {
fallback: PanelFallback::None
}
);
}
#[test]
fn f1_opens_help_or_query_help_by_focus() {
let c = classify_it(&key(KeyCode::F(1), KeyModifiers::NONE), &ctx());
assert_eq!(c.intent, EditorIntent::Op(EditorOp::OpenHelp));
let mut cx = ctx();
cx.focused = PanelKind::Drawer;
cx.drawer_view = DrawerView::Find;
let c = classify_it(&key(KeyCode::F(1), KeyModifiers::NONE), &cx);
assert_eq!(c.intent, EditorIntent::Op(EditorOp::OpenQueryHelp));
}
#[test]
fn unbound_fkeys_are_sunk_with_a_flash() {
let c = classify_it(&key(KeyCode::F(9), KeyModifiers::NONE), &ctx());
assert_eq!(c.intent, EditorIntent::Consume);
assert!(c.flash.is_some());
}
#[test]
fn high_fkeys_are_sunk_like_the_rest() {
let c = classify_it(&key(KeyCode::F(13), KeyModifiers::NONE), &ctx());
assert_eq!(c.intent, EditorIntent::Consume);
}
#[test]
fn bound_fkey_dispatches_when_guard_holds_and_sinks_when_not() {
let c = classify_it(&key(KeyCode::F(2), KeyModifiers::NONE), &ctx());
assert_eq!(c.intent, EditorIntent::Op(EditorOp::ShowFileOps));
let mut cx = ctx();
cx.focused = PanelKind::Drawer;
let c = classify_it(&key(KeyCode::F(2), KeyModifiers::NONE), &cx);
assert_eq!(c.intent, EditorIntent::Consume);
}
#[test]
fn overlay_intercepts_unbound_keys() {
let mut cx = ctx();
cx.overlay = Some(OverlayKind::SavedSearches);
let c = classify_it(&plain('x'), &cx);
assert_eq!(c.intent, EditorIntent::Overlay);
}
#[test]
fn mouse_events_take_the_hit_test_path() {
let ev = InputEvent::Mouse(MouseEvent {
kind: MouseEventKind::Moved,
column: 3,
row: 4,
modifiers: KeyModifiers::NONE,
});
let c = classify_it(&ev, &ctx());
assert_eq!(c.intent, EditorIntent::Mouse);
}
#[test]
fn vim_space_starts_leader_only_when_it_leads() {
let mut cx = ctx();
cx.vim_space_leads = true;
let c = classify_it(&plain(' '), &cx);
assert_eq!(c.intent, EditorIntent::LeaderStart);
cx.vim_space_leads = false;
let c = classify_it(&plain(' '), &cx);
assert_eq!(
c.intent,
EditorIntent::Panel {
fallback: PanelFallback::None
}
);
}
#[test]
fn tab_cycles_focus_when_a_non_editor_panel_passes() {
let mut cx = ctx();
cx.focused = PanelKind::Drawer;
let c = classify_it(&key(KeyCode::Tab, KeyModifiers::NONE), &cx);
assert_eq!(
c.intent,
EditorIntent::Panel {
fallback: PanelFallback::FocusCycle(CycleDir::Right)
}
);
let c = classify_it(&key(KeyCode::BackTab, KeyModifiers::SHIFT), &cx);
assert_eq!(
c.intent,
EditorIntent::Panel {
fallback: PanelFallback::FocusCycle(CycleDir::Left)
}
);
}
#[test]
fn editor_keeps_tab_for_indentation() {
let c = classify_it(&key(KeyCode::Tab, KeyModifiers::NONE), &ctx());
assert_eq!(
c.intent,
EditorIntent::Panel {
fallback: PanelFallback::None
}
);
}
#[test]
fn find_view_yields_focus_to_editor_on_unhandled_esc() {
let mut cx = ctx();
cx.focused = PanelKind::Drawer;
cx.drawer_view = DrawerView::Find;
let c = classify_it(&key(KeyCode::Esc, KeyModifiers::NONE), &cx);
assert_eq!(
c.intent,
EditorIntent::Panel {
fallback: PanelFallback::FocusEditor
}
);
let c = classify_it(&plain('x'), &cx);
assert_eq!(
c.intent,
EditorIntent::Panel {
fallback: PanelFallback::None
}
);
}
}