use anyhow::Result;
use crossterm::{
    cursor::SetCursorStyle,
    event::{self, Event, KeyCode, KeyEvent, KeyModifiers},
    execute,
};
use hjkl_engine::{CursorShape, Host, VimMode};
use hjkl_keymap::{Chord as KmChord, KeyEvent as KmKeyEvent};
use hjkl_vim::VimEditorExt;
use ratatui::{Terminal, backend::CrosstermBackend};
use std::io::Stdout;
use std::time::Duration;

use super::{App, STATUS_LINE_HEIGHT, prompt_cursor_shape};
use crate::render;

/// How long the mouse must rest on a Code zone before the LSP hover RPC fires.
const HOVER_DELAY: Duration = Duration::from_millis(500);

/// Inline-blame ghost idle gate, mirroring `render.rs::render_window`'s
/// `BLAME_IDLE_DELAY`: the ghost appears once the cursor has been still for
/// this long. The event loop keeps repainting within the window after each
/// cursor move so the ghost's first appearance isn't skipped by the
/// idle-draw gate (the renderer reads `blame_cursor_moved_at` directly).
const BLAME_IDLE_DELAY: Duration = Duration::from_millis(400);

/// Outcome returned by [`App::handle_keypress`].
pub enum KeyOutcome {
    /// Continue the event loop (equivalent to `continue`).
    Continue,
    /// Break out of the event loop (equivalent to `break`).
    Break,
    /// Key was not consumed by any overlay/prefix handler; fall through to
    /// the engine (Insert or Normal/Visual via hjkl_vim_tui::handle_key).
    FallThrough,
}

/// Outcome returned by [`App::handle_mouse`].
pub enum MouseOutcome {
    /// Continue the event loop.
    Continue,
    /// Fall through (no explicit `continue` needed but loop iterates).
    FallThrough,
}

/// Translate a crossterm `KeyEvent` to a `hjkl_keymap::KeyEvent`.
/// Returns `None` for release events or unsupported key codes.
fn to_km_event(key: KeyEvent) -> Option<KmKeyEvent> {
    crate::keymap_translate::from_crossterm(&key)
}

/// Replay a slice of `hjkl_keymap::KeyEvent`s to the engine via crossterm
/// `KeyEvent`s. Thin wrapper delegating to `App::replay_to_engine`; kept
/// for callers inside this file that pass `app` as a plain `&mut App` arg.
fn replay_to_engine(app: &mut App, events: &[KmKeyEvent]) {
    app.replay_to_engine(events);
}

/// Map a [`hjkl_vim::OperatorKind`] (reducer-side) to a
/// [`hjkl_engine::Operator`] (engine-side). All nine reducer-side operators
/// have a corresponding engine variant.
pub fn op_kind_to_operator(k: hjkl_vim::OperatorKind) -> hjkl_engine::Operator {
    match k {
        hjkl_vim::OperatorKind::Delete => hjkl_engine::Operator::Delete,
        hjkl_vim::OperatorKind::Yank => hjkl_engine::Operator::Yank,
        hjkl_vim::OperatorKind::Change => hjkl_engine::Operator::Change,
        hjkl_vim::OperatorKind::Indent => hjkl_engine::Operator::Indent,
        hjkl_vim::OperatorKind::Outdent => hjkl_engine::Operator::Outdent,
        hjkl_vim::OperatorKind::Uppercase => hjkl_engine::Operator::Uppercase,
        hjkl_vim::OperatorKind::Lowercase => hjkl_engine::Operator::Lowercase,
        hjkl_vim::OperatorKind::ToggleCase => hjkl_engine::Operator::ToggleCase,
        hjkl_vim::OperatorKind::Reflow => hjkl_engine::Operator::Reflow,
        hjkl_vim::OperatorKind::ReflowKeepCursor => hjkl_engine::Operator::ReflowKeepCursor,
        hjkl_vim::OperatorKind::AutoIndent => hjkl_engine::Operator::AutoIndent,
        hjkl_vim::OperatorKind::Filter => hjkl_engine::Operator::Filter,
        hjkl_vim::OperatorKind::Comment => hjkl_engine::Operator::Comment,
    }
}

impl App {
    /// Insert-mode key dispatcher. Calls `Editor::insert_*` primitives
    /// directly, bypassing the engine FSM for Insert-mode keys.
    ///
    /// This is called from the main event loop whenever the editor is in
    /// `VimMode::Insert` and the key has not been consumed by an overlay
    /// (completion popup, etc.). Normal / Visual modes still route through
    /// `hjkl_vim_tui::handle_key`.
    ///
    /// ### `Ctrl-R {reg}` — register paste
    /// `insert_ctrl_r_arm()` sets an internal flag (`insert_pending_register`).
    /// The NEXT printable character names the register; we detect this via
    /// `editor.is_insert_register_pending()` and call
    /// `insert_paste_register(c)` instead of `insert_char(c)`.
    ///
    /// ### `Ctrl-O` — one-shot normal
    /// `insert_ctrl_o_arm()` flips `vim.mode` to Normal (and syncs
    /// `current_mode`). The NEXT key therefore reads `vim_mode() == Normal`
    /// and is dispatched as a Normal-mode key naturally — no extra flag needed
    /// here. After that single normal command the engine's end-of-step hook
    /// flips back to Insert.
    pub(crate) fn dispatch_insert_key(&mut self, key: KeyEvent) {
        use crossterm::event::{KeyCode, KeyModifiers};
        use hjkl_engine::InsertDir;

        // Macro recording for keys that reach this dispatcher happens upstream
        // in `handle_keypress`'s Insert-mode block (the single hook there
        // covers consume-and-return Continue paths AND fall-through paths so
        // we don't double-record). Don't add a hook here.

        // `Ctrl-R` two-key sequence: the previous key armed the register
        // selector. The next printable char names the register to paste.
        // Any non-printable key cancels (mirrors vim behaviour).
        if self.active_editor().is_insert_register_pending() {
            // Clear the flag first (mirrors step_insert which clears before
            // calling insert_paste_register_bridge).
            self.active_editor_mut().clear_insert_register_pending();
            if let (KeyCode::Char(c), mods) = (key.code, key.modifiers)
                && !mods.contains(KeyModifiers::CONTROL)
            {
                self.active_editor_mut().insert_paste_register(c);
            }
            // Non-char key: flag already cleared; just drop the key.
            return;
        }

        match (key.code, key.modifiers) {
            // Printable characters (including shifted variants like 'A', '!', …).
            // Crossterm sets SHIFT for capital letters but the char `c` already
            // contains the upper-cased glyph, so we just forward `c` directly.
            (KeyCode::Char(c), mods)
                if mods == KeyModifiers::NONE || mods == KeyModifiers::SHIFT =>
            {
                self.active_editor_mut().insert_char(c);
            }

            // Navigation / editing keys
            (KeyCode::Backspace, _) => self.active_editor_mut().insert_backspace(),
            (KeyCode::Enter, _) => self.active_editor_mut().insert_newline(),
            (KeyCode::Tab, _) => self.active_editor_mut().insert_tab(),
            (KeyCode::Esc, _) => self.active_editor_mut().leave_insert_to_normal(),
            (KeyCode::Delete, _) => self.active_editor_mut().insert_delete(),
            (KeyCode::Home, _) => self.active_editor_mut().insert_home(),
            (KeyCode::End, _) => self.active_editor_mut().insert_end(),

            // Arrow keys
            (KeyCode::Left, _) => self.active_editor_mut().insert_arrow(InsertDir::Left),
            (KeyCode::Right, _) => self.active_editor_mut().insert_arrow(InsertDir::Right),
            (KeyCode::Up, _) => self.active_editor_mut().insert_arrow(InsertDir::Up),
            (KeyCode::Down, _) => self.active_editor_mut().insert_arrow(InsertDir::Down),

            // Page keys — need the current viewport height.
            (KeyCode::PageUp, _) => {
                let h = self.active_editor().viewport_height_value();
                self.active_editor_mut().insert_pageup(h);
            }
            (KeyCode::PageDown, _) => {
                let h = self.active_editor().viewport_height_value();
                self.active_editor_mut().insert_pagedown(h);
            }

            // Ctrl-prefixed insert shortcuts
            (KeyCode::Char('w'), KeyModifiers::CONTROL) => self.active_editor_mut().insert_ctrl_w(),
            (KeyCode::Char('u'), KeyModifiers::CONTROL) => self.active_editor_mut().insert_ctrl_u(),
            (KeyCode::Char('h'), KeyModifiers::CONTROL) => self.active_editor_mut().insert_ctrl_h(),
            // `Ctrl-O`: flip to one-shot Normal; the next key routes as Normal.
            (KeyCode::Char('o'), KeyModifiers::CONTROL) => {
                self.active_editor_mut().insert_ctrl_o_arm()
            }
            // `Ctrl-R`: arm register selector; next char calls insert_paste_register.
            (KeyCode::Char('r'), KeyModifiers::CONTROL) => {
                self.active_editor_mut().insert_ctrl_r_arm()
            }
            (KeyCode::Char('t'), KeyModifiers::CONTROL) => self.active_editor_mut().insert_ctrl_t(),
            (KeyCode::Char('d'), KeyModifiers::CONTROL) => self.active_editor_mut().insert_ctrl_d(),

            // Silently drop unrecognised keys (function keys, Alt combos, etc.).
            _ => {}
        }
    }

    /// Dispatch a key that fell through [`App::handle_keypress`] (i.e. wasn't
    /// consumed by any overlay/prefix handler) to the engine — Insert-mode
    /// primitives via [`App::dispatch_insert_key`], or the Normal/Visual FSM
    /// via `hjkl_vim_tui::handle_key` — then run the full post-mutation sync:
    /// viewport, dirty/reset/edits, syntax-span shift, LSP notify, sibling
    /// cursor rebase, sibling-fold + diff-pair sync, and scroll-anim
    /// bookkeeping.
    ///
    /// This is the single canonical fall-through sync path. It used to be
    /// duplicated inline at two call sites — the primary key-read arm and the
    /// same-tick drain-loop mirror in [`App::run`] — which drifted apart from
    /// [`App::sync_after_engine_mutation`] (mod.rs): both copies omitted
    /// sibling `window_folds` invalidation and diff-pair realignment. With a
    /// diff pair active, an edit on either path left `diff_cache` stale,
    /// eventually panicking in `rope.line(row)` (`diff_mode.rs`) once the
    /// buffer shrank past a cached row (e.g. `dG` near EOF). Factoring both
    /// call sites through this one method closes that gap for good.
    ///
    /// Deliberately does **not** call `ensure_cursor_in_scrolloff()` the way
    /// [`App::sync_after_engine_mutation`] does: both `dispatch_insert_key`'s
    /// primitives and the engine FSM's `step()` (reached via
    /// `hjkl_vim_tui::handle_key`) already clamp scrolloff internally, so an
    /// extra call here would be redundant (harmless, but pure noise).
    /// Likewise `pending_recompute` is set unconditionally rather than
    /// gated on whether the syntax view actually changed — see the module
    /// doc on [`App::run`]: keystroke arms must always defer a recompute,
    /// full stop, rather than special-case the gate mouse-drag bursts use.
    pub(crate) fn dispatch_fallthrough_key(&mut self, key: KeyEvent) {
        self.scroll_anim = None; // any new key cancels a running animation
        let prev_top = self.window_scroll(self.focused_window()).0;
        let mode_was_insert = self.active_editor().vim_mode() == VimMode::Insert;
        if mode_was_insert {
            self.dispatch_insert_key(key);
            self.active_editor_mut().emit_cursor_shape_if_changed();
        } else {
            hjkl_vim_tui::handle_key(self.active_editor_mut(), key);
        }

        self.sync_viewport_from_editor();
        if self.active_editor_mut().take_dirty() {
            let elapsed = self.active_mut().refresh_dirty_against_saved();
            self.last_signature_us = elapsed;
            if self.active().dirty {
                self.active_mut().is_new_file = false;
            }
        }
        let buffer_id = self.active().buffer_id;
        if self.active_editor_mut().take_content_reset() {
            self.handle_active_content_reset(buffer_id);
        }
        let edits = self.active_editor_mut().take_content_edits();
        if !edits.is_empty() {
            self.syntax.apply_edits(buffer_id, &edits);
            self.active_editor_mut()
                .shift_syntax_spans_for_edits(&edits);
        }
        self.lsp_notify_change_active(&edits);
        self.rebase_sibling_cursors(&edits);
        self.sync_diff_and_fold_siblings(&edits);
        // Drain pending fold ops to prevent unbounded growth;
        // `recompute_and_install` (via `pending_recompute`)
        // handles the visual refresh.
        let _ = self.active_editor_mut().take_fold_ops();
        self.drain_engine_errors();
        {
            let hint = self.active_editor_mut().take_scroll_anim_hint();
            if hint {
                let ms = self.active_editor().settings().scroll_duration_ms;
                let win = self.focused_window();
                let new_top = self.window_scroll(win).0;
                if ms > 0 && new_top != prev_top {
                    self.scroll_anim = Some(crate::app::ScrollAnim {
                        win_id: win,
                        start_top: prev_top,
                        target_top: new_top,
                        started_at: std::time::Instant::now(),
                        duration: std::time::Duration::from_millis(ms as u64),
                    });
                }
            }
        }
        self.pending_recompute = true;
    }

