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::components::text_editor::EditorClaim;
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 space_leads: bool,
pub claim: EditorClaim,
pub double_click: 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,
},
OpenOverlay(OverlayOpen),
Overlay,
Mouse,
Panel { fallback: PanelFallback },
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum OverlayOpen {
SearchBrowser,
FileFinder,
SavedSearches,
CommandPalette,
WorkspaceSwitcher,
ThemePicker,
Help,
QueryHelp,
Cheatsheet,
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,
FindInBuffer,
ReplaceInBuffer,
ApplyText(TextAction),
OpenAsk,
}
pub fn classify(event: &InputEvent, bindings: &KeyBindings, ctx: &InputCtx) -> Classification {
let mut classification = classify_unclaimed(event, bindings, ctx);
let claim = if ctx.editor_active() {
ctx.claim
} else {
EditorClaim::None
};
let filtered = apply_claim(classification.intent.clone(), event, claim);
if filtered != classification.intent {
classification.flash = None;
}
classification.intent = filtered;
classification
}
fn apply_claim(intent: EditorIntent, event: &InputEvent, claim: EditorClaim) -> EditorIntent {
if claim == EditorClaim::None {
return intent;
}
let deliver = EditorIntent::Panel {
fallback: PanelFallback::None,
};
let find_bar = claim == EditorClaim::FindBar;
match intent {
EditorIntent::LeaderKey(_)
| EditorIntent::Panel { .. }
| EditorIntent::Overlay
| EditorIntent::ToggleOverlay { .. }
| EditorIntent::OpenOverlay(_)
| EditorIntent::Consume => intent,
EditorIntent::Op(EditorOp::ApplyText(_)) => deliver,
EditorIntent::Op(_) => intent,
EditorIntent::EditorPaste | EditorIntent::ImageProbe => {
if find_bar {
deliver
} else {
intent
}
}
EditorIntent::FollowLink => match event {
InputEvent::Mouse(_) => claimed_mouse(event, find_bar),
_ if find_bar => deliver,
_ => intent,
},
EditorIntent::LeaderStart => match event {
InputEvent::Key(k)
if find_bar
&& k.code == ratatui::crossterm::event::KeyCode::Char(' ')
&& k.modifiers.is_empty() =>
{
deliver
}
_ => intent,
},
EditorIntent::Mouse => claimed_mouse(event, find_bar),
}
}
fn claimed_mouse(event: &InputEvent, find_bar: bool) -> EditorIntent {
use ratatui::crossterm::event::MouseEventKind;
match event {
InputEvent::Mouse(m)
if find_bar
&& !matches!(
m.kind,
MouseEventKind::ScrollUp
| MouseEventKind::ScrollDown
| MouseEventKind::ScrollLeft
| MouseEventKind::ScrollRight
) =>
{
EditorIntent::Consume
}
_ => EditorIntent::Mouse,
}
}
fn classify_unclaimed(
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(match &action {
Some(a) => a.label(),
None => 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::OpenAsk) => Some(EditorIntent::Op(EditorOp::OpenAsk)),
Some(ActionShortcuts::YankRow) => None,
Some(ActionShortcuts::OpenSortDialog) => {
if ctx.focused == PanelKind::Drawer && ctx.overlay.is_none() {
Some(match ctx.drawer_view {
DrawerView::Find => EditorIntent::OpenOverlay(OverlayOpen::SortQuery),
DrawerView::Files => EditorIntent::OpenOverlay(OverlayOpen::SortSidebar),
_ => EditorIntent::Consume,
})
} else {
None
}
}
Some(ActionShortcuts::SaveCurrentQuery) => {
Some(EditorIntent::Op(EditorOp::SaveCurrentQuery))
}
Some(ActionShortcuts::SwitchWorkspace) => {
Some(EditorIntent::OpenOverlay(OverlayOpen::WorkspaceSwitcher))
}
Some(ActionShortcuts::QuickNote) => Some(if ctx.overlay.is_none() {
EditorIntent::OpenOverlay(OverlayOpen::QuickNote)
} else {
EditorIntent::Consume
}),
Some(ActionShortcuts::FindInBuffer) if ctx.editor_active() => {
Some(EditorIntent::Op(EditorOp::FindInBuffer))
}
Some(ActionShortcuts::ReplaceInBuffer) if ctx.editor_active() => {
Some(EditorIntent::Op(EditorOp::ReplaceInBuffer))
}
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::OpenOverlay(if ctx.find_panel_focused() {
OverlayOpen::QueryHelp
} else {
OverlayOpen::Help
}))
} 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(_)) {
if ctx.double_click && ctx.editor_active() {
return done(EditorIntent::FollowLink);
}
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.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,
space_leads: false,
claim: EditorClaim::None,
double_click: false,
}
}
fn ctx_find_bar() -> InputCtx {
InputCtx {
claim: EditorClaim::FindBar,
..ctx()
}
}
fn ctx_double() -> InputCtx {
InputCtx {
double_click: true,
..ctx()
}
}
fn press() -> InputEvent {
use ratatui::crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
InputEvent::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 1,
row: 1,
modifiers: KeyModifiers::NONE,
})
}
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 a_claim_blocks_a_buffer_edit() {
let ev = key(KeyCode::Char('b'), KeyModifiers::CONTROL);
assert_eq!(
classify_it(&ev, &ctx()).intent,
EditorIntent::Op(EditorOp::ApplyText(TextAction::Bold)),
"without a claim the chord still bolds"
);
assert_eq!(
classify_it(&ev, &ctx_find_bar()).intent,
EditorIntent::Panel {
fallback: PanelFallback::None
},
"under a claim it is delivered to the holder instead"
);
}
#[test]
fn a_claim_leaves_navigation_alone() {
for ev in [
key(KeyCode::Char('t'), KeyModifiers::CONTROL),
key(KeyCode::Char('p'), KeyModifiers::CONTROL),
] {
assert_eq!(
classify_it(&ev, &ctx_find_bar()).intent,
classify_it(&ev, &ctx()).intent,
"a claim must not swallow navigation"
);
}
}
#[test]
fn a_find_bar_claim_blocks_the_paste_tiers() {
for ev in [
InputEvent::Paste("hi".into()),
key(KeyCode::Char('v'), KeyModifiers::CONTROL),
key(KeyCode::Enter, KeyModifiers::CONTROL),
] {
assert_eq!(
classify_it(&ev, &ctx_find_bar()).intent,
EditorIntent::Panel {
fallback: PanelFallback::None
},
"paste, image probe and follow-link all belong to the bar"
);
}
}
#[test]
fn a_find_bar_claim_blocks_the_space_leader() {
let ev = key(KeyCode::Char(' '), KeyModifiers::NONE);
let vim_ctx = InputCtx {
space_leads: true,
..ctx()
};
assert_eq!(classify_it(&ev, &vim_ctx).intent, EditorIntent::LeaderStart);
let vim_bar = InputCtx {
space_leads: true,
..ctx_find_bar()
};
assert_eq!(
classify_it(&ev, &vim_bar).intent,
EditorIntent::Panel {
fallback: PanelFallback::None
}
);
}
#[test]
fn a_find_bar_claim_blocks_clicks_but_not_scrolling() {
use ratatui::crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
let at = |kind| {
InputEvent::Mouse(MouseEvent {
kind,
column: 1,
row: 1,
modifiers: KeyModifiers::NONE,
})
};
assert_eq!(
classify_it(
&at(MouseEventKind::Down(MouseButton::Left)),
&ctx_find_bar()
)
.intent,
EditorIntent::Consume,
"a click reaches nothing while the bar holds the claim"
);
assert_eq!(
classify_it(&at(MouseEventKind::ScrollUp), &ctx_find_bar()).intent,
EditorIntent::Mouse,
"scrolling still works"
);
}
#[test]
fn a_single_press_only_reaches_the_editor() {
assert_eq!(classify_it(&press(), &ctx()).intent, EditorIntent::Mouse);
}
#[test]
fn a_double_click_follows_the_link_under_it() {
assert_eq!(
classify_it(&press(), &ctx_double()).intent,
EditorIntent::FollowLink,
"the same intent Ctrl+N produces"
);
assert_eq!(
classify_it(&key(KeyCode::Char('n'), KeyModifiers::CONTROL), &ctx()).intent,
EditorIntent::FollowLink,
"and Ctrl+N still produces it"
);
}
#[test]
fn a_double_click_outside_the_editor_is_an_ordinary_click() {
let drawer = InputCtx {
focused: PanelKind::Drawer,
..ctx_double()
};
assert_eq!(classify_it(&press(), &drawer).intent, EditorIntent::Mouse);
}
#[test]
fn an_overlay_outranks_a_double_click() {
let covered = InputCtx {
overlay: Some(OverlayKind::NoteBrowser),
..ctx_double()
};
assert_eq!(
classify_it(&press(), &covered).intent,
EditorIntent::Overlay
);
}
#[test]
fn a_find_bar_claim_swallows_a_double_click_but_takes_ctrl_enter() {
let bar_double = InputCtx {
double_click: true,
..ctx_find_bar()
};
assert_eq!(
classify_it(&press(), &bar_double).intent,
EditorIntent::Consume,
"a follow that arrived as a click is still a click"
);
assert_eq!(
classify_it(&key(KeyCode::Enter, KeyModifiers::CONTROL), &ctx_find_bar()).intent,
EditorIntent::Panel {
fallback: PanelFallback::None
},
"but a follow that arrived as a key is delivered to the bar"
);
}
#[test]
fn an_autocomplete_claim_turns_a_double_click_back_into_a_press() {
let popup = InputCtx {
claim: EditorClaim::Autocomplete,
double_click: true,
..ctx()
};
assert_eq!(classify_it(&press(), &popup).intent, EditorIntent::Mouse);
assert_eq!(
classify_it(&key(KeyCode::Char('n'), KeyModifiers::CONTROL), &popup).intent,
EditorIntent::FollowLink,
"the popup does not own the keyboard follow"
);
}
#[test]
fn an_autocomplete_claim_blocks_only_buffer_edits() {
let popup = InputCtx {
claim: EditorClaim::Autocomplete,
..ctx()
};
assert_eq!(
classify_it(&InputEvent::Paste("hi".into()), &popup).intent,
EditorIntent::EditorPaste,
"the popup does not own pastes"
);
assert_eq!(
classify_it(&key(KeyCode::Char('b'), KeyModifiers::CONTROL), &popup).intent,
EditorIntent::Panel {
fallback: PanelFallback::None
},
"but a buffer edit still loses to any holder"
);
}
#[test]
fn the_leader_outranks_a_claim() {
let ev = key(KeyCode::Char('x'), KeyModifiers::NONE);
let pending = InputCtx {
leader_pending: true,
..ctx_find_bar()
};
assert!(matches!(
classify_it(&ev, &pending).intent,
EditorIntent::LeaderKey(_)
));
}
#[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::OpenOverlay(OverlayOpen::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::OpenOverlay(OverlayOpen::SortQuery));
cx.drawer_view = DrawerView::Files;
let c = classify_it(&ctrl('r'), &cx);
assert_eq!(
c.intent,
EditorIntent::OpenOverlay(OverlayOpen::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::OpenOverlay(OverlayOpen::Help));
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::OpenOverlay(OverlayOpen::QueryHelp));
}
#[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 a_bound_chord_flashes_the_action_label() {
let c = classify_it(&ctrl('k'), &ctx());
assert_eq!(
c.flash.as_deref(),
Some(ActionShortcuts::SearchNotes.label().as_str())
);
}
#[test]
fn an_unbound_chord_still_flashes_the_raw_chord() {
let c = classify_it(&ctrl('y'), &ctx());
let combo =
key_event_to_combo(&KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL)).unwrap();
assert_eq!(c.flash.as_deref(), Some(combo.to_string().as_str()));
}
#[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.space_leads = true;
let c = classify_it(&plain(' '), &cx);
assert_eq!(c.intent, EditorIntent::LeaderStart);
cx.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
}
);
}
}