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