    /// Handle a terminal bracketed-paste (`Event::Paste`) — the whole pasted
    /// blob arrives as one atomic string with real newlines.
    ///
    /// Insert mode inserts the text verbatim at the cursor: `insert_str` splits
    /// embedded `\n` into lines and applies **no** autoindent, which is correct
    /// paste behaviour (no cascading-indent like `:set paste`). CRLF/CR are
    /// normalised to LF so Windows-clipboard line endings split lines too.
    ///
    /// In any non-Insert mode the paste is dropped: feeding pasted bytes to the
    /// Normal-mode FSM would interpret them as commands, which is worse than a
    /// no-op. (Pasting into the `:`/`/` command line is future work.)
    pub(crate) fn handle_paste(&mut self, text: String) {
        if text.is_empty() {
            return;
        }
        self.last_input_at = std::time::Instant::now();
        if self.active_editor().vim_mode() != VimMode::Insert {
            return;
        }
        let normalised = if text.contains('\r') {
            text.replace("\r\n", "\n").replace('\r', "\n")
        } else {
            text
        };
        // A paste while the completion popup is open otherwise inserts text
        // without ever updating/dismissing the popup: its prefix filtering
        // goes stale and it keeps floating over text the paste just changed.
        // Vim dismisses the popup on paste-like input (`<C-r>` register
        // paste, `<C-o>p`, etc.); mirror that — dismissing is simplest and
        // correct since a pasted blob rarely matches the in-flight prefix.
        if self.completion.is_some() {
            self.dismiss_completion();
        }
        // Record the paste into an active macro recording the same way
        // typed keys are recorded (see the Insert-mode recorder hook in
        // `handle_keypress`), so `@q` replay reproduces the paste instead of
        // silently dropping it. `Input` has no dedicated paste variant, so
        // record the equivalent sequence of per-char keystrokes a real
        // keyboard paste would have produced (`\n` as `Enter`).
        if self.active_editor().is_recording_macro() && !self.active_editor().is_replaying_macro() {
            for ch in normalised.chars() {
                let key = if ch == '\n' {
                    hjkl_engine::Key::Enter
                } else {
                    hjkl_engine::Key::Char(ch)
                };
                self.active_editor_mut().record_input(hjkl_engine::Input {
                    key,
                    ctrl: false,
                    alt: false,
                    shift: false,
                });
            }
        }
        self.active_editor_mut().insert_str(&normalised);
        self.sync_after_engine_mutation();
        self.pending_recompute = true;
    }

    /// Handle a terminal `Event::Resize(w, h)` — update the active editor's
    /// viewport dims and request a syntax recompute.
    ///
    /// Both `run()`'s primary read arm and the same-tick drain-loop mirror
    /// previously updated the viewport dims inline but never set
    /// `pending_recompute`, so growing the terminal exposed new rows with no
    /// syntax spans until the next keypress forced a recompute for an
    /// unrelated reason. Factored into one method (mirroring the
    /// `dispatch_fallthrough_key` fix) so both call sites share the fix and a
    /// unit test can drive it directly without a real terminal.
    pub(crate) fn handle_resize(&mut self, w: u16, h: u16) {
        let vp = self.active_editor_mut().host_mut().viewport_mut();
        vp.width = w;
        vp.height = h.saturating_sub(STATUS_LINE_HEIGHT);
        self.pending_recompute = true;
        // Resize always repaints — even when called outside the event arm
        // (both `run()` read arms set `needs_draw` too, but self-contained
        // here so the draw-on-resize contract holds for any caller).
        self.needs_draw = true;
    }

    /// Poll in-flight grammar loads, git signs, format results, and anvil jobs.
    /// Called once per event loop tick before the poll wait.
    ///
    /// Returns `true` when any poll produced output that changed the frame
    /// (a grammar load installed, git/blame data arrived, a format result
    /// was applied, an anvil job surfaced a toast, or fs-watch reloaded a
    /// buffer). The caller sets `needs_draw` from this — the polls run
    /// AFTER `terminal.draw`, so without the flag the updated frame would
    /// only appear on the next input event.
    pub(crate) fn drain_async_polls(&mut self) -> bool {
        let mut changed = false;

        // Poll any in-flight async grammar loads each tick so a freshly
        // compiled grammar installs without needing a keypress.
        if self.poll_grammar_loads() {
            self.recompute_and_install();
            changed = true;
        }

        // Install any git diff-sign / blame results that arrived from workers.
        // When either poll returns true (new data arrived), set pending_recompute
        // so the top-of-loop flush redraws the updated column/signs on the next
        // iteration — without this the blame column stays blank until the next
        // keypress because drain_async_polls runs AFTER terminal.draw.
        if self.poll_git_signs() | self.poll_blame() {
            self.pending_recompute = true;
            changed = true;
        }

        // Install any completed async format results (#118).
        changed |= self.poll_format_results();

        // Poll any in-flight anvil install jobs and surface status toasts.
        changed |= self.poll_anvil_jobs();

        // Event-driven autoreload (#242): reconcile any external file changes
        // the fs-watch surfaced. Like the git/blame polls above, this runs AFTER
        // terminal.draw, so request a repaint when a buffer was reloaded.
        if self.drain_fs_watch_events() {
            self.pending_recompute = true;
            changed = true;
        }

        changed
    }

    /// Compute how long to wait for the next event.
    ///
    /// Normally 120 ms (splash animation cadence), but shortened to the soonest
    /// of (a) which-key popup deadline, (b) chord-timeout deadline (Ambiguous →
    /// timeout_resolve), (c) active indent-flash so each 75 ms phase paints.
    pub(crate) fn compute_poll_timeout(&self) -> Duration {
        let base = Duration::from_millis(120);
        let now = std::time::Instant::now();
        let mut t = base;
        if let Some(prefix_at) = self.pending_prefix_at {
            if self.which_key_enabled && !self.which_key_active {
                let deadline = prefix_at + self.which_key_delay;
                t = t.min(deadline.saturating_duration_since(now));
            }
            if !self.which_key_active && !self.app_keymap.pending(hjkl_vim::Mode::Normal).is_empty()
            {
                let deadline = prefix_at + self.app_keymap.timeout_duration();
                t = t.min(deadline.saturating_duration_since(now));
            }
        }
        if self.indent_flash.is_some() {
            t = t.min(Duration::from_millis(30));
        }
        // Wake at `updatetime` after the last keystroke so the idle swap-write
        // fires promptly. Gated on swap_pending (gen changed since last swap),
        // NOT bare `dirty` — otherwise the deadline stays in the past after the
        // swap is written and the poll timeout collapses to 0 (busy loop).
        // `any_swap_pending` (not just the focused slot) so a dirty buffer
        // left in the background still wakes the sweep promptly (fix 3).
        if self.any_swap_pending() {
            let ut_ms = self.active_editor().settings().updatetime;
            let deadline = self.last_input_at + Duration::from_millis(ut_ms as u64);
            t = t.min(deadline.saturating_duration_since(now));
        }
        if self.scroll_anim.is_some() {
            t = t.min(std::time::Duration::from_millis(16));
        }
        // While the start screen is up, wake for its animation (it redraws
        // every frame) and land exactly on its expiry rather than up to one
        // base tick late.
        if let Some(remaining) = self.start_screen_remaining() {
            t = t.min(remaining).min(std::time::Duration::from_millis(33));
        }
        t
    }

    /// Idle swap-write sweep (issue #185, audit-r2 fix 3).
    ///
    /// Once `updatetime` ms have elapsed since the last keystroke, write the
    /// swap for EVERY slot with a pending swap write — not just the focused
    /// one. Previously only the focused slot's swap was refreshed on idle,
    /// and nothing flushed a defocused slot's swap on buffer switch either,
    /// so a dirty background buffer had no crash protection at all.
    ///
    /// The deadline is a single global one (based on `last_input_at` and the
    /// active editor's `updatetime`) rather than per-slot, matching the
    /// pre-existing pacing: a sweep only runs at most once per `updatetime`
    /// window, and `slot_swap_pending` (a cheap `dirty_gen` comparison) skips
    /// every already-current slot, so the sweep stays cheap even with many
    /// open buffers.
    pub(crate) fn tick_idle_swap_write(&mut self, now: std::time::Instant) {
        if !self.any_swap_pending() {
            return;
        }
        let ut_ms = self.active_editor().settings().updatetime;
        let deadline = self.last_input_at + Duration::from_millis(ut_ms as u64);
        if now < deadline {
            return;
        }
        for idx in 0..self.slots.len() {
            if self.slot_swap_pending(idx) {
                self.write_swap_for_slot(idx);
            }
        }
    }

    /// `true` when the current frame changes on wall-clock even with no input
    /// — i.e. the event loop must keep repainting on idle polls, not just
    /// when `needs_draw` is set. The renderer has several such elements:
    ///
    /// * the app's own animations — the start screen, smooth scroll
    ///   (`scroll_anim`), and the indent flash;
    /// * the status-line spinner (`hjkl_editor_tui::spinner::frame` is
    ///   wall-clock based) while LSP requests or grammar loads are in flight;
    /// * holler toast dimming + expiry (`render_active` is fed `now`);
    /// * the picker's "scanning" spinner + `Spawn`-source requery debounce;
    /// * the inline-blame ghost — it appears once the cursor has been still
    ///   for [`BLAME_IDLE_DELAY`], so the 400 ms window after each cursor
    ///   move needs repaints to land on the crossing tick.
    ///
    /// When none of these is active the frame is fully input/state-driven and
    /// a purely idle poll skips the draw entirely — that is the perf win.
    fn frame_is_time_animated(&self) -> bool {
        self.start_screen.is_some()
            || self.scroll_anim.is_some()
            || self.indent_flash.is_some()
            || self.picker.is_some()
            || !self.lsp_pending.is_empty()
            || !self.directory.in_flight_names().is_empty()
            || self
                .bus
                .active(std::time::SystemTime::now())
                .next()
                .is_some()
            || self.blame_cursor_moved_at.elapsed() < BLAME_IDLE_DELAY
    }

    /// Whether this loop iteration must repaint: an explicit redraw request
    /// (input event, async poll output, resize, timeout-triggered UI), or a
    /// wall-clock-driven element (see [`Self::frame_is_time_animated`]).
    pub(crate) fn frame_needs_redraw(&self) -> bool {
        self.needs_draw || self.frame_is_time_animated()
    }

    /// Cheap fingerprint of the render-visible LSP state that
    /// [`Self::drain_lsp_events`] can change: the pending-request map (status
    /// spinner + response handling), the live server set, active toasts,
    /// hover/completion popups, and every slot's diagnostic list. The
    /// diagnostic content is folded in (not just the count) so a same-count
    /// `publishDiagnostics` still flips the fingerprint. The event loop
    /// compares before/after the drain — the drain cannot be observed any
    /// other way without modifying `drain_lsp_events` itself.
    fn lsp_render_fingerprint(&self) -> (usize, usize, usize, bool, bool, u64, usize) {
        let mut diag_hash = 0x517c_c1b7_2722_0a95u64; // FNV offset basis
        let mut diag_count = 0usize;
        for s in self.slots() {
            for d in &s.lsp_diags {
                diag_count += 1;
                diag_hash = (diag_hash ^ (d.start_row as u64)).wrapping_mul(0x100_0000_01b3);
                diag_hash = (diag_hash ^ (d.start_col as u64)).wrapping_mul(0x100_0000_01b3);
                diag_hash = (diag_hash ^ (d.end_row as u64)).wrapping_mul(0x100_0000_01b3);
                diag_hash = (diag_hash ^ (d.end_col as u64)).wrapping_mul(0x100_0000_01b3);
                diag_hash = (diag_hash ^ d.severity as u64).wrapping_mul(0x100_0000_01b3);
                diag_hash = (diag_hash ^ d.message.len() as u64).wrapping_mul(0x100_0000_01b3);
            }
        }
        (
            self.lsp_pending.len(),
            self.lsp_state.len(),
            self.bus.active(std::time::SystemTime::now()).count(),
            self.hover_popup.is_some(),
            self.completion.is_some(),
            diag_hash,
            diag_count,
        )
    }

