Skip to main content

kimun_notes/app_screen/
editor_input.rs

1//! The editor screen's input classifier: resolves one raw input event into an
2//! **Intent** — what the event *means* under the screen's input precedence —
3//! before anything mutates. `classify` is pure over an [`InputCtx`] snapshot,
4//! so the precedence (leader → paste intercepts → shortcuts → overlay →
5//! mouse → panels) is table-tested here instead of living as statement order
6//! inside `EditorScreen::handle_input`. The screen builds the snapshot,
7//! classifies, then *executes* the intent.
8//!
9//! A pending leader sequence owns the input first — including
10//! ahead of the paste intercepts, which in the pre-extraction ladder sat
11//! above it (the old quirk let a leader-pending Ctrl+V paste an image and
12//! leave the sequence pending). Ctrl-chords and paste payloads cancel the
13//! sequence and dispatch normally; every other key feeds it.
14//!
15//! Two decided collaterals of that reorder, both asserted by tests below:
16//! Ctrl+Enter mid-sequence now feeds the leader instead of following the
17//! link (Enter is not a `Char` chord, so no exception applies — the pending
18//! sequence wins over the old follow-link-first order), and a paste arriving mid-sequence
19//! under an open overlay cancels the leader where the old ladder left it
20//! pending (the paste rule applies regardless of who receives the paste).
21
22use 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/// Snapshot of the screen state the classifier needs. Built per event by
34/// `EditorScreen::handle_input`; keep it minimal — every field is a reason
35/// the classification can change.
36#[derive(Debug, Clone, PartialEq)]
37pub struct InputCtx {
38    /// The open overlay's kind, `None` when no overlay is open.
39    pub overlay: Option<OverlayKind>,
40    /// A leader key sequence is pending.
41    pub leader_pending: bool,
42    /// The focused panel.
43    pub focused: PanelKind,
44    /// The active drawer view (regardless of drawer visibility).
45    pub drawer_view: DrawerView,
46    /// Bare Space starts the leader (vim Normal mode, empty pending state).
47    pub space_leads: bool,
48    /// Which editor-internal surface holds input, if any. Without
49    /// this the classifier cannot know the **find bar** is open, and ownership
50    /// gets re-decided further down, once per event kind.
51    pub claim: EditorClaim,
52    /// This mouse press completes a double-click in the editor column — the
53    /// mouse's way of following a link (see `click_run`).
54    ///
55    /// An *input* to classification, not an output: it picks between
56    /// [`EditorIntent::Mouse`] and [`EditorIntent::FollowLink`], so no intent
57    /// variant has to name the gesture that produced it. The rule itself lives
58    /// in [`ClickRun`](crate::app_screen::click_run::ClickRun); the screen
59    /// records the press before building this snapshot.
60    pub double_click: bool,
61    /// The open overlay is a list the sort dialog can sort (the Ctrl+K
62    /// search browser or the Ctrl+O file finder, not the link results). Lets
63    /// Ctrl+R over it open the
64    /// sort dialog on top instead of reaching the overlay.
65    pub overlay_sortable: bool,
66    /// The open overlay takes the query syntax (the Ctrl+K search browser):
67    /// F1 over it opens the syntax reference stacked on top.
68    pub overlay_query: bool,
69    /// A mouse event landed on the editor column. The classifier cannot
70    /// hit-test, and an **editor claim** only owns presses inside the editor:
71    /// a click on the rail, the drawer or the footer must reach them even
72    /// while the find bar is open. Meaningless for non-mouse events.
73    pub pointer_on_editor: bool,
74}
75
76impl InputCtx {
77    /// The editor owns key input: it is focused and no overlay sits over it.
78    /// Mirrors `EditorScreen::editor_active`.
79    fn editor_active(&self) -> bool {
80        self.focused == PanelKind::Editor && self.overlay.is_none()
81    }
82
83    fn find_panel_focused(&self) -> bool {
84        self.focused == PanelKind::Drawer && self.drawer_view == DrawerView::Find
85    }
86
87    /// Whether a buffer-targeting op (`EditorOp::ApplyText`) may run right
88    /// now: the editor owns input *and* no editor-internal surface holds it.
89    /// The same verdict `apply_claim` reaches for the chord tier; the leader
90    /// route asks here instead of re-deriving it, so the two cannot drift.
91    pub(crate) fn accepts_text_action(&self) -> bool {
92        self.editor_active() && self.claim == EditorClaim::None
93    }
94}
95
96/// The classifier's full verdict: pre-effects plus the intent. The executor
97/// applies `flash` and `cancel_leader` first, then runs the intent.
98#[derive(Debug, Clone, PartialEq)]
99pub struct Classification {
100    /// Footer chord flash (F-keys and Ctrl/Alt+letter chords, except the
101    /// leader gateway, whose affordance is the pending sequence).
102    pub flash: Option<String>,
103    /// A pending leader sequence must be cancelled before the intent runs
104    /// (an overlay opened underneath, or a Ctrl-chord dispatches normally).
105    pub cancel_leader: bool,
106    pub intent: EditorIntent,
107}
108
109/// What one raw input event means in the editor screen. Execution lives in
110/// `EditorScreen`; anything that depends on a runtime outcome (the clipboard
111/// image probe, a panel's first crack at a key) carries its fallback as data.
112#[derive(Debug, Clone, PartialEq)]
113pub enum EditorIntent {
114    /// Swallow the event (guarded no-ops, the F-key sink).
115    Consume,
116    /// Bracketed paste into the editor: try an image paste first, fall
117    /// back to pasting the event's text payload when the clipboard holds
118    /// no image. Carries nothing — the executor reads the payload back
119    /// from the event it already holds, so large pastes are never cloned.
120    EditorPaste,
121    /// Ctrl+V in the editor: probe the clipboard for an image. When there
122    /// is none, the executor reclassifies the tail of the ladder
123    /// (`classify_tail` with `cancel_leader = false` — the probe
124    /// classification already owned any leader cancel) against fresh
125    /// screen state. Lazy on purpose: the common with-image press must not
126    /// pay for a discarded fallback classification.
127    ImageProbe,
128    /// Follow the **follow target** under the editor cursor — a link, or a
129    /// label whose query is run. Reached two ways, deliberately one intent:
130    /// the bound `FollowLink` shortcut, and a double-click, whose first press
131    /// put the cursor where the second one asks about (see `click_run`).
132    FollowLink,
133    /// Feed the key to the pending leader sequence.
134    LeaderKey(KeyEvent),
135    /// Start a leader sequence and schedule the which-key reveal.
136    LeaderStart,
137    /// A screen operation with no classification-time policy beyond its
138    /// guard (see CONTEXT.md **Intent** — "action" is reserved for
139    /// `ActionShortcuts`, the classifier's *input*).
140    Op(EditorOp),
141    /// Dismiss the overlay when one of `kind` is already open, else `open`.
142    ToggleOverlay {
143        kind: OverlayKind,
144        open: OverlayOpen,
145    },
146    /// Present the overlay `OverlayOpen` names (the executor builds it from
147    /// its seed state). No toggle semantics — see `ToggleOverlay` for those.
148    OpenOverlay(OverlayOpen),
149    /// Route the event to the open overlay.
150    Overlay,
151    /// Route the mouse event through the `PanelSet` hit-test path.
152    Mouse,
153    /// Route the event to the focused panel; on `NotConsumed` apply the
154    /// fallback.
155    Panel { fallback: PanelFallback },
156}
157
158/// Every overlay the editor screen can open, as a construction recipe the
159/// executor's one `build_overlay` match turns into the live overlay. Distinct
160/// from `OverlayKind` (the *presentation* kind): two recipes share
161/// `OverlayKind::NoteBrowser` (search browser vs file finder), so the kind
162/// alone cannot pick the opener.
163#[derive(Debug, Clone, Copy, PartialEq)]
164pub enum OverlayOpen {
165    SearchBrowser,
166    FileFinder,
167    SavedSearches,
168    CommandPalette,
169    WorkspaceSwitcher,
170    ThemePicker,
171    /// The flat key-bindings help (F1).
172    Help,
173    /// The search query syntax reference (F1 over the Find panel).
174    QueryHelp,
175    /// The full leader-tree cheatsheet (leader `?`).
176    Cheatsheet,
177    SortQuery,
178    SortSidebar,
179    /// The sort dialog stacked over the open sortable note browser (Ctrl+K
180    /// search browser or Ctrl+O file finder) — the one
181    /// recipe that opens over an overlay instead of being refused by it.
182    SortBrowser,
183    /// The query syntax reference stacked over the open Ctrl+K browser —
184    /// the other recipe that opens over an overlay.
185    QueryHelpOverBrowser,
186    QuickNote,
187    /// The pinned-notes dialog (leader `f p`).
188    PinnedNotes,
189}
190
191/// Fallback applied when the focused panel does not consume the event.
192#[derive(Debug, Clone, Copy, PartialEq)]
193pub enum PanelFallback {
194    None,
195    /// Tab / Shift-Tab cycle panel focus when the panel passes.
196    FocusCycle(CycleDir),
197    /// The FIND view yields focus back to the editor on an unhandled Esc.
198    FocusEditor,
199}
200
201#[derive(Debug, Clone, Copy, PartialEq)]
202pub enum CycleDir {
203    Left,
204    Right,
205}
206
207#[derive(Debug, Clone, PartialEq)]
208pub enum EditorOp {
209    ToggleDrawer,
210    FocusLeft,
211    FocusRight,
212    OpenJournal,
213    ShowFileOps,
214    ToggleQueryPanel,
215    /// Open (or switch the drawer to) FILES and reveal the open note's
216    /// directory — never hides the drawer.
217    OpenFileBrowserReveal,
218    SaveCurrentQuery,
219    FindInBuffer,
220    /// Open the find bar with the replace field revealed.
221    ReplaceInBuffer,
222    ApplyText(TextAction),
223    /// Switch to the Ask workspace and focus its composer (F6 and leader
224    /// `a a`).
225    OpenAsk,
226}
227
228/// Resolve one raw input event into its [`Classification`] under the editor
229/// screen's input precedence. Pure: same event + bindings + ctx, same verdict.
230///
231/// Runs the precedence ladder, then applies the **editor claim** filter — an
232/// intent the current holder does not allow is rewritten so the event reaches
233/// the holder instead. With no claim held the filter is the identity,
234/// which is why the ladder's own tests are unaffected by it.
235pub fn classify(event: &InputEvent, bindings: &KeyBindings, ctx: &InputCtx) -> Classification {
236    let mut classification = classify_unclaimed(event, bindings, ctx);
237    // An **editor claim** only holds while the editor is the active panel: the
238    // bar is inside it, so a drawer click or an open overlay outranks it.
239    // Without this the filter would swallow every click anywhere in the app.
240    // Focus alone is not enough for the mouse: the editor stays focused until
241    // a press lands elsewhere, so that press must be judged by where it lands.
242    let off_editor = matches!(event, InputEvent::Mouse(_)) && !ctx.pointer_on_editor;
243    let claim = if ctx.editor_active() && !off_editor {
244        ctx.claim
245    } else {
246        EditorClaim::None
247    };
248    let filtered = apply_claim(classification.intent.clone(), event, claim);
249    if filtered != classification.intent {
250        // The chord did not run, so it must not advertise itself in the footer.
251        classification.flash = None;
252    }
253    classification.intent = filtered;
254    classification
255}
256
257/// Which intents survive an **editor claim**, and what a blocked one becomes.
258///
259/// An allow-list, exhaustive over [`EditorIntent`]: a new variant defaults to
260/// blocked, so getting it wrong shows up as a key that appears to do nothing
261/// rather than one that silently edits the note. A block-list would default the
262/// other way — which is how `Ctrl+B` came to embolden the note while the user
263/// was typing a find pattern.
264///
265/// A blocked intent becomes [`EditorIntent::Panel`] — the existing default,
266/// which routes to the focused panel, which is the editor, which routes to its
267/// holder. The claim decides *ownership*; the holder decides behaviour.
268fn apply_claim(intent: EditorIntent, event: &InputEvent, claim: EditorClaim) -> EditorIntent {
269    if claim == EditorClaim::None {
270        return intent;
271    }
272    let deliver = EditorIntent::Panel {
273        fallback: PanelFallback::None,
274    };
275    let find_bar = claim == EditorClaim::FindBar;
276    match intent {
277        // Always allowed. A pending leader sequence outranks a claim; the panel default IS the delivery path; an overlay cannot
278        // coexist with a claim (a claim implies the editor is focused and
279        // unobscured); the palette stays reachable as a global escape.
280        EditorIntent::LeaderKey(_)
281        | EditorIntent::Panel { .. }
282        | EditorIntent::Overlay
283        | EditorIntent::ToggleOverlay { .. }
284        | EditorIntent::OpenOverlay(_)
285        | EditorIntent::Consume => intent,
286
287        // Buffer-targeting ops lose to any holder — typing a find pattern must
288        // not embolden the note. Navigation ops are unaffected.
289        EditorIntent::Op(EditorOp::ApplyText(_)) => deliver,
290        EditorIntent::Op(_) => intent,
291
292        // The find bar is a text field: a paste belongs in it and an image
293        // probe is meaningless there. The popup wants both to behave normally.
294        EditorIntent::EditorPaste | EditorIntent::ImageProbe => {
295            if find_bar {
296                deliver
297            } else {
298                intent
299            }
300        }
301
302        // Ctrl+Enter must not follow a link out of the bar, so a key-origin
303        // follow is delivered to it. A mouse-origin one carries the same intent
304        // but is a *press* wearing a different name, so it degrades to whatever
305        // a plain press does under this claim — swallowed by the bar, delivered
306        // normally under the popup, which wants the click that dismisses it.
307        // Abandoning the follow is the point: a holder is mid-edit, and the
308        // link under the pointer is text it is still being typed into.
309        EditorIntent::FollowLink => match event {
310            InputEvent::Mouse(_) => claimed_mouse(event, find_bar),
311            _ if find_bar => deliver,
312            _ => intent,
313        },
314
315        // Bare Space is a character in a find pattern. The bound leader
316        // gateway is not — it opens "in every context, including mid-typing",
317        // and blocking it here would also make a pending sequence unreachable.
318        EditorIntent::LeaderStart => match event {
319            InputEvent::Key(k)
320                if find_bar
321                    && k.code == ratatui::crossterm::event::KeyCode::Char(' ')
322                    && k.modifiers.is_empty() =>
323            {
324                deliver
325            }
326            _ => intent,
327        },
328
329        EditorIntent::Mouse => claimed_mouse(event, find_bar),
330    }
331}
332
333/// What a claim does to a press. The one policy, so a follow that arrived as a
334/// click cannot drift away from the click it is.
335///
336/// Scrolling moves the viewport, not the cursor, so reading elsewhere
337/// mid-search stays available. Clicks and drags would move the cursor off the
338/// current match and overwrite the selection marking it — the find bar has no
339/// way to represent either. Every other holder takes the press normally.
340fn claimed_mouse(event: &InputEvent, find_bar: bool) -> EditorIntent {
341    use ratatui::crossterm::event::MouseEventKind;
342
343    match event {
344        InputEvent::Mouse(m)
345            if find_bar
346                && !matches!(
347                    m.kind,
348                    MouseEventKind::ScrollUp
349                        | MouseEventKind::ScrollDown
350                        | MouseEventKind::ScrollLeft
351                        | MouseEventKind::ScrollRight
352                ) =>
353        {
354            EditorIntent::Consume
355        }
356        _ => EditorIntent::Mouse,
357    }
358}
359
360fn classify_unclaimed(
361    event: &InputEvent,
362    bindings: &KeyBindings,
363    ctx: &InputCtx,
364) -> Classification {
365    use ratatui::crossterm::event::{KeyCode, KeyModifiers};
366
367    // A pending leader sequence owns the input ahead of everything else,
368    // including the paste intercepts below. Exceptions: an
369    // overlay that opened underneath wins, a Ctrl-chord cancels the sequence
370    // then dispatches normally, and a paste payload — not a key the sequence
371    // can consume — gets the same cancel-then-dispatch treatment.
372    let mut cancel_leader = false;
373    if ctx.leader_pending {
374        match event {
375            InputEvent::Key(key) => {
376                if ctx.overlay.is_some() {
377                    cancel_leader = true;
378                } else if matches!(key.code, KeyCode::Char(_))
379                    && key.modifiers.contains(KeyModifiers::CONTROL)
380                {
381                    cancel_leader = true;
382                    // fall through to normal dispatch below
383                } else {
384                    return Classification {
385                        flash: None,
386                        cancel_leader: false,
387                        intent: EditorIntent::LeaderKey(*key),
388                    };
389                }
390            }
391            InputEvent::Paste(_) => cancel_leader = true,
392            // Mouse input never fed the sequence; it routes below unchanged.
393            InputEvent::Mouse(_) => {}
394        }
395    }
396
397    // Bracketed paste (terminal-level). The executor tries an image paste
398    // first and falls back to the text payload.
399    if ctx.editor_active() && matches!(event, InputEvent::Paste(_)) {
400        return Classification {
401            flash: None,
402            cancel_leader,
403            intent: EditorIntent::EditorPaste,
404        };
405    }
406
407    // Ctrl+V: probe the clipboard for an image ahead of the editor's own
408    // text paste. No image → the executor reclassifies the tail against
409    // fresh state. The leader cancel rides on this classification: the
410    // sequence dies whether or not the clipboard holds an image.
411    if ctx.editor_active()
412        && let InputEvent::Key(key) = event
413        && key.modifiers == KeyModifiers::CONTROL
414        && key.code == KeyCode::Char('v')
415    {
416        return Classification {
417            flash: None,
418            cancel_leader,
419            intent: EditorIntent::ImageProbe,
420        };
421    }
422
423    classify_tail(event, bindings, ctx, cancel_leader)
424}
425
426/// The ladder below the leader tier and the paste intercepts: shortcuts →
427/// overlay → mouse → vim Space leader → Tab cycle → FIND Esc → focused
428/// panel. `cancel_leader` carries the leader tier's verdict into the
429/// returned classification. `pub(crate)` for one caller besides
430/// [`classify`]: the executor's no-image [`EditorIntent::ImageProbe`]
431/// path, which runs the tail against post-probe state.
432pub(crate) fn classify_tail(
433    event: &InputEvent,
434    bindings: &KeyBindings,
435    ctx: &InputCtx,
436    cancel_leader: bool,
437) -> Classification {
438    use ratatui::crossterm::event::{KeyCode, KeyModifiers};
439
440    // Ctrl+Enter follows the link under the cursor on kitty-protocol
441    // terminals (legacy terminals can't tell it from Enter).
442    if ctx.editor_active()
443        && let InputEvent::Key(key) = event
444        && key.code == KeyCode::Enter
445        && key.modifiers.contains(KeyModifiers::CONTROL)
446    {
447        return Classification {
448            flash: None,
449            cancel_leader,
450            intent: EditorIntent::FollowLink,
451        };
452    }
453
454    // Shortcut tier: resolve the bound action (if any) to an intent, and
455    // compute the footer chord flash. The match *yields* instead of
456    // returning so `flash` has a single owner: the one `done` constructor
457    // below moves it into whichever classification wins.
458    let mut flash = None;
459    let mut shortcut_intent = None;
460    if let InputEvent::Key(key) = event
461        && let Some(combo) = key_event_to_combo(key)
462    {
463        let is_fkey = combo.key.is_fkey();
464        let action = bindings.get_action(&combo);
465        // Name the action when the chord resolves to one; fall back to the raw
466        // chord when it does not. A bare "Ctrl&K" only answers "what did I
467        // press"; "Search notes" answers "what did it do", and the raw chord was
468        // never the interesting half for a binding the user chose. Unbound
469        // chords keep the echo — there is nothing else to say about them.
470        //
471        // Handlers that report their own outcome (a yank's "path copied", a
472        // paste's "pasted") overwrite this: the tier's flash is applied first,
473        // then the intent runs.
474        //
475        // The leader gateway is exempt either way — its affordance is the
476        // pending sequence and the which-key overlay.
477        if action != Some(ActionShortcuts::Leader) && (is_fkey || combo.is_letter_chord()) {
478            flash = Some(match &action {
479                Some(a) => a.label(),
480                None => combo.to_string(),
481            });
482        }
483        shortcut_intent = match action {
484            Some(ActionShortcuts::OpenCommandPalette) => Some(EditorIntent::ToggleOverlay {
485                kind: OverlayKind::CommandPalette,
486                open: OverlayOpen::CommandPalette,
487            }),
488            Some(ActionShortcuts::Leader) => {
489                // The gateway works in every context, including mid-typing —
490                // but not while an overlay owns input.
491                Some(if ctx.overlay.is_none() {
492                    EditorIntent::LeaderStart
493                } else {
494                    EditorIntent::Consume
495                })
496            }
497            Some(ActionShortcuts::ToggleSidebar) => Some(EditorIntent::Op(EditorOp::ToggleDrawer)),
498            Some(ActionShortcuts::FocusSidebar) => {
499                // No-op while an overlay owns input, but still consume the key.
500                Some(if ctx.overlay.is_none() {
501                    EditorIntent::Op(EditorOp::FocusLeft)
502                } else {
503                    EditorIntent::Consume
504                })
505            }
506            Some(ActionShortcuts::FocusEditor) => Some(if ctx.overlay.is_none() {
507                EditorIntent::Op(EditorOp::FocusRight)
508            } else {
509                EditorIntent::Consume
510            }),
511            Some(ActionShortcuts::NewJournal) => Some(EditorIntent::Op(EditorOp::OpenJournal)),
512            Some(ActionShortcuts::SearchNotes) => Some(EditorIntent::ToggleOverlay {
513                kind: OverlayKind::NoteBrowser,
514                open: OverlayOpen::SearchBrowser,
515            }),
516            Some(ActionShortcuts::OpenNote) => Some(EditorIntent::ToggleOverlay {
517                kind: OverlayKind::NoteBrowser,
518                open: OverlayOpen::FileFinder,
519            }),
520            Some(ActionShortcuts::FileOperations) if ctx.editor_active() => {
521                Some(EditorIntent::Op(EditorOp::ShowFileOps))
522            }
523            Some(ActionShortcuts::FollowLink) if ctx.editor_active() => {
524                Some(EditorIntent::FollowLink)
525            }
526            Some(ActionShortcuts::ToggleQueryPanel) => {
527                Some(EditorIntent::Op(EditorOp::ToggleQueryPanel))
528            }
529            Some(ActionShortcuts::OpenFileBrowser) => {
530                // Open (or switch to) the FILES view — never hides the drawer;
531                // ToggleSidebar is the on/off switch. Always reveal: with FILES
532                // already open this is the "where is my note" gesture.
533                Some(EditorIntent::Op(EditorOp::OpenFileBrowserReveal))
534            }
535            Some(ActionShortcuts::OpenSavedSearches) => Some(EditorIntent::ToggleOverlay {
536                kind: OverlayKind::SavedSearches,
537                open: OverlayOpen::SavedSearches,
538            }),
539            Some(ActionShortcuts::OpenAsk) => Some(EditorIntent::Op(EditorOp::OpenAsk)),
540            // Deliberately produces NO intent, so the chord is never consumed
541            // here and always reaches whatever has focus. A list surface claims
542            // it inside `SearchList` and yanks the selected row's target; the
543            // editor claims it as redo. The binding exists for the other two
544            // things a hand-rolled pre-check could never give it — rebinding and
545            // a help-dialog entry.
546            Some(ActionShortcuts::YankRow) => None,
547            Some(ActionShortcuts::OpenSortDialog) => {
548                // Sort applies only to a list: a sortable note browser (Ctrl+K
549                // search, Ctrl+O finder) when it is the open overlay, else the focused drawer's Find /
550                // Files view. Over any other overlay, or with the editor
551                // focused, do NOT consume — fall through (`None`) so the key
552                // reaches it (e.g. Ctrl+R is redo in the nvim editor).
553                if ctx.overlay == Some(OverlayKind::NoteBrowser) && ctx.overlay_sortable {
554                    Some(EditorIntent::OpenOverlay(OverlayOpen::SortBrowser))
555                } else if ctx.focused == PanelKind::Drawer && ctx.overlay.is_none() {
556                    Some(match ctx.drawer_view {
557                        DrawerView::Find => EditorIntent::OpenOverlay(OverlayOpen::SortQuery),
558                        DrawerView::Files => EditorIntent::OpenOverlay(OverlayOpen::SortSidebar),
559                        _ => EditorIntent::Consume,
560                    })
561                } else {
562                    None
563                }
564            }
565            Some(ActionShortcuts::SaveCurrentQuery) => {
566                // Whether there is anything to save is executor state (the
567                // live query text); the key is consumed either way.
568                Some(EditorIntent::Op(EditorOp::SaveCurrentQuery))
569            }
570            Some(ActionShortcuts::SwitchWorkspace) => {
571                Some(EditorIntent::OpenOverlay(OverlayOpen::WorkspaceSwitcher))
572            }
573            Some(ActionShortcuts::QuickNote) => Some(if ctx.overlay.is_none() {
574                EditorIntent::OpenOverlay(OverlayOpen::QuickNote)
575            } else {
576                EditorIntent::Consume
577            }),
578            Some(ActionShortcuts::FindInBuffer) if ctx.editor_active() => {
579                Some(EditorIntent::Op(EditorOp::FindInBuffer))
580            }
581            Some(ActionShortcuts::ReplaceInBuffer) if ctx.editor_active() => {
582                Some(EditorIntent::Op(EditorOp::ReplaceInBuffer))
583            }
584            Some(ActionShortcuts::Text(
585                action @ (TextAction::Bold | TextAction::Italic | TextAction::Strikethrough),
586            )) if ctx.editor_active() => Some(EditorIntent::Op(EditorOp::ApplyText(action))),
587            _ => {
588                if is_fkey {
589                    // F1 opens the help modal. Over the Find panel it surfaces
590                    // query syntax instead of the flat key-bindings help. All
591                    // F-keys are consumed and never forwarded to the editor.
592                    if combo.key == KeyStrike::F1 && combo.modifiers.is_empty() && ctx.overlay_query
593                    {
594                        // Over the Ctrl+K browser: its syntax, on top of it.
595                        Some(EditorIntent::OpenOverlay(OverlayOpen::QueryHelpOverBrowser))
596                    } else if combo.key == KeyStrike::F1 && combo.modifiers.is_empty() {
597                        Some(EditorIntent::OpenOverlay(if ctx.find_panel_focused() {
598                            OverlayOpen::QueryHelp
599                        } else {
600                            OverlayOpen::Help
601                        }))
602                    } else {
603                        Some(EditorIntent::Consume)
604                    }
605                } else {
606                    None
607                }
608            }
609        };
610    }
611
612    // Single constructor: moves `flash` into the winning classification.
613    // Every call site diverges, so the FnOnce closure is used at most once.
614    let done = move |intent| Classification {
615        flash,
616        cancel_leader,
617        intent,
618    };
619
620    if let Some(intent) = shortcut_intent {
621        return done(intent);
622    }
623
624    // An open overlay intercepts all remaining input ahead of the panels.
625    if ctx.overlay.is_some() {
626        return done(EditorIntent::Overlay);
627    }
628
629    if matches!(event, InputEvent::Mouse(_)) {
630        // A double-click in the editor follows the link under it, exactly as
631        // Ctrl+N does — same intent, same executor, one follow path. The
632        // classifier cannot hit-test panels (that is `PanelSet`'s job, and why
633        // `Mouse` exists at all), so it leans on focus: the first press of the
634        // pair already focused whatever column it landed in.
635        if ctx.double_click && ctx.editor_active() {
636            return done(EditorIntent::FollowLink);
637        }
638        return done(EditorIntent::Mouse);
639    }
640
641    // Vim Normal mode: bare Space is a second leader gateway, but only with
642    // an empty pending state so it never shadows Space as a motion/operator
643    // argument. Insert/Visual and the other backends keep Space typing a
644    // space (`space_leads` is false for those states).
645    if ctx.editor_active()
646        && (!ctx.leader_pending || cancel_leader)
647        && let InputEvent::Key(key) = event
648        && key.code == KeyCode::Char(' ')
649        && key.modifiers.is_empty()
650        && ctx.space_leads
651    {
652        return done(EditorIntent::LeaderStart);
653    }
654
655    // Tab / Shift-Tab cycle panel focus. The focused panel gets
656    // first crack — the Query panel's autocomplete accepts on Tab — and the
657    // editor keeps Tab for indentation.
658    if ctx.focused != PanelKind::Editor
659        && let InputEvent::Key(key) = event
660        && matches!(key.code, KeyCode::Tab | KeyCode::BackTab)
661    {
662        return done(EditorIntent::Panel {
663            fallback: PanelFallback::FocusCycle(if key.code == KeyCode::Tab {
664                CycleDir::Right
665            } else {
666                CycleDir::Left
667            }),
668        });
669    }
670
671    // The drawer's FIND view gets first crack (its autocomplete popup may
672    // consume Esc); on an unhandled Esc it yields focus back to the editor.
673    if ctx.find_panel_focused()
674        && let InputEvent::Key(key) = event
675        && key.code == KeyCode::Esc
676    {
677        return done(EditorIntent::Panel {
678            fallback: PanelFallback::FocusEditor,
679        });
680    }
681
682    done(EditorIntent::Panel {
683        fallback: PanelFallback::None,
684    })
685}
686
687#[cfg(test)]
688mod tests {
689    use super::*;
690    use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind};
691
692    fn bindings() -> KeyBindings {
693        let mut kb = KeyBindings::empty();
694        kb.batch_add()
695            .with_ctrl()
696            .add(KeyStrike::KeyP, ActionShortcuts::OpenCommandPalette)
697            .add(KeyStrike::KeyK, ActionShortcuts::SearchNotes)
698            .add(KeyStrike::KeyG, ActionShortcuts::Leader)
699            .add(KeyStrike::KeyT, ActionShortcuts::ToggleSidebar)
700            .add(KeyStrike::KeyR, ActionShortcuts::OpenSortDialog)
701            .add(KeyStrike::KeyH, ActionShortcuts::FocusSidebar)
702            .add(KeyStrike::KeyB, ActionShortcuts::Text(TextAction::Bold))
703            .add(KeyStrike::KeyN, ActionShortcuts::FollowLink)
704            .add(KeyStrike::KeyW, ActionShortcuts::QuickNote);
705        kb.batch_add()
706            .add(KeyStrike::F2, ActionShortcuts::FileOperations);
707        kb
708    }
709
710    /// Editor focused, nothing pending — the common ctx.
711    fn ctx() -> InputCtx {
712        InputCtx {
713            overlay: None,
714            leader_pending: false,
715            focused: PanelKind::Editor,
716            drawer_view: DrawerView::Files,
717            space_leads: false,
718            claim: EditorClaim::None,
719            double_click: false,
720            overlay_sortable: false,
721            overlay_query: false,
722            pointer_on_editor: true,
723        }
724    }
725
726    /// The common ctx with the find bar holding the **editor claim**.
727    fn ctx_find_bar() -> InputCtx {
728        InputCtx {
729            claim: EditorClaim::FindBar,
730            ..ctx()
731        }
732    }
733
734    /// The common ctx with the press completing a double-click. Whether it
735    /// *is* one is `ClickRun`'s question, answered before this snapshot.
736    fn ctx_double() -> InputCtx {
737        InputCtx {
738            double_click: true,
739            ..ctx()
740        }
741    }
742
743    /// A left press. Coordinates are irrelevant to the classifier — it never
744    /// hit-tests; the screen decided the cell was the editor's before it fed
745    /// the click run.
746    fn press() -> InputEvent {
747        use ratatui::crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
748        InputEvent::Mouse(MouseEvent {
749            kind: MouseEventKind::Down(MouseButton::Left),
750            column: 1,
751            row: 1,
752            modifiers: KeyModifiers::NONE,
753        })
754    }
755
756    fn key(code: KeyCode, mods: KeyModifiers) -> InputEvent {
757        InputEvent::Key(KeyEvent::new(code, mods))
758    }
759
760    fn ctrl(c: char) -> InputEvent {
761        key(KeyCode::Char(c), KeyModifiers::CONTROL)
762    }
763
764    fn plain(c: char) -> InputEvent {
765        key(KeyCode::Char(c), KeyModifiers::NONE)
766    }
767
768    fn classify_it(event: &InputEvent, ctx: &InputCtx) -> Classification {
769        classify(event, &bindings(), ctx)
770    }
771
772    // ---- editor claim -----------------------------------------
773
774    /// The bug that motivated the claim: with the editor focused — which is
775    /// exactly the state when the find bar is open — a formatting chord
776    /// classified as a buffer edit and the bar never saw the key. Typing a
777    /// find pattern and pressing Ctrl+B emboldened the note.
778    #[test]
779    fn a_claim_blocks_a_buffer_edit() {
780        let ev = key(KeyCode::Char('b'), KeyModifiers::CONTROL);
781        assert_eq!(
782            classify_it(&ev, &ctx()).intent,
783            EditorIntent::Op(EditorOp::ApplyText(TextAction::Bold)),
784            "without a claim the chord still bolds"
785        );
786        assert_eq!(
787            classify_it(&ev, &ctx_find_bar()).intent,
788            EditorIntent::Panel {
789                fallback: PanelFallback::None
790            },
791            "under a claim it is delivered to the holder instead"
792        );
793    }
794
795    /// Navigation is not a buffer edit — the drawer, panel focus and the
796    /// palette stay reachable while searching.
797    #[test]
798    fn a_claim_leaves_navigation_alone() {
799        for ev in [
800            key(KeyCode::Char('t'), KeyModifiers::CONTROL),
801            key(KeyCode::Char('p'), KeyModifiers::CONTROL),
802        ] {
803            assert_eq!(
804                classify_it(&ev, &ctx_find_bar()).intent,
805                classify_it(&ev, &ctx()).intent,
806                "a claim must not swallow navigation"
807            );
808        }
809    }
810
811    /// A paste aimed at the replace field used to land in the buffer behind
812    /// the bar; the fix lived inside `paste_text`. It is a classification now.
813    #[test]
814    fn a_find_bar_claim_blocks_the_paste_tiers() {
815        for ev in [
816            InputEvent::Paste("hi".into()),
817            key(KeyCode::Char('v'), KeyModifiers::CONTROL),
818            key(KeyCode::Enter, KeyModifiers::CONTROL),
819        ] {
820            assert_eq!(
821                classify_it(&ev, &ctx_find_bar()).intent,
822                EditorIntent::Panel {
823                    fallback: PanelFallback::None
824                },
825                "paste, image probe and follow-link all belong to the bar"
826            );
827        }
828    }
829
830    /// Space is a character in a find pattern, not the leader gateway. This
831    /// used to be enforced by making `space_leads()` lie while the bar was
832    /// open — the smuggled fact the claim replaces.
833    #[test]
834    fn a_find_bar_claim_blocks_the_space_leader() {
835        let ev = key(KeyCode::Char(' '), KeyModifiers::NONE);
836        let vim_ctx = InputCtx {
837            space_leads: true,
838            ..ctx()
839        };
840        assert_eq!(classify_it(&ev, &vim_ctx).intent, EditorIntent::LeaderStart);
841        let vim_bar = InputCtx {
842            space_leads: true,
843            ..ctx_find_bar()
844        };
845        assert_eq!(
846            classify_it(&ev, &vim_bar).intent,
847            EditorIntent::Panel {
848                fallback: PanelFallback::None
849            }
850        );
851    }
852
853    /// A click would move the cursor off the current match and overwrite the
854    /// selection marking it — reachable as a panic before this. Scrolling is
855    /// allowed: it moves the viewport, not the cursor.
856    #[test]
857    fn a_find_bar_claim_blocks_clicks_but_not_scrolling() {
858        use ratatui::crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
859        let at = |kind| {
860            InputEvent::Mouse(MouseEvent {
861                kind,
862                column: 1,
863                row: 1,
864                modifiers: KeyModifiers::NONE,
865            })
866        };
867        assert_eq!(
868            classify_it(
869                &at(MouseEventKind::Down(MouseButton::Left)),
870                &ctx_find_bar()
871            )
872            .intent,
873            EditorIntent::Consume,
874            "a click reaches nothing while the bar holds the claim"
875        );
876        assert_eq!(
877            classify_it(&at(MouseEventKind::ScrollUp), &ctx_find_bar()).intent,
878            EditorIntent::Mouse,
879            "scrolling still works"
880        );
881    }
882
883    /// The claim lives inside the editor. A press elsewhere — the rail, the
884    /// drawer, the footer — is that surface's, even while the editor still
885    /// holds focus; consuming it froze every click in the app while the bar
886    /// was open.
887    #[test]
888    fn a_find_bar_claim_does_not_own_presses_off_the_editor() {
889        let off = InputCtx {
890            pointer_on_editor: false,
891            ..ctx_find_bar()
892        };
893        assert_eq!(classify_it(&press(), &off).intent, EditorIntent::Mouse);
894        assert_eq!(
895            classify_it(&key(KeyCode::Enter, KeyModifiers::CONTROL), &off).intent,
896            EditorIntent::Panel {
897                fallback: PanelFallback::None
898            },
899            "keys still belong to the bar"
900        );
901    }
902
903    // ── Following a link with the mouse (see `click_run`) ───────────────────
904
905    /// A press classifies as an ordinary mouse event whatever it lands on.
906    /// Following is the *second* press of a pair, never the first — this is
907    /// the regression that a stray click can no longer navigate.
908    #[test]
909    fn a_single_press_only_reaches_the_editor() {
910        assert_eq!(classify_it(&press(), &ctx()).intent, EditorIntent::Mouse);
911    }
912
913    /// The whole point: a double-click means the same thing as Ctrl+N, so it
914    /// produces the same intent and runs through the same executor.
915    #[test]
916    fn a_double_click_follows_the_link_under_it() {
917        assert_eq!(
918            classify_it(&press(), &ctx_double()).intent,
919            EditorIntent::FollowLink,
920            "the same intent Ctrl+N produces"
921        );
922        assert_eq!(
923            classify_it(&key(KeyCode::Char('n'), KeyModifiers::CONTROL), &ctx()).intent,
924            EditorIntent::FollowLink,
925            "and Ctrl+N still produces it"
926        );
927    }
928
929    /// The classifier cannot hit-test panels, so it leans on focus — which the
930    /// *first* press of the pair set. Double-clicking a row in the drawer must
931    /// not follow whatever the editor's cursor happens to sit on.
932    #[test]
933    fn a_double_click_outside_the_editor_is_an_ordinary_click() {
934        let drawer = InputCtx {
935            focused: PanelKind::Drawer,
936            ..ctx_double()
937        };
938        assert_eq!(classify_it(&press(), &drawer).intent, EditorIntent::Mouse);
939    }
940
941    /// An overlay owns the input ahead of the panels, so a double-click under
942    /// one is the overlay's, not a follow against the buffer behind it.
943    #[test]
944    fn an_overlay_outranks_a_double_click() {
945        let covered = InputCtx {
946            overlay: Some(OverlayKind::NoteBrowser),
947            ..ctx_double()
948        };
949        assert_eq!(
950            classify_it(&press(), &covered).intent,
951            EditorIntent::Overlay
952        );
953    }
954
955    /// Same intent, different origin, different answer. Ctrl+Enter belongs to
956    /// the bar (it is a text field); the press underneath a double-click does
957    /// not, and the bar has no way to represent the cursor move it implies.
958    #[test]
959    fn a_find_bar_claim_swallows_a_double_click_but_takes_ctrl_enter() {
960        let bar_double = InputCtx {
961            double_click: true,
962            ..ctx_find_bar()
963        };
964        assert_eq!(
965            classify_it(&press(), &bar_double).intent,
966            EditorIntent::Consume,
967            "a follow that arrived as a click is still a click"
968        );
969        assert_eq!(
970            classify_it(&key(KeyCode::Enter, KeyModifiers::CONTROL), &ctx_find_bar()).intent,
971            EditorIntent::Panel {
972                fallback: PanelFallback::None
973            },
974            "but a follow that arrived as a key is delivered to the bar"
975        );
976    }
977
978    /// The claim filter has three holders, not two. A mouse-origin follow
979    /// degrades to whatever a plain press does under the holder — which for the
980    /// popup means being delivered, so it can dismiss itself and place the
981    /// cursor. Consuming it instead left the popup open over a note that had
982    /// navigated away.
983    #[test]
984    fn an_autocomplete_claim_turns_a_double_click_back_into_a_press() {
985        let popup = InputCtx {
986            claim: EditorClaim::Autocomplete,
987            double_click: true,
988            ..ctx()
989        };
990        assert_eq!(classify_it(&press(), &popup).intent, EditorIntent::Mouse);
991        assert_eq!(
992            classify_it(&key(KeyCode::Char('n'), KeyModifiers::CONTROL), &popup).intent,
993            EditorIntent::FollowLink,
994            "the popup does not own the keyboard follow"
995        );
996    }
997
998    /// The asymmetry that makes the claim an enum rather than a bool: the
999    /// popup wants the events the bar blocks. A click should dismiss it and
1000    /// place the cursor.
1001    #[test]
1002    fn an_autocomplete_claim_blocks_only_buffer_edits() {
1003        let popup = InputCtx {
1004            claim: EditorClaim::Autocomplete,
1005            ..ctx()
1006        };
1007        assert_eq!(
1008            classify_it(&InputEvent::Paste("hi".into()), &popup).intent,
1009            EditorIntent::EditorPaste,
1010            "the popup does not own pastes"
1011        );
1012        assert_eq!(
1013            classify_it(&key(KeyCode::Char('b'), KeyModifiers::CONTROL), &popup).intent,
1014            EditorIntent::Panel {
1015                fallback: PanelFallback::None
1016            },
1017            "but a buffer edit still loses to any holder"
1018        );
1019    }
1020
1021    /// A pending leader sequence outranks a claim.
1022    #[test]
1023    fn the_leader_outranks_a_claim() {
1024        let ev = key(KeyCode::Char('x'), KeyModifiers::NONE);
1025        let pending = InputCtx {
1026            leader_pending: true,
1027            ..ctx_find_bar()
1028        };
1029        assert!(matches!(
1030            classify_it(&ev, &pending).intent,
1031            EditorIntent::LeaderKey(_)
1032        ));
1033    }
1034
1035    // ---- paste tiers -----------------------------------------------------
1036
1037    #[test]
1038    fn bracketed_paste_in_editor_is_editor_paste() {
1039        let c = classify_it(&InputEvent::Paste("hi".into()), &ctx());
1040        assert_eq!(c.intent, EditorIntent::EditorPaste);
1041    }
1042
1043    #[test]
1044    fn bracketed_paste_with_overlay_routes_to_overlay() {
1045        let mut cx = ctx();
1046        cx.overlay = Some(OverlayKind::NoteBrowser);
1047        let c = classify_it(&InputEvent::Paste("hi".into()), &cx);
1048        assert_eq!(c.intent, EditorIntent::Overlay);
1049    }
1050
1051    #[test]
1052    fn bracketed_paste_drawer_focused_goes_to_panel() {
1053        let mut cx = ctx();
1054        cx.focused = PanelKind::Drawer;
1055        let c = classify_it(&InputEvent::Paste("hi".into()), &cx);
1056        assert_eq!(
1057            c.intent,
1058            EditorIntent::Panel {
1059                fallback: PanelFallback::None
1060            }
1061        );
1062    }
1063
1064    #[test]
1065    fn ctrl_v_in_editor_is_a_bare_image_probe() {
1066        // The probe carries no precomputed fallback: the executor
1067        // reclassifies the tail only when the clipboard holds no image.
1068        let c = classify_it(&ctrl('v'), &ctx());
1069        assert_eq!(c.intent, EditorIntent::ImageProbe);
1070        assert_eq!(c.flash, None, "flash belongs to the no-image tail only");
1071    }
1072
1073    #[test]
1074    fn ctrl_v_no_image_tail_reaches_panel_with_a_flash() {
1075        // The executor's no-image path: classify_tail against post-probe
1076        // state (leader already cancelled → cancel_leader = false). Ctrl+V
1077        // is unbound, so it reaches the focused panel (the editor's own
1078        // paste), flashing the chord on the way through the shortcut tier.
1079        let c = classify_tail(&ctrl('v'), &bindings(), &ctx(), false);
1080        assert_eq!(
1081            c.intent,
1082            EditorIntent::Panel {
1083                fallback: PanelFallback::None
1084            }
1085        );
1086        assert!(c.flash.is_some());
1087        assert!(!c.cancel_leader, "the outer probe already owned the cancel");
1088    }
1089
1090    #[test]
1091    fn leader_pending_ctrl_v_cancels_leader_then_probes_image() {
1092        // Ctrl-chord exception: the chord cancels the pending sequence
1093        // and dispatches normally — for Ctrl+V that is the image probe, so
1094        // the cancel rides on the probe classification (the leader must die
1095        // whether or not the clipboard holds an image), and exactly once:
1096        // the no-image reclassification must not re-cancel.
1097        let mut cx = ctx();
1098        cx.leader_pending = true;
1099        let c = classify_it(&ctrl('v'), &cx);
1100        assert!(c.cancel_leader);
1101        assert_eq!(c.intent, EditorIntent::ImageProbe);
1102    }
1103
1104    #[test]
1105    fn leader_pending_bracketed_paste_cancels_leader_then_pastes() {
1106        // A paste payload is not a key the sequence can consume: the
1107        // ctrl-chord rule applied to a non-key event — cancel, then paste.
1108        let mut cx = ctx();
1109        cx.leader_pending = true;
1110        let c = classify_it(&InputEvent::Paste("hi".into()), &cx);
1111        assert!(c.cancel_leader);
1112        assert_eq!(c.intent, EditorIntent::EditorPaste);
1113    }
1114
1115    #[test]
1116    fn leader_pending_ctrl_enter_feeds_leader() {
1117        // Enter is not a Char ctrl-chord, so no exception applies: the
1118        // pending sequence owns the key ahead of follow-link.
1119        let mut cx = ctx();
1120        cx.leader_pending = true;
1121        let c = classify_it(&key(KeyCode::Enter, KeyModifiers::CONTROL), &cx);
1122        assert_eq!(
1123            c.intent,
1124            EditorIntent::LeaderKey(KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL))
1125        );
1126        assert!(!c.cancel_leader);
1127    }
1128
1129    #[test]
1130    fn ctrl_enter_in_editor_follows_link() {
1131        let c = classify_it(&key(KeyCode::Enter, KeyModifiers::CONTROL), &ctx());
1132        assert_eq!(c.intent, EditorIntent::FollowLink);
1133    }
1134
1135    // ---- leader tier -----------------------------------------------------
1136
1137    #[test]
1138    fn leader_pending_plain_key_feeds_leader() {
1139        let mut cx = ctx();
1140        cx.leader_pending = true;
1141        let c = classify_it(&plain('f'), &cx);
1142        assert_eq!(
1143            c.intent,
1144            EditorIntent::LeaderKey(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE))
1145        );
1146        assert!(!c.cancel_leader);
1147    }
1148
1149    #[test]
1150    fn leader_pending_with_overlay_cancels_and_routes_to_overlay() {
1151        let mut cx = ctx();
1152        cx.leader_pending = true;
1153        cx.overlay = Some(OverlayKind::Dialog);
1154        let c = classify_it(&plain('f'), &cx);
1155        assert!(c.cancel_leader);
1156        assert_eq!(c.intent, EditorIntent::Overlay);
1157    }
1158
1159    #[test]
1160    fn leader_pending_ctrl_chord_cancels_then_dispatches() {
1161        let mut cx = ctx();
1162        cx.leader_pending = true;
1163        let c = classify_it(&ctrl('p'), &cx);
1164        assert!(c.cancel_leader);
1165        assert_eq!(
1166            c.intent,
1167            EditorIntent::ToggleOverlay {
1168                kind: OverlayKind::CommandPalette,
1169                open: OverlayOpen::CommandPalette
1170            }
1171        );
1172    }
1173
1174    // ---- shortcut tier ---------------------------------------------------
1175
1176    #[test]
1177    fn command_palette_chord_toggles_regardless_of_open_state() {
1178        // Same intent open or closed — the executor dismisses when the kind
1179        // is already active.
1180        let c = classify_it(&ctrl('p'), &ctx());
1181        assert_eq!(
1182            c.intent,
1183            EditorIntent::ToggleOverlay {
1184                kind: OverlayKind::CommandPalette,
1185                open: OverlayOpen::CommandPalette
1186            }
1187        );
1188        assert!(c.flash.is_some());
1189
1190        let mut cx = ctx();
1191        cx.overlay = Some(OverlayKind::CommandPalette);
1192        let c = classify_it(&ctrl('p'), &cx);
1193        assert_eq!(
1194            c.intent,
1195            EditorIntent::ToggleOverlay {
1196                kind: OverlayKind::CommandPalette,
1197                open: OverlayOpen::CommandPalette
1198            }
1199        );
1200    }
1201
1202    #[test]
1203    fn leader_gateway_starts_sequence_without_flash() {
1204        let c = classify_it(&ctrl('g'), &ctx());
1205        assert_eq!(c.intent, EditorIntent::LeaderStart);
1206        assert_eq!(c.flash, None);
1207    }
1208
1209    #[test]
1210    fn leader_gateway_with_overlay_is_consumed_noop() {
1211        let mut cx = ctx();
1212        cx.overlay = Some(OverlayKind::NoteBrowser);
1213        let c = classify_it(&ctrl('g'), &cx);
1214        assert_eq!(c.intent, EditorIntent::Consume);
1215    }
1216
1217    #[test]
1218    fn focus_sidebar_with_overlay_is_consumed_noop() {
1219        let mut cx = ctx();
1220        cx.overlay = Some(OverlayKind::NoteBrowser);
1221        let c = classify_it(&ctrl('h'), &cx);
1222        assert_eq!(c.intent, EditorIntent::Consume);
1223    }
1224
1225    #[test]
1226    fn toggle_drawer_chord_is_action() {
1227        let c = classify_it(&ctrl('t'), &ctx());
1228        assert_eq!(c.intent, EditorIntent::Op(EditorOp::ToggleDrawer));
1229    }
1230
1231    #[test]
1232    fn quick_note_opens_dialog_only_without_overlay() {
1233        let c = classify_it(&ctrl('w'), &ctx());
1234        assert_eq!(c.intent, EditorIntent::OpenOverlay(OverlayOpen::QuickNote));
1235
1236        let mut cx = ctx();
1237        cx.overlay = Some(OverlayKind::Dialog);
1238        let c = classify_it(&ctrl('w'), &cx);
1239        assert_eq!(c.intent, EditorIntent::Consume);
1240    }
1241
1242    #[test]
1243    fn text_style_chord_needs_active_editor() {
1244        let c = classify_it(&ctrl('b'), &ctx());
1245        assert_eq!(
1246            c.intent,
1247            EditorIntent::Op(EditorOp::ApplyText(TextAction::Bold))
1248        );
1249
1250        // Drawer focused: the guard fails and the chord falls through the
1251        // match to the focused panel.
1252        let mut cx = ctx();
1253        cx.focused = PanelKind::Drawer;
1254        let c = classify_it(&ctrl('b'), &cx);
1255        assert_eq!(
1256            c.intent,
1257            EditorIntent::Panel {
1258                fallback: PanelFallback::None
1259            }
1260        );
1261    }
1262
1263    #[test]
1264    fn sort_dialog_targets_the_focused_drawer_view() {
1265        let mut cx = ctx();
1266        cx.focused = PanelKind::Drawer;
1267        cx.drawer_view = DrawerView::Find;
1268        let c = classify_it(&ctrl('r'), &cx);
1269        assert_eq!(c.intent, EditorIntent::OpenOverlay(OverlayOpen::SortQuery));
1270
1271        cx.drawer_view = DrawerView::Files;
1272        let c = classify_it(&ctrl('r'), &cx);
1273        assert_eq!(
1274            c.intent,
1275            EditorIntent::OpenOverlay(OverlayOpen::SortSidebar)
1276        );
1277
1278        // A drawer view without a sortable list consumes the chord.
1279        cx.drawer_view = DrawerView::Tags;
1280        let c = classify_it(&ctrl('r'), &cx);
1281        assert_eq!(c.intent, EditorIntent::Consume);
1282    }
1283
1284    #[test]
1285    fn sort_dialog_chord_falls_through_when_editor_focused() {
1286        // Ctrl+R must reach the editor (redo in the nvim backend).
1287        let c = classify_it(&ctrl('r'), &ctx());
1288        assert_eq!(
1289            c.intent,
1290            EditorIntent::Panel {
1291                fallback: PanelFallback::None
1292            }
1293        );
1294    }
1295
1296    /// Ctrl+R over a sortable note browser opens the sort dialog on top of
1297    /// it; a non-sortable browser (link results) or any other overlay keeps
1298    /// the chord.
1299    #[test]
1300    fn sort_dialog_over_a_sortable_note_browser() {
1301        let cx = InputCtx {
1302            overlay: Some(OverlayKind::NoteBrowser),
1303            overlay_sortable: true,
1304            ..ctx()
1305        };
1306        let c = classify_it(&ctrl('r'), &cx);
1307        assert_eq!(
1308            c.intent,
1309            EditorIntent::OpenOverlay(OverlayOpen::SortBrowser)
1310        );
1311
1312        let links = InputCtx {
1313            overlay_sortable: false,
1314            ..cx.clone()
1315        };
1316        let c = classify_it(&ctrl('r'), &links);
1317        assert_eq!(c.intent, EditorIntent::Overlay);
1318
1319        // A sortable flag on a non-browser overlay does not count.
1320        let dialog = InputCtx {
1321            overlay: Some(OverlayKind::Dialog),
1322            ..cx
1323        };
1324        let c = classify_it(&ctrl('r'), &dialog);
1325        assert_eq!(c.intent, EditorIntent::Overlay);
1326    }
1327
1328    #[test]
1329    fn f1_opens_help_or_query_help_by_focus() {
1330        let c = classify_it(&key(KeyCode::F(1), KeyModifiers::NONE), &ctx());
1331        assert_eq!(c.intent, EditorIntent::OpenOverlay(OverlayOpen::Help));
1332
1333        let mut cx = ctx();
1334        cx.focused = PanelKind::Drawer;
1335        cx.drawer_view = DrawerView::Find;
1336        let c = classify_it(&key(KeyCode::F(1), KeyModifiers::NONE), &cx);
1337        assert_eq!(c.intent, EditorIntent::OpenOverlay(OverlayOpen::QueryHelp));
1338    }
1339
1340    #[test]
1341    fn unbound_fkeys_are_sunk_with_a_flash() {
1342        let c = classify_it(&key(KeyCode::F(9), KeyModifiers::NONE), &ctx());
1343        assert_eq!(c.intent, EditorIntent::Consume);
1344        assert!(c.flash.is_some());
1345    }
1346
1347    /// A bound chord flashes what it *does*, not which keys were pressed — the
1348    /// user chose the binding, so the chord is the half they already know.
1349    #[test]
1350    fn a_bound_chord_flashes_the_action_label() {
1351        let c = classify_it(&ctrl('k'), &ctx());
1352        assert_eq!(
1353            c.flash.as_deref(),
1354            Some(ActionShortcuts::SearchNotes.label().as_str())
1355        );
1356    }
1357
1358    /// An unbound chord has no action to name, so the echo stays — it is the
1359    /// only answer available to "what did I just press?".
1360    #[test]
1361    fn an_unbound_chord_still_flashes_the_raw_chord() {
1362        // Ctrl+Y is deliberately absent from this test's bindings.
1363        let c = classify_it(&ctrl('y'), &ctx());
1364        let combo =
1365            key_event_to_combo(&KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL)).unwrap();
1366        assert_eq!(c.flash.as_deref(), Some(combo.to_string().as_str()));
1367    }
1368
1369    #[test]
1370    fn high_fkeys_are_sunk_like_the_rest() {
1371        // "All F-keys are consumed and never forwarded to the editor" —
1372        // including F13+ (KeyStrike defines up to F25); the shared
1373        // KeyStrike::is_fkey predicate keeps this in step with the
1374        // binding validator.
1375        let c = classify_it(&key(KeyCode::F(13), KeyModifiers::NONE), &ctx());
1376        assert_eq!(c.intent, EditorIntent::Consume);
1377    }
1378
1379    #[test]
1380    fn bound_fkey_dispatches_when_guard_holds_and_sinks_when_not() {
1381        // F2 = FileOperations, guarded on the active editor.
1382        let c = classify_it(&key(KeyCode::F(2), KeyModifiers::NONE), &ctx());
1383        assert_eq!(c.intent, EditorIntent::Op(EditorOp::ShowFileOps));
1384
1385        let mut cx = ctx();
1386        cx.focused = PanelKind::Drawer;
1387        let c = classify_it(&key(KeyCode::F(2), KeyModifiers::NONE), &cx);
1388        assert_eq!(c.intent, EditorIntent::Consume);
1389    }
1390
1391    // ---- lower tiers -----------------------------------------------------
1392
1393    #[test]
1394    fn overlay_intercepts_unbound_keys() {
1395        let mut cx = ctx();
1396        cx.overlay = Some(OverlayKind::SavedSearches);
1397        let c = classify_it(&plain('x'), &cx);
1398        assert_eq!(c.intent, EditorIntent::Overlay);
1399    }
1400
1401    #[test]
1402    fn mouse_events_take_the_hit_test_path() {
1403        let ev = InputEvent::Mouse(MouseEvent {
1404            kind: MouseEventKind::Moved,
1405            column: 3,
1406            row: 4,
1407            modifiers: KeyModifiers::NONE,
1408        });
1409        let c = classify_it(&ev, &ctx());
1410        assert_eq!(c.intent, EditorIntent::Mouse);
1411    }
1412
1413    #[test]
1414    fn vim_space_starts_leader_only_when_it_leads() {
1415        let mut cx = ctx();
1416        cx.space_leads = true;
1417        let c = classify_it(&plain(' '), &cx);
1418        assert_eq!(c.intent, EditorIntent::LeaderStart);
1419
1420        cx.space_leads = false;
1421        let c = classify_it(&plain(' '), &cx);
1422        assert_eq!(
1423            c.intent,
1424            EditorIntent::Panel {
1425                fallback: PanelFallback::None
1426            }
1427        );
1428    }
1429
1430    #[test]
1431    fn tab_cycles_focus_when_a_non_editor_panel_passes() {
1432        let mut cx = ctx();
1433        cx.focused = PanelKind::Drawer;
1434        let c = classify_it(&key(KeyCode::Tab, KeyModifiers::NONE), &cx);
1435        assert_eq!(
1436            c.intent,
1437            EditorIntent::Panel {
1438                fallback: PanelFallback::FocusCycle(CycleDir::Right)
1439            }
1440        );
1441
1442        let c = classify_it(&key(KeyCode::BackTab, KeyModifiers::SHIFT), &cx);
1443        assert_eq!(
1444            c.intent,
1445            EditorIntent::Panel {
1446                fallback: PanelFallback::FocusCycle(CycleDir::Left)
1447            }
1448        );
1449    }
1450
1451    #[test]
1452    fn editor_keeps_tab_for_indentation() {
1453        let c = classify_it(&key(KeyCode::Tab, KeyModifiers::NONE), &ctx());
1454        assert_eq!(
1455            c.intent,
1456            EditorIntent::Panel {
1457                fallback: PanelFallback::None
1458            }
1459        );
1460    }
1461
1462    #[test]
1463    fn find_view_yields_focus_to_editor_on_unhandled_esc() {
1464        let mut cx = ctx();
1465        cx.focused = PanelKind::Drawer;
1466        cx.drawer_view = DrawerView::Find;
1467        let c = classify_it(&key(KeyCode::Esc, KeyModifiers::NONE), &cx);
1468        assert_eq!(
1469            c.intent,
1470            EditorIntent::Panel {
1471                fallback: PanelFallback::FocusEditor
1472            }
1473        );
1474
1475        // Any other unhandled key propagates as-is.
1476        let c = classify_it(&plain('x'), &cx);
1477        assert_eq!(
1478            c.intent,
1479            EditorIntent::Panel {
1480                fallback: PanelFallback::None
1481            }
1482        );
1483    }
1484}