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