    /// Handle a single key event. Returns a [`KeyOutcome`] that tells `run()`
    /// whether to `continue`, `break`, or fall through to the engine dispatch.
    ///
    /// All overlay handling, Normal-mode pre-routing (count prefix, Esc,
    /// which-key Backspace), and keymap chord routing live here.
    pub(crate) fn handle_keypress(&mut self, key: KeyEvent) -> KeyOutcome {
        if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
            if self.command_field.is_some() {
                self.command_field = None;
                self.completion = None;
                self.command_completion_range = None;
                self.restore_previewed_theme();
                return KeyOutcome::Continue;
            }
            if self.search_field.is_some() {
                self.cancel_search_prompt();
                return KeyOutcome::Continue;
            }
            // <C-c> in the cmdline window closes it.
            if self.is_cmdline_win_focused() {
                self.close_cmdline_window();
                return KeyOutcome::Continue;
            }
            // Bare <C-c> exits like `:qa` — reuse its all-slots dirty check
            // (skips explorer scratch buffers, same as `:qa`) instead of
            // exiting unconditionally. An accidental Ctrl-C used to exit
            // even with dirty buffers, and the graceful-exit path
            // (`cleanup_swaps_on_exit`) then deletes every slot's swap
            // file — destroying the only recovery copy of unsaved work a
            // crash (which skips that cleanup) would have preserved.
            // `quit_all(false)` shows the same E37 message `:qa` does and
            // leaves `exit_requested` false when any buffer is dirty;
            // `:qa!` / `:wqa` remain available to force the exit.
            self.quit_all(false);
            if self.exit_requested {
                return KeyOutcome::Break;
            }
            return KeyOutcome::Continue;
        }

        // ── Hop / easymotion overlay (#197) ──────────────────────
        // Must run BEFORE all other overlay checks so hop owns every key
        // while active. Any non-char key cancels (Esc explicitly, others
        // via the None path in hop_handle_key).
        if self.hop.is_some() {
            match key.code {
                KeyCode::Esc => self.hop_handle_key(None, true),
                KeyCode::Char(c)
                    if key.modifiers == KeyModifiers::NONE
                        || key.modifiers == KeyModifiers::SHIFT =>
                {
                    self.hop_handle_key(Some(c), false)
                }
                _ => self.hop_handle_key(None, true),
            }
            return KeyOutcome::Continue;
        }

        // ── Quickfix / location-list dock (#63 Phase B) ────────────
        // The dock is a real window/buffer now (replaces the old `:copen`/
        // `:lopen` Clear+List overlay that owned every key while up) — j/k,
        // /, ?, yy, 5G, etc. all reach the engine normally. The ONLY key
        // still intercepted here is `<CR>`: jump to the entry under the
        // dock's cursor. `q` intentionally does NOT close it (vim's real
        // quickfix window doesn't close on `q` either) — `:cclose`/`:lclose`/
        // `<C-w>c` do, via `dispatch_window_action`/`handle_qf_command`.
        if self.is_bottom_dock(self.focused_window())
            && self.active_editor().vim_mode() == VimMode::Normal
            && self.pending_state.is_none()
            && key.code == KeyCode::Enter
            && key.modifiers == KeyModifiers::NONE
        {
            self.qf_dock_jump_at_cursor();
            self.sync_after_engine_mutation();
            return KeyOutcome::Continue;
        }

        // ── BLAME mode ────────────────────────────────────────────
        // BLAME is now an FSM-owned read-only view (`Editor::view_mode`). The
        // engine handles every transition out of it natively: `Esc` (hjkl-vim
        // normal dispatch), mode-entering keys (i/v/… via the mode funnels),
        // and mouse-drag-into-Visual (the visual bridge) all auto-exit BLAME.
        // No host-side key interception or per-tick invariant is needed.

        // ── Cmdline window <CR> intercept (issue #37) ─────────────
        // Must run BEFORE normal-mode routing so `<Enter>` in the
        // cmdline window commits the line rather than opening a new line
        // below (o) or doing nothing in Normal mode.
        if self.is_cmdline_win_focused()
            && key.code == KeyCode::Enter
            && key.modifiers == KeyModifiers::NONE
        {
            self.commit_cmdline_window();
            if self.exit_requested {
                return KeyOutcome::Break;
            }
            return KeyOutcome::Continue;
        }

        // Dismiss the start screen on any non-Ctrl-C keypress and
        // let the key fall through to normal handling so `:`,
        // `/`, `i`, etc. take effect on the same press.
        if self.start_screen.is_some() {
            self.start_screen = None;
        }

        // Any keypress clears the which-key popup immediately. The
        // prefix resolution branches below call note_prefix_set() again
        // when chaining into a sub-prefix, which re-arms the timer.
        self.which_key_active = false;

        // ── Info popup dismissal ──────────────────────────────────
        if self.info_popup.is_some() {
            self.info_popup = None;
            return KeyOutcome::Continue;
        }

        // ── Crash-recovery prompt (issue #185) ───────────────────
        // Intercept BEFORE normal engine routing so y/N/q reach the
        // recovery handler.
        if self.pending_recovery.is_some() {
            self.handle_recovery_key(key);
            return KeyOutcome::Continue;
        }

        // ── Dirty-buffer disk-change prompt (issue #241) ──────────
        // Intercept BEFORE engine routing so k/r/d reach the handler.
        if self.pending_disk_change.is_some() {
            self.handle_disk_change_key(key);
            return KeyOutcome::Continue;
        }

        // ── Confirm-substitute prompt (:s/pat/rep/c) ──────────────
        // Intercept BEFORE normal engine routing so y/n/a/q/l reach
        // the confirm handler rather than the vim FSM.
        if self.confirming_substitute.is_some() {
            self.handle_confirm_substitute_key(key);
            return KeyOutcome::Continue;
        }

        // ── Hover popup dismissal (Phase 5 mouse support) ─────────
        if self.hover_popup.is_some() {
            self.hover_popup = None;
            self.hover_timer = None;
            // fall through — key still takes effect
        }

        // ── Context menu keyboard navigation (Phase 2, Round A) ───
        if self.context_menu.is_some() {
            let consumed = self.handle_context_menu_key(key);
            if consumed {
                return KeyOutcome::Continue;
            }
            // Any non-nav key dismisses the menu and falls through.
            self.context_menu = None;
        }

        // ── Explorer git-discard confirm ──────────────────────────
        if self.explorer_git_discard_confirm.is_some() {
            self.handle_explorer_git_discard_confirm_key(key);
            return KeyOutcome::Continue;
        }

        // ── Command palette (`:` prompt) ─────────────────────────
        if self.command_field.is_some() {
            self.handle_command_field_key(key);
            if self.exit_requested {
                return KeyOutcome::Break;
            }
            return KeyOutcome::Continue;
        }

        // ── Filter prompt (`!` operator) ──────────────────────────
        if self.filter_field.is_some() {
            self.handle_filter_field_key(key);
            return KeyOutcome::Continue;
        }

        // ── Search prompt (`/` `?`) ──────────────────────────────
        if self.search_field.is_some() {
            self.handle_search_field_key(key);
            if self.exit_requested {
                return KeyOutcome::Break;
            }
            return KeyOutcome::Continue;
        }

        // ── Picker overlay ────────────────────────────────────────
        if self.picker.is_some() {
            self.handle_picker_key(key);
            if self.exit_requested {
                return KeyOutcome::Break;
            }
            return KeyOutcome::Continue;
        }

        // ── File-explorer buffer (#55) ────────────────────────────
        // Explorer keys are routed through `explorer_keymap` in
        // `route_chord_key_inner` (step 2b) so they surface in the which-key
        // popup. `u` (undo) and `<C-r>` (redo) are handled here because
        // they are position-dependent or state-dependent.
        if self.explorer_buf_focused()
            && self.active_editor().vim_mode() == VimMode::Normal
            && self.pending_state.is_none()
            && key.modifiers == KeyModifiers::NONE
        {
            // `u` — undo the last explorer fs transaction (journal-based,
            // not vim buffer undo; the buffer is set_content-reset after
            // each reconcile so vim undo is meaningless here).
            if let KeyCode::Char('u') = key.code {
                self.explorer_undo();
                return KeyOutcome::Continue;
            }
            // `o` on a DIRECTORY → create the new entry INSIDE the dir (child
            // indent, line below). `O` is left as the normal open-line-above,
            // which autoindents to the current line's depth = a sibling. On a
            // file, `o` also falls through to the normal open-line-below.
            if key.code == KeyCode::Char('o') && self.explorer_open_in_dir() {
                return KeyOutcome::Continue;
            }
            // `p` on a DIRECTORY → paste the cut/yanked entries INSIDE the dir
            // (re-indented as children), so `dd` a dir then `p` on another dir
            // MOVES it into that dir. On a file (or empty register) it falls
            // through to the normal sibling paste.
            if key.code == KeyCode::Char('p') && self.explorer_paste_in_dir() {
                return KeyOutcome::Continue;
            }
        }

        // <C-r> in the explorer while in Normal mode and no pending: redo.
        if self.explorer_buf_focused()
            && self.active_editor().vim_mode() == VimMode::Normal
            && self.pending_state.is_none()
            && key.code == KeyCode::Char('r')
            && key.modifiers.contains(KeyModifiers::CONTROL)
        {
            self.explorer_redo();
            return KeyOutcome::Continue;
        }

        // ── Visual-mode `:` → command prompt prefilled with '<,'> ─
        // Must run BEFORE route_chord_key so a pending_state from a
        // prior chord (e.g. first `g` in Visual mode) does not eat
        // the `:` key. Visual `:` is not a chord continuation.
        if key.code == KeyCode::Char(':')
            && key.modifiers == KeyModifiers::NONE
            && matches!(
                self.active_editor().vim_mode(),
                VimMode::Visual | VimMode::VisualLine | VimMode::VisualBlock
            )
        {
            // Exit visual mode by feeding Esc to the engine. The
            // visual-exit hook in hjkl-engine sets the `<` / `>`
            // marks so :'<,'> resolves.
            hjkl_vim_tui::handle_key(
                self.active_editor_mut(),
                KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
            );
            self.open_command_prompt_with("'<,'>");
            return KeyOutcome::Continue;
        }

