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