        // ── Normal-mode app-level pre-routing ────────────────────
        // These run BEFORE route_chord_key below. They may return Continue
        // (consuming the key) or fall through to route_chord_key.
        // Out of scope for route_chord_key: count-prefix, Esc, which-key BS.
        // Migrated to keymap (issue #120):
        //   Phase 2: Ctrl-^, K, `:`, `/`, `?`
        //   Phase 3: H/L buffer cycle (BufferCycleH/L),
        //            Ctrl-h/j/k/l window focus + tmux (TmuxNavigate)
        if self.active_editor().vim_mode() == VimMode::Normal {
            // ── App-level count prefix buffering ─────────────────
            // View digit keys so that count-aware chords (Ngt,
            // N<C-w>+) can consume the count. When the non-digit key
            // is not a chord-starter, replay digits to the engine.
            //
            // Chord-starters: any key that the app_keymap might
            // consume as the first key of a chord. We check
            // has_prefix heuristically by attempting a feed of the
            // raw key and rewinding if it returns Unbound immediately
            // without any buffered prefix — but that's complex.
            // Instead we keep the same explicit list as before:
            // digits are buffered and replayed if the next key doesn't
            // match a chord prefix.
            //
            // Skip count-prefix buffering entirely when a pending_state
            // chord is active (e.g. SelectRegister after `"a`). In that
            // case the next key is consumed by the reducer (not the count
            // accumulator), and flushing digits to the engine would corrupt
            // the engine's internal count state. route_chord_key below owns
            // the key in that situation.
            if self.pending_state.is_none() && key.modifiers == KeyModifiers::NONE {
                if let KeyCode::Char(d @ '0'..='9') = key.code {
                    // try_accumulate returns false for '0' with empty buffer
                    // (vim's LineStart quirk); in that case fall through to keymap.
                    if self.pending_count.try_accumulate(d) {
                        return KeyOutcome::Continue;
                    }
                    // '0' with empty pending_count → start-of-line; fall through.
                } else if !self.pending_count.is_empty() {
                    // Non-digit with buffered count.
                    // If it could start a chord, keep count alive.
                    // Otherwise replay digits now.
                    //
                    // Query the trie rather than a static char-set:
                    // ask whether this key is a root-level key in
                    // the Normal-mode bindings (i.e. a valid first
                    // key of any chord).  `children_all` with an
                    // empty prefix returns all root entries without
                    // mutating the pending-chord state.
                    use hjkl_vim::Mode;
                    let could_start_chord = !self.app_keymap.pending(Mode::Normal).is_empty()
                        || to_km_event(key).is_some_and(|km_ev| {
                            let root = self
                                .app_keymap
                                .children_all(Mode::Normal, &KmChord::from_events(vec![]));
                            root.iter().any(|(k, _)| *k == km_ev)
                        });
                    if !could_start_chord {
                        self.flush_pending_count_to_engine();
                    }
                }
            } else if self.pending_state.is_none() {
                // Modifier key. For Ctrl+Char keys the keymap may
                // match (e.g. <C-d>/<C-u>/<C-f>/<C-b> from Phase 3g)
                // and should receive the buffered count. Keep
                // pending_count alive so dispatch_keymap sees it.
                // If the keymap misses, the "Unbound" path below
                // drains the digits to the engine before replaying.
                // For non-Ctrl modifier keys (Alt, etc.) flush now as
                // before — they are not keymap-bound count consumers.
                let is_ctrl_char =
                    key.modifiers == KeyModifiers::CONTROL && matches!(key.code, KeyCode::Char(_));
                if !is_ctrl_char && !self.pending_count.is_empty() {
                    self.flush_pending_count_to_engine();
                }
            }

            // ── Escape: cancel any pending chord, else toggle which-key ─────────
            if key.code == KeyCode::Esc {
                // BLAME is a Normal-only read-only view; Esc must leave it. The
                // engine's normal-mode Esc handler does exit_blame + force_normal,
                // but the which-key toggle below returns before Esc ever reaches
                // the engine — so exit BLAME here explicitly (mirroring the
                // engine) and clear any pending state, as Esc otherwise would.
                if self.active_editor().is_blame() {
                    self.active_editor_mut().exit_blame();
                    self.active_editor_mut().force_normal();
                    self.cancel_all_pending();
                    self.chord_history.clear();
                    self.which_key_sticky = false;
                    self.which_key_active = false;
                    return KeyOutcome::Continue;
                }
                let had_pending = self.any_chord_pending() || !self.pending_count.is_empty();
                // Cancel across all three pending owners (trie, app pending_state,
                // engine pending) + reset count. This restores Esc-cancels-chord
                // behaviour that the which-key toggle would otherwise swallow.
                self.cancel_all_pending();
                self.chord_history.clear();
                if had_pending {
                    self.which_key_sticky = false;
                    self.which_key_active = false;
                    return KeyOutcome::Continue;
                }
                // Nothing pending → toggle the top-level which-key display.
                // Repeated Esc flips it on/off (Normal mode only).
                if self.which_key_sticky {
                    self.which_key_sticky = false;
                    self.which_key_active = false;
                } else {
                    self.which_key_sticky = true;
                    self.which_key_active = true;
                    self.note_prefix_set();
                }
                return KeyOutcome::Continue;
            }

            // ── Backspace: pop one chord level, else toggle which-key ───────────
            if key.code == KeyCode::Backspace
                && key.modifiers == KeyModifiers::NONE
                && self.active_editor().vim_mode() == VimMode::Normal
            {
                if self.any_chord_pending() {
                    // Pop one level across ALL pending owners (trie, app
                    // pending_state, engine pending): cancel everything, then
                    // replay the chord's keys minus the last. This makes engine
                    // chords poppable too — e.g. `gc<BS>cc` resolves to `gcc`.
                    let mut hist = std::mem::take(&mut self.chord_history);
                    hist.pop();
                    self.cancel_all_pending();
                    for ev in hist {
                        self.chord_history.push(ev);
                        self.route_chord_key(ev);
                    }
                    if self.chord_history.is_empty() {
                        // Popped to root — keep the popup showing root entries.
                        self.which_key_sticky = true;
                    }
                    self.which_key_active = true;
                    self.note_prefix_set();
                    return KeyOutcome::Continue;
                }
                // Nothing pending → toggle the top-level which-key display,
                // mirroring Esc. Backspace no longer moves left in Normal mode;
                // it is the which-key navigate-up / toggle key.
                if self.which_key_sticky {
                    self.which_key_sticky = false;
                    self.which_key_active = false;
                } else {
                    self.which_key_sticky = true;
                    self.which_key_active = true;
                    self.note_prefix_set();
                }
                return KeyOutcome::Continue;
            } else {
                // Any non-Backspace key clears sticky which-key.
                self.which_key_sticky = false;
            }

            // Fall through to route_chord_key below.
        } else if matches!(
            self.active_editor().vim_mode(),
            VimMode::Visual | VimMode::VisualLine | VimMode::VisualBlock
        ) {
            // ── Visual-mode count prefix ─────────────────────────
            // Clear stale Normal-mode chord state but PRESERVE the pending
            // count so visual-mode counts accumulate (`2j`, `2>`, `3<`),
            // mirroring the Normal-mode buffering above. Without this the
            // digit was dropped (and the count reset every key), so every
            // visual op / motion ran with count 1.
            self.app_keymap.reset(hjkl_vim::Mode::Normal);
            self.explorer_keymap.reset(hjkl_vim::Mode::Normal);
            self.clear_prefix_state();
            if key.code == KeyCode::Esc {
                // Cancel a half-typed count; the engine still receives Esc
                // below to exit visual mode.
                self.pending_count.reset();
            } else if self.pending_state.is_none()
                && key.modifiers == KeyModifiers::NONE
                && let KeyCode::Char(d @ '0'..='9') = key.code
            {
                // try_accumulate buffers the digit; `0` with an empty buffer
                // returns false (LineStart motion) and falls through.
                if self.pending_count.try_accumulate(d) {
                    return KeyOutcome::Continue;
                }
            }
        } else {
            // Insert / other modes: reset any pending Normal-mode chord state.
            self.app_keymap.reset(hjkl_vim::Mode::Normal);
            self.explorer_keymap.reset(hjkl_vim::Mode::Normal);
            self.pending_count.reset();
            self.clear_prefix_state();
        }

        // ── Canonical chord routing ───────────────────────────────
        // Handles:
        //   (1) pending_state reducer (all modes, pending_state.is_some())
        //   (2) Non-Normal trie dispatch (mode != Normal, pending_state.is_none())
        //   (3) Normal-mode keymap dispatch (mode == Normal, pending_state.is_none())
        // count-prefix, engine-pending bypass, and replay logic are encapsulated
        // inside route_chord_key for step (3).
        //
        // Record Normal-mode keys into `chord_history` so Backspace can pop one
        // chord level. Push before routing, then reconcile: keep the key only
        // while a chord is still pending afterwards, otherwise the chord
        // committed/cancelled and the history resets.
        let track_chord = self.active_editor().vim_mode() == VimMode::Normal
            && !matches!(key.code, KeyCode::Backspace | KeyCode::Esc);
        if track_chord {
            self.chord_history.push(key);
        }
        if self.route_chord_key(key) {
            if track_chord && !self.any_chord_pending() {
                self.chord_history.clear();
            }
            if self.exit_requested {
                return KeyOutcome::Break;
            }
            return KeyOutcome::Continue;
        }
        if track_chord {
            self.chord_history.clear();
        }

        // ── Insert-mode completion key handling ──────────────────
        // This block intercepts specific keys in insert mode to
        // manage the completion popup, before forwarding to the engine.
        if self.active_editor().vim_mode() == VimMode::Insert {
            // Recorder hook for Insert-mode keys that this block consumes
            // (printable chars routed to insert_char, popup-open Backspace
            // routed to insert_backspace, etc). Those paths return
            // KeyOutcome::Continue without ever reaching dispatch_insert_key
            // or the engine FSM step wrapper, so the engine end_step
            // recorder doesn't fire. Skipped during replay so played-back
            // inputs don't append to the active recording. Keys that
            // fall through this block to dispatch_insert_key get their
            // recording from dispatch_insert_key's own hook.
            if self.active_editor().is_recording_macro()
                && !self.active_editor().is_replaying_macro()
            {
                let input = hjkl_engine_tui::crossterm_to_input(key);
                if input.key != hjkl_engine::Key::Null {
                    self.active_editor_mut().record_input(input);
                }
            }

            // <C-x><C-o> manual omni-completion trigger.
            if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('x') {
                self.pending_ctrl_x = true;
                return KeyOutcome::Continue;
            }
            if self.pending_ctrl_x {
                self.pending_ctrl_x = false;
                if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('o') {
                    self.lsp_request_completion();
                    return KeyOutcome::Continue;
                }
                // Any other key: fall through normally (consume pending_ctrl_x).
            }

            // Keys that navigate/accept/dismiss the popup (popup must be open).
            if self.completion.is_some() {
                match key.code {
                    // <C-n> / <C-p> navigate selection.
                    KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) => {
                        if let Some(ref mut p) = self.completion {
                            p.cycle_down();
                        }
                        return KeyOutcome::Continue;
                    }
                    KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => {
                        if let Some(ref mut p) = self.completion {
                            p.cycle_up();
                        }
                        return KeyOutcome::Continue;
                    }
                    // <Down> / <Up> navigate selection (mirrors <C-n>/<C-p>).
                    KeyCode::Down => {
                        if let Some(ref mut p) = self.completion {
                            p.cycle_down();
                        }
                        return KeyOutcome::Continue;
                    }
                    KeyCode::Up => {
                        if let Some(ref mut p) = self.completion {
                            p.cycle_up();
                        }
                        return KeyOutcome::Continue;
                    }
                    // <Enter> accepts the selected item (only when popup is open).
                    KeyCode::Enter => {
                        self.accept_completion();
                        self.sync_after_engine_mutation();
                        return KeyOutcome::Continue;
                    }
                    // <Tab> or <C-y> accept selected item.
                    KeyCode::Tab => {
                        self.accept_completion();
                        self.sync_after_engine_mutation();
                        return KeyOutcome::Continue;
                    }
                    KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::CONTROL) => {
                        self.accept_completion();
                        self.sync_after_engine_mutation();
                        return KeyOutcome::Continue;
                    }
                    // <C-e> dismiss without accepting.
                    KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => {
                        self.dismiss_completion();
                        return KeyOutcome::Continue;
                    }
                    // <Esc> dismisses popup and falls through to engine
                    // which exits insert mode.
                    KeyCode::Esc => {
                        self.dismiss_completion();
                        // fall through to engine
                    }
                    // Printable char or backspace: update prefix, maybe dismiss.
                    KeyCode::Char(c) if key.modifiers == KeyModifiers::NONE => {
                        // Phase 6.5: call insert primitive directly.
                        self.active_editor_mut().insert_char(c);
                        self.sync_viewport_from_editor();
                        if self.active_editor_mut().take_dirty() {
                            let elapsed = self.active_mut().refresh_dirty_against_saved();
                            self.last_signature_us = elapsed;
                            if self.active().dirty {
                                self.active_mut().is_new_file = false;
                            }
                        }
                        let buffer_id = self.active().buffer_id;
                        if self.active_editor_mut().take_content_reset() {
                            self.handle_active_content_reset(buffer_id);
                        }
                        let edits = self.active_editor_mut().take_content_edits();
                        if !edits.is_empty() {
                            self.syntax.apply_edits(buffer_id, &edits);
                        }
                        self.lsp_notify_change_active(&edits);
                        self.rebase_sibling_cursors(&edits);
                        // Sibling-window fold invalidation + diff-pair
                        // realignment — see the doc comment on
                        // `sync_diff_and_fold_siblings` for why this
                        // completion-popup arm must call it too (it's one of
                        // the sites that historically drifted from
                        // `sync_after_engine_mutation`).
                        self.sync_diff_and_fold_siblings(&edits);
                        // Defer TS reparse to the end-of-drain flush so a
                        // burst of insert-mode keys folds into one parse
                        // instead of paying per-keystroke sync cost.
                        self.pending_recompute = true;

                        // Update popup prefix.
                        let anchor_col = self.completion.as_ref().map_or(0, |p| p.anchor_col);
                        let cur_col = self.active_editor().buffer().cursor().col;
                        let cur_row = self.active_editor().buffer().cursor().row;
                        let anchor_row = self.completion.as_ref().map_or(cur_row, |p| p.anchor_row);
                        if cur_row != anchor_row || cur_col < anchor_col {
                            // Cursor moved out of anchor range — dismiss.
                            self.dismiss_completion();
                        } else {
                            let new_prefix = {
                                // `.line(cur_row)` is O(log N) on rope storage
                                // and clones a single row, not the whole doc.
                                let rope = self.active_editor().buffer().rope();
                                let line = if cur_row < rope.len_lines() {
                                    hjkl_buffer::rope_line_str(&rope, cur_row)
                                } else {
                                    String::new()
                                };
                                // `anchor_col`/`cur_col` are CHAR indices; map to
                                // byte offsets so the slice never lands inside a
                                // multibyte char (crash on pasted Unicode).
                                let byte_of = |char_col: usize| -> usize {
                                    line.char_indices()
                                        .nth(char_col)
                                        .map_or(line.len(), |(b, _)| b)
                                };
                                let a = byte_of(anchor_col);
                                let c = byte_of(cur_col);
                                line[a.min(c)..a.max(c)].to_string()
                            };
                            if let Some(ref mut popup) = self.completion {
                                popup.set_prefix(&new_prefix);
                                if popup.is_empty() {
                                    self.completion = None;
                                }
                            }
                        }
                        // Auto-trigger on trigger chars when popup just closed.
                        if self.completion.is_none() {
                            self.maybe_auto_trigger_completion(c);
                        }
                        return KeyOutcome::Continue;
                    }
                    KeyCode::Backspace if key.modifiers == KeyModifiers::NONE => {
                        // Phase 6.5: call insert primitive directly.
                        self.active_editor_mut().insert_backspace();
                        self.sync_viewport_from_editor();
                        if self.active_editor_mut().take_dirty() {
                            let elapsed = self.active_mut().refresh_dirty_against_saved();
                            self.last_signature_us = elapsed;
                            if self.active().dirty {
                                self.active_mut().is_new_file = false;
                            }
                        }
                        let buffer_id = self.active().buffer_id;
                        if self.active_editor_mut().take_content_reset() {
                            self.handle_active_content_reset(buffer_id);
                        }
                        let edits = self.active_editor_mut().take_content_edits();
                        if !edits.is_empty() {
                            self.syntax.apply_edits(buffer_id, &edits);
                        }
                        self.lsp_notify_change_active(&edits);
                        self.rebase_sibling_cursors(&edits);
                        // Sibling-window fold invalidation + diff-pair
                        // realignment — see the doc comment on
                        // `sync_diff_and_fold_siblings` for why this
                        // completion-popup arm must call it too (it's one of
                        // the sites that historically drifted from
                        // `sync_after_engine_mutation`).
                        self.sync_diff_and_fold_siblings(&edits);
                        // Defer TS reparse to the end-of-drain flush so a
                        // burst of insert-mode keys folds into one parse
                        // instead of paying per-keystroke sync cost.
                        self.pending_recompute = true;

                        let anchor_col = self.completion.as_ref().map_or(0, |p| p.anchor_col);
                        let cur_col = self.active_editor().buffer().cursor().col;
                        let cur_row = self.active_editor().buffer().cursor().row;
                        let anchor_row = self.completion.as_ref().map_or(cur_row, |p| p.anchor_row);
                        if cur_row != anchor_row || cur_col < anchor_col {
                            self.dismiss_completion();
                        } else {
                            let new_prefix = {
                                let rope = self.active_editor().buffer().rope();
                                let line = if cur_row < rope.len_lines() {
                                    hjkl_buffer::rope_line_str(&rope, cur_row)
                                } else {
                                    String::new()
                                };
                                // CHAR indices → byte offsets (multibyte-safe).
                                let byte_of = |char_col: usize| -> usize {
                                    line.char_indices()
                                        .nth(char_col)
                                        .map_or(line.len(), |(b, _)| b)
                                };
                                let a = byte_of(anchor_col);
                                let c = byte_of(cur_col);
                                line[a.min(c)..a.max(c)].to_string()
                            };
                            if let Some(ref mut popup) = self.completion {
                                popup.set_prefix(&new_prefix);
                                if popup.is_empty() {
                                    self.completion = None;
                                }
                            }
                        }
                        return KeyOutcome::Continue;
                    }
                    _ => {
                        // Any other key dismisses the popup.
                        self.dismiss_completion();
                    }
                }
            } else {
                // Popup is closed. Handle <C-n>/<C-p> as manual trigger.
                if key.modifiers.contains(KeyModifiers::CONTROL)
                    && matches!(key.code, KeyCode::Char('n') | KeyCode::Char('p'))
                {
                    self.lsp_request_completion();
                    return KeyOutcome::Continue;
                }
                // Auto-trigger on trigger chars.
                if key.modifiers == KeyModifiers::NONE
                    && let KeyCode::Char(c) = key.code
                {
                    // Phase 6.5: call insert primitive directly.
                    self.active_editor_mut().insert_char(c);
                    self.sync_viewport_from_editor();
                    if self.active_editor_mut().take_dirty() {
                        let elapsed = self.active_mut().refresh_dirty_against_saved();
                        self.last_signature_us = elapsed;
                        if self.active().dirty {
                            self.active_mut().is_new_file = false;
                        }
                    }
                    let buffer_id = self.active().buffer_id;
                    if self.active_editor_mut().take_content_reset() {
                        self.handle_active_content_reset(buffer_id);
                    }
                    let edits = self.active_editor_mut().take_content_edits();
                    if !edits.is_empty() {
                        self.syntax.apply_edits(buffer_id, &edits);
                    }
                    self.lsp_notify_change_active(&edits);
                    self.rebase_sibling_cursors(&edits);
                    // Sibling-window fold invalidation + diff-pair realignment
                    // — same drift class as the two arms above.
                    self.sync_diff_and_fold_siblings(&edits);
                    self.pending_recompute = true;
                    self.maybe_auto_trigger_completion(c);
                    return KeyOutcome::Continue;
                }
            }
        } else {
            // Left insert mode — dismiss popup.
            if self.completion.is_some() {
                self.dismiss_completion();
            }
        }

        KeyOutcome::FallThrough
    }

    /// Handle a single mouse event. Returns a [`MouseOutcome`] indicating
    /// whether the loop should `continue` or fall through.
    pub(crate) fn handle_mouse(&mut self, me: crossterm::event::MouseEvent) -> MouseOutcome {
        use crossterm::event::{MouseButton, MouseEventKind};
        // Skip while these overlays are active — no mouse handling exists
        // for them yet.
        if self.command_field.is_some() || self.search_field.is_some() || self.info_popup.is_some()
        {
            return MouseOutcome::Continue;
        }
        // Picker is exclusive: mirrors `mouse::hit_test_zone`'s "Picker
        // exclusive" resolution order (see its doc comment) — nothing else
        // (border-drag, explorer clicks, context menu, text drag/scroll)
        // should fire underneath the modal overlay. Previously the picker
        // was lumped into the blanket return above with a "Phase 8 will
        // handle mouse in overlays" comment, but Phase 8 WAS built (the
        // `Zone::PickerRow` right-click menu below, plus `hit_test_zone` /
        // `hit_test_picker_row` / `picker_overlay_rect` in mouse.rs) — the
        // blanket return just made all of it unreachable.
        if self.picker.is_some() {
            return self.handle_picker_mouse(me);
        }
        // P11.3 — gate events by per-mode mouse flags.
        // Command-field overlay already handled above; here we gate
        // on the editor's vim mode for the remaining events.
        {
            let mode = self.active_editor().vim_mode();
            if !crate::app::mouse_enabled_for(mode, &self.mouse_flags) {
                return MouseOutcome::Continue;
            }
        }
        // 3 lines/cols per wheel notch — vim's `mousescroll` default.
        const WHEEL_TICKS: i16 = 3;
        use crossterm::event::KeyModifiers;
        /// Route scroll to the window under the cursor, focusing it
        /// if needed. Returns `false` when the pointer is outside
        /// every window (e.g. over the status bar) — caller should
        /// skip the scroll in that case.
        fn focus_window_under_cursor(app: &mut crate::app::App, col: u16, row: u16) -> bool {
            use crate::app::mouse;
            if let Some(win_id) = mouse::hit_test_window(app, col, row) {
                let current_focus = app.focused_window();
                if win_id != current_focus {
                    app.switch_focus(win_id);
                }
                true
            } else {
                false
            }
        }
        // Scroll arms set `pending_recompute` instead of calling
        // `recompute_and_install` synchronously. The main event loop
        // drains all currently-ready events before firing one recompute,
        // so a burst of mouse-wheel scroll events runs the sync query +
        // install pipeline ONCE per drain instead of N times per burst.
        // Without this the per-event ~2-5ms sync query stacked into
        // visible scroll lag.
        match me.kind {
            MouseEventKind::ScrollDown => {
                if me.modifiers.contains(KeyModifiers::SHIFT) {
                    if focus_window_under_cursor(self, me.column, me.row) {
                        self.active_editor_mut().scroll_right(WHEEL_TICKS);
                        self.sync_viewport_from_editor();
                        self.pending_recompute = true;
                    }
                } else if focus_window_under_cursor(self, me.column, me.row) {
                    self.active_editor_mut().scroll_down(WHEEL_TICKS);
                    self.sync_viewport_from_editor();
                    self.pending_recompute = true;
                }
            }
            MouseEventKind::ScrollUp => {
                if me.modifiers.contains(KeyModifiers::SHIFT) {
                    if focus_window_under_cursor(self, me.column, me.row) {
                        self.active_editor_mut().scroll_left(WHEEL_TICKS);
                        self.sync_viewport_from_editor();
                        self.pending_recompute = true;
                    }
                } else if focus_window_under_cursor(self, me.column, me.row) {
                    self.active_editor_mut().scroll_up(WHEEL_TICKS);
                    self.sync_viewport_from_editor();
                    self.pending_recompute = true;
                }
            }
            MouseEventKind::ScrollLeft if focus_window_under_cursor(self, me.column, me.row) => {
                self.active_editor_mut().scroll_left(WHEEL_TICKS);
                self.sync_viewport_from_editor();
                self.pending_recompute = true;
            }
            MouseEventKind::ScrollRight if focus_window_under_cursor(self, me.column, me.row) => {
                self.active_editor_mut().scroll_right(WHEEL_TICKS);
                self.sync_viewport_from_editor();
                self.pending_recompute = true;
            }
            MouseEventKind::Down(MouseButton::Left) => {
                use crate::app::mouse;

                self.dismiss_hover_popup_on_click();

                // ── Explorer mouse handling ───────────────────────
                // Click a tree row: focus the explorer, move the cursor there,
                // and activate it (toggle a dir / open-or-focus a file).
                // The search box is gone — the explorer is now a plain buffer
                // where `/` opens the normal incremental search.
                if let Some(win_id) = self.tabs[self.active_tab]
                    .explorer
                    .as_ref()
                    .map(|ep| ep.win_id)
                {
                    let rect = self
                        .windows
                        .get(win_id)
                        .and_then(|w| w.as_ref())
                        .and_then(|w| w.last_rect);
                    if let Some(rect) = rect {
                        let in_pane = me.column >= rect.x
                            && me.column < rect.x + rect.w
                            && me.row >= rect.y
                            && me.row < rect.y + rect.h;

                        // Tree-row click → move cursor there + activate. Resolve
                        // the doc row with the FOLD-AWARE `cell_to_doc` (a naive
                        // `top_row + screen_offset` ignores collapsed dirs, so a
                        // click below a closed fold lands on the wrong node). It
                        // also uses the window's own scroll origin, correct for
                        // the (usually unfocused) explorer pane.
                        if in_pane {
                            if let Some((doc_row, _)) =
                                mouse::cell_to_doc(self, win_id, me.column, me.row)
                            {
                                // Move the explorer window editor cursor to the
                                // clicked row (#151 Phase D), then activate: toggle
                                // a dir's fold or open/focus a file.
                                self.set_explorer_window_cursor(doc_row, 0, None);
                                self.explorer_activate();
                                self.sync_after_engine_mutation_deferred();
                            }
                            return MouseOutcome::Continue;
                        }
                        // else: click outside the explorer pane → fall through.
                    }
                }

                // ── Phase 9: border-drag hit-test ─────────────────
                // Check BEFORE context-menu and window-click logic so
                // a border click never accidentally focuses a window.
                if let Some(hit) = mouse::hit_test_border(self, me.column, me.row) {
                    // Encode border position as a synthetic id for the
                    // double-click tracker. We use a large offset beyond
                    // real WindowIds to avoid collisions.
                    let synthetic_id: usize = usize::MAX
                        .wrapping_sub(hit.border_cell.0 as usize)
                        .wrapping_sub((hit.border_cell.1 as usize) << 16);
                    let count = self.mouse_click_tracker.register(synthetic_id, 0, 0);
                    if count == 2 && !hit.dock {
                        // Double-click → equalize all splits. Not meaningful
                        // for the dock border (no ratio to reset); a dock
                        // double-click is just a no-op single click below.
                        self.equalize_split();
                    } else {
                        // Single click → begin drag.
                        let last_pos = match hit.orientation {
                            mouse::SplitOrientation::Vertical => me.column,
                            mouse::SplitOrientation::Horizontal => me.row,
                        };
                        self.border_drag = Some(crate::app::BorderDrag {
                            orientation: hit.orientation,
                            split_origin: hit.split_origin,
                            split_total: hit.split_total,
                            last_pos,
                            dock: hit.dock,
                        });
                    }
                    return MouseOutcome::Continue;
                }

                // ── Context-menu: click-inside → invoke / click-outside → dismiss
                if let Some(ref menu) = self.context_menu {
                    let screen_size = self.screen_rect();
                    let rect = crate::menu::bounding_rect(menu, screen_size);
                    let inside = me.column >= rect.x
                        && me.column < rect.x + rect.width
                        && me.row >= rect.y
                        && me.row < rect.y + rect.height;

                    if inside {
                        // Check whether click landed on a selectable row.
                        if me.row > rect.y && me.row < rect.y + rect.height - 1 {
                            let item_idx = (me.row - rect.y - 1) as usize;
                            let action = menu
                                .items
                                .get(item_idx)
                                .filter(|it| {
                                    it.enabled && it.action != crate::menu::MenuAction::Separator
                                })
                                .map(|it| it.action.clone());
                            self.context_menu = None;
                            if let Some(act) = action {
                                self.invoke_menu_action(act);
                            }
                        }
                        return MouseOutcome::Continue; // Don't fall through to editor click.
                    } else {
                        self.context_menu = None;
                        // Fall through to normal editor click.
                    }
                }

                // ── P4.1: Ctrl+Left-click → goto-definition ──────
                if me.modifiers.contains(KeyModifiers::CONTROL) {
                    if let mouse::Zone::Code {
                        win_id,
                        doc_row,
                        doc_col,
                    } = mouse::hit_test_zone(self, me.column, me.row)
                    {
                        // Focus window if needed.
                        let current_focus = self.focused_window();
                        if win_id != current_focus {
                            self.switch_focus(win_id);
                        }
                        self.active_editor_mut().mouse_click_doc(doc_row, doc_col);
                        self.sync_after_engine_mutation_deferred();
                        self.lsp_goto_definition();
                    }
                    // Ctrl+click outside Code zone is a no-op.
                    return MouseOutcome::Continue;
                }

                // ── P4.2: Shift+Left-click → extend visual selection
                if me.modifiers.contains(KeyModifiers::SHIFT) {
                    if let mouse::Zone::Code {
                        win_id,
                        doc_row,
                        doc_col,
                    } = mouse::hit_test_zone(self, me.column, me.row)
                    {
                        // Focus window if needed.
                        let current_focus = self.focused_window();
                        if win_id != current_focus {
                            self.switch_focus(win_id);
                        }
                        // Anchor at current cursor if not already visual.
                        if self.active_editor().vim_mode() != VimMode::Visual {
                            self.active_editor_mut().mouse_begin_drag();
                        }
                        self.active_editor_mut()
                            .mouse_extend_drag_doc(doc_row, doc_col);
                        self.sync_after_engine_mutation_deferred();
                    }
                    // Shift+click outside Code zone is a no-op.
                    return MouseOutcome::Continue;
                }

                // Left-click on the tab bar / buffer line switches
                // to that tab or buffer. Clicking the close glyph closes it.
                match mouse::hit_test_zone(self, me.column, me.row) {
                    mouse::Zone::TabBarClose { tab_idx } => {
                        if tab_idx != self.active_tab {
                            self.switch_tab(tab_idx);
                        }
                        self.do_tabclose();
                        return MouseOutcome::Continue;
                    }
                    mouse::Zone::TabBar { tab_idx } => {
                        if tab_idx != self.active_tab {
                            self.switch_tab(tab_idx);
                        }
                        return MouseOutcome::Continue;
                    }
                    mouse::Zone::BufferLineClose { slot_idx } => {
                        self.close_buffer_slot(slot_idx);
                        return MouseOutcome::Continue;
                    }
                    mouse::Zone::BufferLine { slot_idx } => {
                        if slot_idx != self.focused_slot_idx() {
                            self.switch_to(slot_idx);
                        }
                        return MouseOutcome::Continue;
                    }
                    // ── P10: left-click a fold marker in the gutter → toggle fold.
                    mouse::Zone::Gutter { win_id, doc_row } => {
                        // Focus the clicked window first (matches inactive-window
                        // click-to-focus behaviour); only toggle when a fold
                        // actually starts/contains this row so plain line-number
                        // clicks stay no-ops (see `gutter_click_no_cursor_move`).
                        let current_focus = self.focused_window();
                        if win_id != current_focus {
                            self.switch_focus(win_id);
                        }
                        if self.active_editor().buffer().fold_at_row(doc_row).is_some() {
                            self.active_editor_mut()
                                .apply_fold_op(hjkl_engine::FoldOp::ToggleAt(doc_row));
                            self.sync_after_engine_mutation_deferred();
                        } else if self.active().git_signs.iter().any(|s| s.row == doc_row) {
                            // P10: no fold here, but a git sign is — preview the
                            // hunk covering this row in a read-only popup.
                            self.git_show_hunk_diff_at_row(doc_row);
                        }
                        return MouseOutcome::Continue;
                    }
                    _ => {}
                }

                if let Some(win_id) = mouse::hit_test_window(self, me.column, me.row) {
                    // Resolve the clicked doc position AGAINST THE RENDERED FRAME,
                    // i.e. BEFORE focusing. `switch_focus` installs the window's
                    // saved cursor and applies scrolloff, which can scroll the
                    // editor viewport; mapping the click after that would read the
                    // post-scroll `top_row` and land on the wrong line (every
                    // click into a not-yet-focused pane came out offset).
                    let hit = mouse::cell_to_doc(self, win_id, me.column, me.row);
                    // Focus the clicked window if it differs.
                    let current_focus = self.focused_window();
                    if win_id != current_focus {
                        self.switch_focus(win_id);
                    }
                    if let Some((doc_row, doc_col)) = hit {
                        let count = self.mouse_click_tracker.register(win_id, doc_row, doc_col);
                        // ── #63 Phase C: quickfix/location-list dock double-click ──
                        // Double-click jumps to the entry under the cursor, mirroring
                        // vim's real quickfix window (`:h quickfix-window`:
                        // "double-clicking on an entry has the same effect as typing
                        // <CR>") and this app's own `<CR>` binding
                        // (`qf_dock_jump_at_cursor`). This intentionally overrides the
                        // double-click-selects-word convention used for ordinary text
                        // windows below — the dock is a read-only entry list, not
                        // prose, so word-select has no use there and vim's own
                        // quickfix window makes the identical trade-off.
                        if count == 2 && self.is_bottom_dock(win_id) {
                            self.active_editor_mut().mouse_click_doc(doc_row, doc_col);
                            self.qf_dock_jump_at_cursor();
                            return MouseOutcome::Continue;
                        }
                        match count {
                            1 => {
                                self.active_editor_mut().mouse_click_doc(doc_row, doc_col);
                            }
                            2 => {
                                // Double-click: select word.
                                self.active_editor_mut().mouse_click_doc(doc_row, doc_col);
                                let line = {
                                    let rope = self.active_editor().buffer().rope();
                                    if doc_row < rope.len_lines() {
                                        hjkl_buffer::rope_line_str(&rope, doc_row)
                                    } else {
                                        String::new()
                                    }
                                };
                                let (ws, we) = mouse::word_bounds(&line, doc_col);
                                // Anchor at word start, cursor at word end - 1.
                                self.active_editor_mut().enter_visual_char();
                                self.active_editor_mut().set_cursor_doc(doc_row, ws);
                                self.active_editor_mut().mouse_begin_drag();
                                self.active_editor_mut()
                                    .set_cursor_doc(doc_row, we.saturating_sub(1).max(ws));
                            }
                            _ => {
                                // Triple-click (and count≥4 wraps to 1 in tracker,
                                // so this branch only fires at count==3).
                                self.active_editor_mut().mouse_click_doc(doc_row, doc_col);
                                self.active_editor_mut().enter_visual_line();
                            }
                        }
                        self.sync_after_engine_mutation_deferred();
                    }
                }
            }
            MouseEventKind::Drag(MouseButton::Left) => {
                use crate::app::mouse;

                // ── Phase 9: border drag ──────────────────────────
                if let Some(drag) = self.border_drag {
                    let new_pos = match drag.orientation {
                        mouse::SplitOrientation::Vertical => me.column,
                        mouse::SplitOrientation::Horizontal => me.row,
                    };
                    if drag.dock {
                        // Dock resize (#63 Phase A/B) is delta-based against
                        // the live config width/height, not a ratio
                        // recompute — there's no split origin/total to work
                        // from. Persistence happens once on release, not per
                        // drag frame (see the `Up` arm below).
                        let delta = new_pos as i32 - drag.last_pos as i32;
                        match drag.orientation {
                            mouse::SplitOrientation::Vertical => self.resize_dock_width_by(delta),
                            // The bottom dock sits BELOW its top border, so
                            // dragging the border down (positive row delta)
                            // shrinks it — the inverse sign of the width
                            // case, where dragging right (positive col
                            // delta) grows the (left-anchored) dock.
                            mouse::SplitOrientation::Horizontal => {
                                self.resize_dock_height_by(-delta)
                            }
                        }
                    } else {
                        let split_pos = new_pos.saturating_sub(drag.split_origin);
                        self.resize_split_to(
                            drag.orientation,
                            drag.split_origin,
                            drag.split_total,
                            split_pos,
                        );
                    }
                    if let Some(d) = self.border_drag.as_mut() {
                        d.last_pos = new_pos;
                    }
                    return MouseOutcome::Continue;
                }

                let win_id = self.focused_window();
                if let Some((doc_row, doc_col)) =
                    mouse::cell_to_doc(self, win_id, me.column, me.row)
                {
                    // Begin drag on first drag event if not already in
                    // visual mode.
                    if self.active_editor().vim_mode() != VimMode::Visual {
                        self.active_editor_mut().mouse_begin_drag();
                    }
                    self.active_editor_mut()
                        .mouse_extend_drag_doc(doc_row, doc_col);
                    self.sync_after_engine_mutation_deferred();
                }
            }
            // Up: clear any active border drag; vim stays in
            // Visual after a text drag-release — no-op otherwise.
            MouseEventKind::Up(MouseButton::Left) if self.border_drag.is_some() => {
                use crate::app::mouse;
                // Dock drags persist once here on release, rather than per
                // drag-frame (`<C-w><`/`<C-w>>`/`<C-w>+`/`<C-w>-` persist per
                // press instead — see `App::persist_dock_width`'s doc
                // comment).
                if let Some(drag) = self.border_drag
                    && drag.dock
                {
                    match drag.orientation {
                        mouse::SplitOrientation::Vertical => self.persist_dock_width(),
                        mouse::SplitOrientation::Horizontal => self.persist_dock_height(),
                    }
                }
                self.border_drag = None;
            }

            // ── P4.3: Middle-click → primary-selection paste ──────
            //
            // X11 / Wayland convention: middle-click pastes the
            // primary selection (whatever is currently highlighted
            // anywhere on screen, independent of the system
            // clipboard).  macOS / Windows have no primary
            // selection; we silently no-op when the clipboard
            // backend does not report `Capabilities::PRIMARY`.
            MouseEventKind::Down(MouseButton::Middle) => {
                self.dismiss_hover_popup_on_click();
                self.middle_click(me.column, me.row);
            }

            // ── Right-click: open context menu (Phase 2 + 7 + 8) ─
            MouseEventKind::Down(MouseButton::Right) => {
                use crate::app::mouse;
                use crate::menu::{
                    ContextMenu, build_code_menu, build_split_border_menu, build_status_line_menu,
                    build_tab_menu,
                };

                // Dismiss hover popup — same rationale as left-click.
                self.hover_popup = None;
                self.hover_timer = None;
                let zone = mouse::hit_test_zone(self, me.column, me.row);
                let items = match zone {
                    mouse::Zone::Code { .. } => {
                        self.move_cursor_for_right_click(me.column, me.row);
                        let has_sel = matches!(
                            self.active_editor().vim_mode(),
                            VimMode::Visual | VimMode::VisualLine | VimMode::VisualBlock
                        );
                        build_code_menu(has_sel, self.active_has_lsp())
                    }
                    // ── Phase 6/10: gutter / sign-column menu ──────
                    // A diagnostic on the clicked line leads with Show
                    // Diagnostic + Code Actions (#116); a git change on
                    // the line adds Stage / Revert / Show Hunk (#115);
                    // otherwise this falls through to the Code menu.
                    mouse::Zone::Gutter { doc_row, .. } => {
                        self.move_cursor_for_right_click(me.column, me.row);
                        let has_sel = matches!(
                            self.active_editor().vim_mode(),
                            VimMode::Visual | VimMode::VisualLine | VimMode::VisualBlock
                        );
                        let has_diag = self.diagnostic_on_row(doc_row);
                        let git_kind = self.git_hunk_kind_at_row(doc_row);
                        crate::menu::build_gutter_menu(
                            has_diag,
                            git_kind,
                            self.active_has_lsp(),
                            has_sel,
                        )
                    }
                    mouse::Zone::TabBar { tab_idx } | mouse::Zone::TabBarClose { tab_idx } => {
                        // Switch to the clicked tab first so that
                        // Close-Tab / Close-Right / Close-Left operate on it.
                        if tab_idx != self.active_tab {
                            self.switch_tab(tab_idx);
                        }
                        build_tab_menu(self.tabs.len() > 1)
                    }
                    mouse::Zone::BufferLine { slot_idx }
                    | mouse::Zone::BufferLineClose { slot_idx } => {
                        // Switch to the clicked buffer first so the
                        // tab menu's actions operate on it. View
                        // line shares the tab menu for v1 — close /
                        // close-others / close-{left,right} have
                        // intuitive buffer-line semantics too.
                        if slot_idx != self.focused_slot_idx() {
                            self.switch_to(slot_idx);
                        }
                        build_tab_menu(self.tabs.len() > 1)
                    }
                    // ── Phase 7: status-line menu ─────────────────
                    mouse::Zone::StatusLine => {
                        let ft = self.active_filetype_label();
                        let lsp_name = self.active_lsp_server_name();
                        build_status_line_menu(&ft, lsp_name.as_deref())
                    }
                    // ── Phase 7: split-border menu ─────────────────
                    mouse::Zone::SplitBorder { .. } => build_split_border_menu(),
                    // `Zone::PickerRow` is handled by `handle_picker_mouse`,
                    // which returns before this match is ever reached while
                    // a picker is open (see the picker-exclusive check at
                    // the top of `handle_mouse`); kept here only so the
                    // match stays exhaustive.
                    mouse::Zone::PickerRow { .. } | mouse::Zone::None => {
                        return MouseOutcome::Continue;
                    }
                };
                self.context_menu = Some(ContextMenu::new(items, (me.column, me.row)));
            }

            // ── Mouse hover: update selected item ────────────────
            MouseEventKind::Moved => {
                // Read viewport dims BEFORE borrowing menu mutably
                // (split-borrow workaround). The previous "anchor +
                // slack" approximation broke hover→item mapping for
                // menus anchored near the screen edges: when
                // `bounding_rect` flipped the popup upward to fit on
                // screen, this handler still used the original
                // anchor as the rect origin and mapped hovers to the
                // wrong items. Use the real terminal area instead.
                let screen_size = self.screen_rect();
                if let Some(menu) = &mut self.context_menu {
                    let rect = crate::menu::bounding_rect(menu, screen_size);
                    // Inner area (strip border row/col).
                    if me.row > rect.y
                        && me.row < rect.y + rect.height - 1
                        && me.column > rect.x
                        && me.column < rect.x + rect.width - 1
                    {
                        // Row inside inner content; map to item index.
                        let item_idx = (me.row - rect.y - 1) as usize;
                        if item_idx < menu.items.len() {
                            let enabled = menu.items[item_idx].enabled
                                && menu.items[item_idx].action
                                    != crate::menu::MenuAction::Separator;
                            if enabled {
                                menu.selected = item_idx;
                            }
                        }
                    }
                }

                // ── Phase 5: hover-popup timer ────────────────────
                let cell = (me.column, me.row);

                // Any mouse move dismisses an open hover popup and
                // resets the timer to track the new cell.
                if self.hover_popup.is_some() {
                    self.hover_popup = None;
                    self.hover_timer = None;
                }

                // Skip arming entirely while an overlay is up —
                // a hover for the doc cell behind the menu/picker
                // would render through the overlay.
                if !self.overlay_active() {
                    let same_cell = self.hover_timer.as_ref().is_some_and(|h| h.cell == cell);
                    if !same_cell {
                        self.hover_timer = Some(crate::app::HoverTimer {
                            cell,
                            started_at: std::time::Instant::now(),
                            request_sent: false,
                        });
                    }
                    // Fire check (also handled in the poll-timeout
                    // tick) so we react immediately on the Moved
                    // event that coincides with the 500ms threshold.
                    self.tick_hover_timer();
                }
            }

            _ => {}
        }
        MouseOutcome::FallThrough
    }

    /// Route a mouse event while the picker overlay is open. Only called
    /// from `handle_mouse`'s picker-exclusive check, so `self.picker` is
    /// always `Some` on entry.
    ///
    /// - Left click on a list row selects it — mirrors what the right-click
    ///   arm already did before selecting the clicked row and opening the
    ///   menu (below); there's no existing single/double-click *confirm*
    ///   behavior anywhere else in the codebase to make reachable, so this
    ///   intentionally stops at selecting (confirm is still `<CR>` via
    ///   `handle_picker_key`).
    /// - Right click on a list row moves the selection there and opens the
    ///   existing `build_picker_menu` context menu (this used to be the
    ///   `Zone::PickerRow` arm of the right-click match in `handle_mouse`,
    ///   which was unreachable dead code for the same reason as the rest of
    ///   picker mouse support).
    /// - Wheel scroll moves the selection via `Picker::select_prev` /
    ///   `select_next`. There's no dedicated scroll `Zone` (the overlay is
    ///   modal, so any scroll while it's open targets the picker, not
    ///   whatever pane happens to be under the pointer) — gated on
    ///   `self.picker.is_some()` rather than a cell hit-test.
    /// - Everything else (drag, middle-click, hover) is swallowed: no
    ///   behavior exists for it, and letting it fall through would reach
    ///   the window/editor behind the overlay.
    pub(crate) fn handle_picker_mouse(&mut self, me: crossterm::event::MouseEvent) -> MouseOutcome {
        use crate::app::mouse;
        use crossterm::event::{MouseButton, MouseEventKind};

        match me.kind {
            MouseEventKind::Down(MouseButton::Left) => {
                let zone = mouse::hit_test_zone(self, me.column, me.row);
                if let (mouse::Zone::PickerRow { row_idx }, Some(p)) = (zone, self.picker.as_mut())
                {
                    p.selected = row_idx;
                }
            }
            MouseEventKind::Down(MouseButton::Right) => {
                use crate::menu::{ContextMenu, build_picker_menu};
                if let mouse::Zone::PickerRow { row_idx } =
                    mouse::hit_test_zone(self, me.column, me.row)
                {
                    if let Some(ref mut p) = self.picker {
                        p.selected = row_idx;
                    }
                    let has_path = self
                        .picker
                        .as_ref()
                        .and_then(|p| p.path_for_visible_row(p.selected))
                        .is_some();
                    let items = build_picker_menu(has_path);
                    self.context_menu = Some(ContextMenu::new(items, (me.column, me.row)));
                }
            }
            MouseEventKind::ScrollUp => {
                if let Some(ref mut p) = self.picker {
                    p.select_prev();
                }
            }
            MouseEventKind::ScrollDown => {
                if let Some(ref mut p) = self.picker {
                    p.select_next();
                }
            }
            _ => {}
        }
        MouseOutcome::Continue
    }

    /// Main event loop. Draws every frame, routes key events through
    /// the vim FSM, handles resize, exits on Ctrl-C.
    ///
    /// WARN: This loop batches expensive sync work (tree-sitter reparse,
    /// span query, git signs) via `self.pending_recompute = true` flushed
    /// at the top of each iteration right before `terminal.draw`. The
    /// invariants are load-bearing — break them and per-keystroke CPU
    /// regresses by multiple TS parses per key on huge files:
    ///
    /// 1. Keystroke arms (insert + normal) MUST set `pending_recompute = true`
    ///    and never call `recompute_and_install()` inline.
    /// 2. `KeyOutcome::Continue` MUST NOT `continue` to the top of the loop —
    ///    it must fall through to the drain block below so queued events
    ///    fold into the same flush. Use the `consumed_inline` flag pattern.
    /// 3. `render::frame` MUST NOT call `recompute_and_install()` — the
    ///    flush above already handled it. `App::new` seeds
    ///    `pending_recompute = true` so the first frame still runs an
    ///    initial parse via this flush.
    /// 4. Order is fixed: lsp drain → viewport → cursor shape → FLUSH →
    ///    draw → async polls → poll(timeout) → read → handle + drain.
    pub fn run(&mut self, terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> Result<()> {
        // Toast visibility as of the previous iteration. Toasts expire on a
        // wall clock while `frame_is_time_animated` only keeps repainting
        // while one is VISIBLE — the tick that flips Some→None must request
        // the draw that clears the expired toast from the screen.
        let mut last_toast_visible = false;
        loop {
            // ── Per-tick setup ────────────────────────────────────
            // NOTE: sync_viewport_to_editor() is NOT called here — it is
            // called only on focus change (switch_focus / close_focused_window
            // / move_window_to_new_tab).  Calling it before every keypress
            // clobbered sticky_col and broke j/k column preservation (#151).
            // Ensure every window has a view editor onto its slot's Buffer
            // (#151 Phase D). Splits create a window before its editor exists;
            // buffer switches change a window's slot. Idempotent — rebuilds a
            // window editor only on a real content change (Arc::ptr_eq).
            self.reconcile_window_editors();
            {
                let size = terminal.size()?;
                let vp = self.active_editor_mut().host_mut().viewport_mut();
                vp.width = size.width;
                vp.height = size.height.saturating_sub(STATUS_LINE_HEIGHT);
                // A terminal size change that never produced a `Resize` event
                // still changes the frame — repaint so the viewport catch-up
                // is visible. `last_frame_rect` is the dims of the last
                // actually-drawn frame; `None` (never drawn) forces the draw.
                if self
                    .last_frame_rect
                    .is_none_or(|r| r.width != size.width || r.height != size.height)
                {
                    self.needs_draw = true;
                }
            }
            // LSP async events can push toasts, update diagnostics/signs,
            // show hover/completion popups, or drop stale pending requests —
            // all render-visible. Fingerprint the LSP-driven state before and
            // after the drain (the drain itself can't be observed otherwise)
            // and repaint when it changed.
            if self.lsp.is_some() {
                let lsp_pre = self.lsp_render_fingerprint();
                self.drain_lsp_events();
                if self.lsp_render_fingerprint() != lsp_pre {
                    self.needs_draw = true;
                }
            } else {
                // No LSP manager — the drain is a guaranteed no-op.
                self.drain_lsp_events();
            }

            // ── Cursor shape ──────────────────────────────────────
            let current_shape = if let Some(ref f) = self.command_field {
                prompt_cursor_shape(f)
            } else if let Some(ref f) = self.search_field {
                prompt_cursor_shape(f)
            } else {
                self.active_editor().host().cursor_shape()
            };
            if current_shape != self.last_cursor_shape {
                match current_shape {
                    CursorShape::Block => {
                        let _ = execute!(terminal.backend_mut(), SetCursorStyle::SteadyBlock);
                    }
                    CursorShape::Bar => {
                        let _ = execute!(terminal.backend_mut(), SetCursorStyle::SteadyBar);
                    }
                    CursorShape::Underline => {
                        let _ = execute!(terminal.backend_mut(), SetCursorStyle::SteadyUnderScore);
                    }
                }
                self.last_cursor_shape = current_shape;
            }

            // Flush any deferred syntax recompute before drawing so the
            // frame sees the latest spans. Insert-mode arms `return
            // KeyOutcome::Continue` and skip the end-of-drain flush, so
            // without this the highlights would only catch up when the
            // user pressed a non-insert-arm key (e.g. ESC).
            if self.pending_recompute {
                self.pending_recompute = false;
                self.recompute_and_install();
                // New spans installed → the frame changed.
                self.needs_draw = true;
            }

            if self.scroll_anim_expired() {
                self.scroll_anim = None;
                // Animation ended: the next frame shows the settled position.
                self.needs_draw = true;
            }

            // ── Draw ──────────────────────────────────────────────
            // 120 ms bookkeeping runs on EVERY poll regardless of whether a
            // draw happens: the inline-blame idle debounce (source-agnostic,
            // so the blame ghost engages only after the cursor has settled
            // for `BLAME_IDLE_DELAY`) and the start-screen expiry (it must
            // retire on time even with no input at all). When the expiry
            // actually clears the splash, the transition frame must draw.
            self.note_blame_cursor_motion();
            let had_start_screen = self.start_screen.is_some();
            self.expire_start_screen();
            if had_start_screen && self.start_screen.is_none() {
                self.needs_draw = true;
            }
            // Same transition for toasts: while one is visible the repaints
            // are continuous (`frame_is_time_animated`), but the tick it
            // expires needs an explicit draw to clear it off the screen.
            let toast_visible = self
                .bus
                .active(std::time::SystemTime::now())
                .next()
                .is_some();
            if last_toast_visible && !toast_visible {
                self.needs_draw = true;
            }
            last_toast_visible = toast_visible;
            // Repaint only when something changed since the last frame, or a
            // wall-clock-driven element is animating (`frame_is_time_animated`).
            // Purely idle polls — no input, no async output, nothing animating
            // — skip the redraw entirely.
            if self.frame_needs_redraw() {
                self.needs_draw = false;
                // `:redraw!` sets force_clear_screen; clear before drawing so
                // stale terminal content is wiped. Cleared immediately so only
                // the next frame pays the cost.
                if self.force_clear_screen {
                    self.force_clear_screen = false;
                    terminal.clear()?;
                }
                let t_draw = std::time::Instant::now();
                terminal.draw(|frame| render::frame(frame, self))?;
                tracing::debug!(
                    target: "hjkl::profile",
                    draw_us = t_draw.elapsed().as_micros(),
                    "draw"
                );
            }

            // ── Async polls ───────────────────────────────────────
            // Runs after the draw; when a poll produced output, request a
            // repaint so the next iteration shows it (the polls used to set
            // `pending_recompute` and rely on the unconditional draw to pick
            // the new state up).
            if self.drain_async_polls() {
                self.needs_draw = true;
            }

            // ── Poll timeout ──────────────────────────────────────
            let poll_timeout = self.compute_poll_timeout();

            // ── Wait for event ────────────────────────────────────
            if !event::poll(poll_timeout)? {
                let now = std::time::Instant::now();
                // Timeout-triggered UI changes (which-key popup, chord
                // resolution, hover popup show/expiry, indent-flash end) all
                // need a repaint — tracked here so a purely idle timeout
                // (nothing to show) stays draw-free.
                let mut repaint = false;
                if !self.which_key_active
                    && !self.active_which_key_prefix().is_empty()
                    && hjkl_which_key::should_show(
                        self.pending_prefix_at,
                        self.which_key_delay,
                        self.which_key_enabled,
                        now,
                    )
                {
                    self.which_key_active = true;
                    repaint = true;
                }
                // Chord timeout: only resolves an ambiguous prefix when the
                // which-key popup is NOT visible. Once the popup shows, the
                // user has seen the menu and should pick a key (or Esc to
                // cancel) — letting the timeout fire here would yank the
                // popup away mid-decision. Matches which-key.nvim default
                // and vim-which-key behaviour.
                if !self.which_key_active
                    && let Some(prefix_at) = self.pending_prefix_at
                    && !self.app_keymap.pending(hjkl_vim::Mode::Normal).is_empty()
                    && now >= prefix_at + self.app_keymap.timeout_duration()
                    && let Some(replay) = self.resolve_chord_timeout(hjkl_vim::Mode::Normal)
                    && !replay.is_empty()
                {
                    replay_to_engine(self, &replay);
                    self.sync_after_engine_mutation();
                    repaint = true;
                }
                // ── Idle swap-write (issue #185, audit-r2 fix 3) ───────
                self.tick_idle_swap_write(now);
                // The hover timer can show a popup (blame-mode hover sets it
                // synchronously), and the expiry check below can clear one —
                // both change the frame.
                let had_hover = self.hover_popup.is_some();
                self.tick_hover_timer();
                if self
                    .hover_popup
                    .as_ref()
                    .is_some_and(|p| p.is_expired(std::time::Instant::now()))
                {
                    self.hover_popup = None;
                    self.hover_timer = None;
                }
                if self.hover_popup.is_some() != had_hover {
                    repaint = true;
                }
                // Indent flash: repaint while it animates (it is also part of
                // `frame_is_time_animated`), and repaint on the tick that
                // expires it so the flash's disappearance shows.
                if self.indent_flash.is_some() {
                    self.indent_flash_active();
                    if self.indent_flash.is_none() {
                        repaint = true;
                    }
                }
                if repaint {
                    self.needs_draw = true;
                }
                continue;
            }

            // ── Dispatch event ────────────────────────────────────
            // ANY terminal event may have changed render state — flag the
            // repaint before dispatching so a handler that mutates something
            // the renderer reads can never be missed (conservative default:
            // draw when in doubt).
            self.needs_draw = true;
            match event::read()? {
                Event::Key(key) => {
                    // Record keystroke time for the idle swap-write timer (#185).
                    self.last_input_at = std::time::Instant::now();

                    // ── Kitty keyboard normalization ────────
                    // Under DISAMBIGUATE_ESCAPE_CODES, Ctrl+[ ≠ Esc, Ctrl+I ≠ Tab,
                    // Ctrl+M ≠ Enter at the terminal level. Normalize back to the
                    // legacy aliases vim expects.
                    let key = hjkl_kitty::normalize_legacy(key);

                    let consumed_inline = match self.handle_keypress(key) {
                        KeyOutcome::Break => break,
                        // Insert-mode arms handle the keystroke fully and
                        // set `pending_recompute = true` themselves. Skip
                        // the FallThrough cleanup but still hit the drain
                        // loop below so a burst of inline-consumed keys
                        // folds into one recompute + draw.
                        KeyOutcome::Continue => true,
                        KeyOutcome::FallThrough => false,
                    };

                    if !consumed_inline {
                        self.dispatch_fallthrough_key(key);
                    }
                }
                Event::Mouse(me) => {
                    let _ = self.handle_mouse(me);
                }
                Event::Resize(w, h) => self.handle_resize(w, h),
                Event::FocusGained => {
                    self.checktime_all();
                }
                Event::Paste(text) => {
                    self.handle_paste(text);
                }
                _ => {}
            }

            // After every key tick (both consumed-inline and fall-through
            // paths), check whether the explorer buffer changed and apply any
            // pending filesystem ops. The dirty_gen guard and Normal-mode
            // check make this a no-op on most ticks.
            self.maybe_reconcile_explorer();

            // Drain any additional events currently ready (e.g. a burst
            // of mouse-wheel scrolls) before running the deferred sync
            // query. Each scroll handler set `pending_recompute = true`
            // instead of firing `recompute_and_install` synchronously,
            // so we collapse the whole burst into one sync query install.
            let t_drain = std::time::Instant::now();
            let mut drained = 0usize;
            while event::poll(Duration::from_millis(0)).unwrap_or(false) {
                drained += 1;
                if let Ok(extra) = event::read() {
                    match extra {
                        Event::Key(k) => {
                            // Kitty-protocol legacy normalization (drain-loop
                            // mirror of the primary path): map disambiguated
                            // Ctrl+[ / Ctrl+I / Ctrl+M back to Esc / Tab / Enter
                            // so muscle-memory survives.
                            let k = hjkl_kitty::normalize_legacy(k);
                            match self.handle_keypress(k) {
                                KeyOutcome::Break => {
                                    self.exit_requested = true;
                                    break;
                                }
                                KeyOutcome::Continue => continue,
                                // Drain-loop mirror of the primary key arm above —
                                // routes through the same shared method so the two
                                // fall-through paths cannot drift out of sync again.
                                KeyOutcome::FallThrough => self.dispatch_fallthrough_key(k),
                            }
                        }
                        Event::Mouse(me2) => {
                            let _ = self.handle_mouse(me2);
                        }
                        // Drain-loop mirror of the primary Resize arm above —
                        // shares `handle_resize` so both paths stay in sync.
                        Event::Resize(w, h) => self.handle_resize(w, h),
                        Event::FocusGained => {
                            self.checktime_all();
                        }
                        Event::Paste(text) => {
                            self.handle_paste(text);
                        }
                        _ => {}
                    }
                }
            }

            // Flush deferred recompute once after the drain loop ends.
            // Coalesces burst-scrolls (and rapid keystrokes within one
            // poll tick) into a single sync query + install.
            if drained > 0 {
                tracing::debug!(
                    target: "hjkl::profile",
                    drained,
                    drain_us = t_drain.elapsed().as_micros(),
                    "event drain"
                );
            }
            if self.pending_recompute {
                self.pending_recompute = false;
                self.recompute_and_install();
            }

            if self.exit_requested {
                break;
            }
        }
        // Graceful exit: remember every open buffer's last cursor position for
        // cross-session restore (docs §6b), in a single store write, BEFORE the
        // swap files are removed. Best-effort — never blocks the quit.
        self.persist_all_cursors();
        // Graceful exit: remove all swap files so clean sessions leave no stale
        // swap behind. Crashes / SIGKILL bypass this block and the swap survives
        // for recovery — which is exactly the distinction we want.
        self.cleanup_swaps_on_exit();
        Ok(())
    }

    /// Tick the Phase 5 hover timer.
    ///
    /// Called on every poll-timeout tick AND on every `MouseEventKind::Moved`
    /// event so the RPC fires promptly when the mouse has been stationary for
    /// [`HOVER_DELAY`]. If the timer is armed, the cell is in a Code zone, and
    /// the 500ms threshold has elapsed, sends the LSP hover RPC once.
    pub(crate) fn tick_hover_timer(&mut self) {
        // If a popup is already showing, nothing to do.
        if self.hover_popup.is_some() {
            return;
        }
        // Suppress hover firing while any overlay is on top of the editor —
        // a hover RPC for the doc cell BEHIND the overlay would show the
        // popup through the menu/picker/command field. Drop the timer too
        // so it doesn't fire the instant the overlay closes.
        if self.overlay_active() {
            self.hover_timer = None;
            return;
        }
        let (cell, should_fire) = match &self.hover_timer {
            Some(h) if !h.request_sent && h.started_at.elapsed() >= HOVER_DELAY => (h.cell, true),
            _ => return,
        };

        if !should_fire {
            return;
        }
        // In BLAME mode, hovering ANY row — including the virtual commit-header
        // border — shows the full commit message in the markdown popup. Resolve
        // the commit's doc row via the box-plan-aware helper (hit_test_zone
        // returns None on border rows, so it can't be used here).
        if self.active_editor().is_blame() {
            if let Some(doc_row) = crate::app::mouse::blame_hover_doc_row(self, cell.0, cell.1) {
                self.show_blame_commit_hover(doc_row, cell);
                if let Some(h) = self.hover_timer.as_mut() {
                    h.request_sent = true;
                }
            }
            return;
        }
        // Otherwise a code cell triggers an LSP hover.
        if let crate::app::mouse::Zone::Code {
            win_id,
            doc_row,
            doc_col,
        } = crate::app::mouse::hit_test_zone(self, cell.0, cell.1)
        {
            // Skip (and clear the timer) when the hovered window's buffer has
            // hover popups disabled — avoids spurious LSP requests over the
            // explorer or other special scratch buffers.
            let hover_disabled = self
                .windows
                .get(win_id)
                .and_then(|w| w.as_ref())
                .is_some_and(|w| !self.slots[w.slot].features.hover);
            if hover_disabled {
                self.hover_timer = None;
                return;
            }
            self.lsp_hover_at_doc(doc_row, doc_col);
            if let Some(h) = self.hover_timer.as_mut() {
                h.request_sent = true;
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn temp_app() -> App {
        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join("needs_draw.txt");
        std::fs::write(&path, "hello\nworld\n").unwrap();
        App::new(Some(path), false, None, None).unwrap()
    }

    /// A fresh `App` must repaint its first frame: the loop draws at the top
    /// of the first iteration before any event arrives, so `App::new` has to
    /// seed the flag.
    #[test]
    fn fresh_app_requests_a_first_frame() {
        let app = temp_app();
        assert!(app.needs_draw, "App::new must seed needs_draw");
        assert!(app.frame_needs_redraw(), "the first frame must be drawn");
    }

    /// The whole point of the gate: an input event requests a repaint, while
    /// a purely idle poll (no state change, no wall-clock animation) must
    /// NOT — that is where the draw cost is saved.
    #[test]
    fn input_event_requests_draw_but_pure_idle_poll_does_not() {
        let mut app = temp_app();
        // Settle every wall-clock-driven element so the frame is static: a
        // fresh app has no start screen (file arg), scroll/indent animation,
        // picker, toast, or in-flight request — but the blame idle window
        // starts at "now", so push it far into the past.
        app.blame_cursor_moved_at = std::time::Instant::now() - std::time::Duration::from_secs(5);
        app.needs_draw = false; // simulate the loop having drawn the first frame

        // Pure idle poll: nothing changed, nothing animating → no repaint.
        assert!(
            !app.frame_is_time_animated(),
            "settled app has no time-animated element"
        );
        assert!(
            !app.frame_needs_redraw(),
            "a settled app must skip the idle redraw"
        );

        // An input event (the resize handler is the event-arm path that sets
        // the flag inside its own handler; key/mouse/paste set it in the
        // loop's event arm before dispatch) requests a repaint.
        app.handle_resize(100, 40);
        assert!(app.needs_draw, "an input event must set needs_draw");
        assert!(
            app.frame_needs_redraw(),
            "an input event must request a repaint"
        );
    }

    /// The draw arm clears the flag, so the NEXT idle poll is draw-free
    /// again — the flag must not latch.
    #[test]
    fn draw_clears_flag_and_next_idle_poll_stays_draw_free() {
        let mut app = temp_app();
        app.blame_cursor_moved_at = std::time::Instant::now() - std::time::Duration::from_secs(5);
        // The loop's draw arm clears `needs_draw` right before drawing.
        app.needs_draw = false;
        assert!(
            !app.frame_needs_redraw(),
            "after a draw, an idle poll must not redraw"
        );
    }
}
