Skip to main content

fresh/app/
input.rs

1use super::*;
2use crate::model::event::LeafId;
3use crate::services::plugins::hooks::HookArgs;
4use anyhow::Result as AnyhowResult;
5use rust_i18n::t;
6impl Editor {
7    /// Determine the current keybinding context based on UI state
8    pub fn get_key_context(&self) -> crate::input::keybindings::KeyContext {
9        use crate::input::keybindings::KeyContext;
10
11        // Priority order: Settings > Menu > Prompt > Popup > CompositeBuffer > Current context (FileExplorer or Normal)
12        if self.settings_state.as_ref().is_some_and(|s| s.visible) {
13            KeyContext::Settings
14        } else if self.menu_state.active_menu.is_some() {
15            KeyContext::Menu
16        } else if self.is_prompting() {
17            KeyContext::Prompt
18        } else if self.active_state().popups.is_visible() {
19            KeyContext::Popup
20        } else if self.is_composite_buffer(self.active_buffer()) {
21            KeyContext::CompositeBuffer
22        } else {
23            // Use the current context (can be FileExplorer or Normal)
24            self.key_context.clone()
25        }
26    }
27
28    /// Handle a key event and return whether it was handled
29    /// This is the central key handling logic used by both main.rs and tests
30    pub fn handle_key(
31        &mut self,
32        code: crossterm::event::KeyCode,
33        modifiers: crossterm::event::KeyModifiers,
34    ) -> AnyhowResult<()> {
35        use crate::input::keybindings::Action;
36
37        let _t_total = std::time::Instant::now();
38
39        tracing::trace!(
40            "Editor.handle_key: code={:?}, modifiers={:?}",
41            code,
42            modifiers
43        );
44
45        // Create key event for dispatch methods
46        let key_event = crossterm::event::KeyEvent::new(code, modifiers);
47
48        // Event debug dialog intercepts ALL key events before any other processing.
49        // This must be checked here (not just in main.rs/gui) so it works in
50        // client/server mode where handle_key is called directly.
51        if self.is_event_debug_active() {
52            self.handle_event_debug_input(&key_event);
53            return Ok(());
54        }
55
56        // Try terminal input dispatch first (handles terminal mode and re-entry)
57        if self.dispatch_terminal_input(&key_event).is_some() {
58            return Ok(());
59        }
60
61        // Clear skip_ensure_visible flag so cursor becomes visible after key press
62        // (scroll actions will set it again if needed). Use the *effective*
63        // active split so this clears the flag on a focused buffer-group
64        // panel's own view state, not the group host's — without this, a
65        // scroll action in the panel (mouse scrollbar click, plugin
66        // scrollBufferToLine, etc.) sets `skip_ensure_visible` on the panel
67        // and subsequent key presses never clear it, so cursor motion stops
68        // scrolling the viewport.
69        let active_split = self.effective_active_split();
70        if let Some(view_state) = self.split_view_states.get_mut(&active_split) {
71            view_state.viewport.clear_skip_ensure_visible();
72        }
73
74        // Dismiss theme info popup on any key press
75        if self.theme_info_popup.is_some() {
76            self.theme_info_popup = None;
77        }
78
79        // Determine the current context first
80        let mut context = self.get_key_context();
81
82        // Special case: Hover and Signature Help popups should be dismissed on any key press
83        // EXCEPT for Ctrl+C when the popup has a text selection (allow copy first)
84        if matches!(context, crate::input::keybindings::KeyContext::Popup) {
85            // Check if the current popup is transient (hover, signature help)
86            let (is_transient_popup, has_selection) = {
87                let popup = self.active_state().popups.top();
88                (
89                    popup.is_some_and(|p| p.transient),
90                    popup.is_some_and(|p| p.has_selection()),
91                )
92            };
93
94            // Don't dismiss if popup has selection and user is pressing Ctrl+C (let them copy first)
95            let is_copy_key = key_event.code == crossterm::event::KeyCode::Char('c')
96                && key_event
97                    .modifiers
98                    .contains(crossterm::event::KeyModifiers::CONTROL);
99
100            if is_transient_popup && !(has_selection && is_copy_key) {
101                // Dismiss the popup on any key press (except Ctrl+C with selection)
102                self.hide_popup();
103                tracing::debug!("Dismissed transient popup on key press");
104                // Recalculate context now that popup is gone
105                context = self.get_key_context();
106            }
107        }
108
109        // Try hierarchical modal input dispatch first (Settings, Menu, Prompt, Popup)
110        if self.dispatch_modal_input(&key_event).is_some() {
111            return Ok(());
112        }
113
114        // If a modal was dismissed (e.g., completion popup closed and returned Ignored),
115        // recalculate the context so the key is processed in the correct context.
116        if context != self.get_key_context() {
117            context = self.get_key_context();
118        }
119
120        // Only check buffer mode keybindings when the editor buffer has focus.
121        // FileExplorer, Menu, Prompt, Popup contexts should not trigger mode bindings
122        // (e.g. markdown-source's Enter handler should not fire while the explorer is focused).
123        let should_check_mode_bindings =
124            matches!(context, crate::input::keybindings::KeyContext::Normal);
125
126        if should_check_mode_bindings {
127            // effective_mode() returns buffer-local mode if present, else global mode.
128            // This ensures virtual buffer modes aren't hijacked by global modes.
129            let effective_mode = self.effective_mode().map(|s| s.to_owned());
130
131            if let Some(ref mode_name) = effective_mode {
132                let mode_ctx = crate::input::keybindings::KeyContext::Mode(mode_name.to_string());
133                let key_event = crossterm::event::KeyEvent::new(code, modifiers);
134
135                // Mode chord resolution (via KeybindingResolver)
136                let (chord_result, resolved_action) = {
137                    let keybindings = self.keybindings.read().unwrap();
138                    let chord_result =
139                        keybindings.resolve_chord(&self.chord_state, &key_event, mode_ctx.clone());
140                    let resolved = keybindings.resolve(&key_event, mode_ctx);
141                    (chord_result, resolved)
142                };
143                match chord_result {
144                    crate::input::keybindings::ChordResolution::Complete(action) => {
145                        tracing::debug!("Mode chord resolved to action: {:?}", action);
146                        self.chord_state.clear();
147                        return self.handle_action(action);
148                    }
149                    crate::input::keybindings::ChordResolution::Partial => {
150                        tracing::debug!("Potential chord prefix in mode '{}'", mode_name);
151                        self.chord_state.push((code, modifiers));
152                        return Ok(());
153                    }
154                    crate::input::keybindings::ChordResolution::NoMatch => {
155                        if !self.chord_state.is_empty() {
156                            tracing::debug!("Chord sequence abandoned in mode, clearing state");
157                            self.chord_state.clear();
158                        }
159                    }
160                }
161
162                // Mode single-key resolution (custom > keymap > plugin defaults)
163                if resolved_action != Action::None {
164                    return self.handle_action(resolved_action);
165                }
166            }
167
168            // Handle unbound keys for modes that want to capture input.
169            //
170            // Buffer-local modes with allow_text_input (e.g. search-replace-list)
171            // capture character keys and block other unbound keys.
172            //
173            // Buffer-local modes WITHOUT allow_text_input (e.g. diff-view) let
174            // unbound keys fall through to normal keybinding handling so that
175            // Ctrl+C, arrows, etc. still work.
176            //
177            // Global editor modes (e.g. vi-normal) block all unbound keys when
178            // read-only.
179            if let Some(ref mode_name) = effective_mode {
180                if self.mode_registry.allows_text_input(mode_name) {
181                    if let KeyCode::Char(c) = code {
182                        let ch = if modifiers.contains(KeyModifiers::SHIFT) {
183                            c.to_uppercase().next().unwrap_or(c)
184                        } else {
185                            c
186                        };
187                        if !modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) {
188                            let action_name = format!("mode_text_input:{}", ch);
189                            return self.handle_action(Action::PluginAction(action_name));
190                        }
191                    }
192                    tracing::debug!("Blocking unbound key in text-input mode '{}'", mode_name);
193                    return Ok(());
194                }
195            }
196            if let Some(ref mode_name) = self.editor_mode {
197                if self.mode_registry.is_read_only(mode_name) {
198                    tracing::debug!("Ignoring unbound key in read-only mode '{}'", mode_name);
199                    return Ok(());
200                }
201                tracing::debug!(
202                    "Mode '{}' is not read-only, allowing key through",
203                    mode_name
204                );
205            }
206        }
207
208        // --- Composite buffer input routing ---
209        // If the active buffer is a composite buffer (side-by-side diff),
210        // route remaining composite-specific keys (scroll, pane switch, close)
211        // through CompositeInputRouter before falling through to regular
212        // keybinding resolution. Hunk navigation (n/p/]/[) is handled by the
213        // Action system via CompositeBuffer context bindings.
214        {
215            let active_buf = self.active_buffer();
216            let active_split = self.effective_active_split();
217            if self.is_composite_buffer(active_buf) {
218                if let Some(handled) =
219                    self.try_route_composite_key(active_split, active_buf, &key_event)
220                {
221                    return handled;
222                }
223            }
224        }
225
226        // Check for chord sequence matches first
227        let key_event = crossterm::event::KeyEvent::new(code, modifiers);
228        let (chord_result, action) = {
229            let keybindings = self.keybindings.read().unwrap();
230            let chord_result =
231                keybindings.resolve_chord(&self.chord_state, &key_event, context.clone());
232            let action = keybindings.resolve(&key_event, context.clone());
233            (chord_result, action)
234        };
235
236        match chord_result {
237            crate::input::keybindings::ChordResolution::Complete(action) => {
238                // Complete chord match - execute action and clear chord state
239                tracing::debug!("Complete chord match -> Action: {:?}", action);
240                self.chord_state.clear();
241                return self.handle_action(action);
242            }
243            crate::input::keybindings::ChordResolution::Partial => {
244                // Partial match - add to chord state and wait for more keys
245                tracing::debug!("Partial chord match - waiting for next key");
246                self.chord_state.push((code, modifiers));
247                return Ok(());
248            }
249            crate::input::keybindings::ChordResolution::NoMatch => {
250                // No chord match - clear state and try regular resolution
251                if !self.chord_state.is_empty() {
252                    tracing::debug!("Chord sequence abandoned, clearing state");
253                    self.chord_state.clear();
254                }
255            }
256        }
257
258        // Regular single-key resolution (already resolved above)
259        tracing::trace!("Context: {:?} -> Action: {:?}", context, action);
260
261        // Cancel pending LSP requests on user actions (except LSP actions themselves)
262        // This ensures stale completions don't show up after the user has moved on
263        match action {
264            Action::LspCompletion
265            | Action::LspGotoDefinition
266            | Action::LspReferences
267            | Action::LspHover
268            | Action::None => {
269                // Don't cancel for LSP actions or no-op
270            }
271            _ => {
272                // Cancel any pending LSP requests
273                self.cancel_pending_lsp_requests();
274            }
275        }
276
277        // Note: Modal components (Settings, Menu, Prompt, Popup, File Browser) are now
278        // handled by dispatch_modal_input using the InputHandler system.
279        // All remaining actions delegate to handle_action.
280        self.handle_action(action)
281    }
282
283    /// Handle an action (for normal mode and command execution).
284    /// Used by the app module internally and by the GUI module for native menu dispatch.
285    pub(crate) fn handle_action(&mut self, action: Action) -> AnyhowResult<()> {
286        use crate::input::keybindings::Action;
287
288        // Record action to macro if recording
289        self.record_macro_action(&action);
290
291        // Reset dabbrev cycling session on any non-dabbrev action.
292        if !matches!(action, Action::DabbrevExpand) {
293            self.reset_dabbrev_state();
294        }
295
296        match action {
297            Action::Quit => self.quit(),
298            Action::ForceQuit => {
299                self.should_quit = true;
300            }
301            Action::Detach => {
302                self.should_detach = true;
303            }
304            Action::Save => {
305                // Check if buffer has a file path - if not, redirect to SaveAs
306                if self.active_state().buffer.file_path().is_none() {
307                    self.start_prompt_with_initial_text(
308                        t!("file.save_as_prompt").to_string(),
309                        PromptType::SaveFileAs,
310                        String::new(),
311                    );
312                    self.init_file_open_state();
313                } else if self.check_save_conflict().is_some() {
314                    // Check if file was modified externally since we opened/saved it
315                    self.start_prompt(
316                        t!("file.file_changed_prompt").to_string(),
317                        PromptType::ConfirmSaveConflict,
318                    );
319                } else if let Err(e) = self.save() {
320                    let msg = format!("{}", e);
321                    self.status_message = Some(t!("file.save_failed", error = &msg).to_string());
322                }
323            }
324            Action::SaveAs => {
325                // Get current filename as default suggestion
326                let current_path = self
327                    .active_state()
328                    .buffer
329                    .file_path()
330                    .map(|p| {
331                        // Make path relative to working_dir if possible
332                        p.strip_prefix(&self.working_dir)
333                            .unwrap_or(p)
334                            .to_string_lossy()
335                            .to_string()
336                    })
337                    .unwrap_or_default();
338                self.start_prompt_with_initial_text(
339                    t!("file.save_as_prompt").to_string(),
340                    PromptType::SaveFileAs,
341                    current_path,
342                );
343                self.init_file_open_state();
344            }
345            Action::Open => {
346                self.start_prompt(t!("file.open_prompt").to_string(), PromptType::OpenFile);
347                self.prefill_open_file_prompt();
348                self.init_file_open_state();
349            }
350            Action::SwitchProject => {
351                self.start_prompt(
352                    t!("file.switch_project_prompt").to_string(),
353                    PromptType::SwitchProject,
354                );
355                self.init_folder_open_state();
356            }
357            Action::GotoLine => {
358                let has_line_index = self
359                    .buffers
360                    .get(&self.active_buffer())
361                    .is_none_or(|s| s.buffer.line_count().is_some());
362                if has_line_index {
363                    self.start_prompt(
364                        t!("file.goto_line_prompt").to_string(),
365                        PromptType::GotoLine,
366                    );
367                } else {
368                    self.start_prompt(
369                        t!("goto.scan_confirm_prompt", yes = "y", no = "N").to_string(),
370                        PromptType::GotoLineScanConfirm,
371                    );
372                }
373            }
374            Action::ScanLineIndex => {
375                self.start_incremental_line_scan(false);
376            }
377            Action::New => {
378                self.new_buffer();
379            }
380            Action::Close | Action::CloseTab => {
381                // Both Close and CloseTab use close_tab() which handles:
382                // - Closing the split if this is the last buffer and there are other splits
383                // - Prompting for unsaved changes
384                // - Properly closing the buffer
385                self.close_tab();
386            }
387            Action::Revert => {
388                // Check if buffer has unsaved changes - prompt for confirmation
389                if self.active_state().buffer.is_modified() {
390                    let revert_key = t!("prompt.key.revert").to_string();
391                    let cancel_key = t!("prompt.key.cancel").to_string();
392                    self.start_prompt(
393                        t!(
394                            "prompt.revert_confirm",
395                            revert_key = revert_key,
396                            cancel_key = cancel_key
397                        )
398                        .to_string(),
399                        PromptType::ConfirmRevert,
400                    );
401                } else {
402                    // No local changes, just revert
403                    if let Err(e) = self.revert_file() {
404                        self.set_status_message(
405                            t!("error.failed_to_revert", error = e.to_string()).to_string(),
406                        );
407                    }
408                }
409            }
410            Action::ToggleAutoRevert => {
411                self.toggle_auto_revert();
412            }
413            Action::FormatBuffer => {
414                if let Err(e) = self.format_buffer() {
415                    self.set_status_message(
416                        t!("error.format_failed", error = e.to_string()).to_string(),
417                    );
418                }
419            }
420            Action::TrimTrailingWhitespace => match self.trim_trailing_whitespace() {
421                Ok(true) => {
422                    self.set_status_message(t!("whitespace.trimmed").to_string());
423                }
424                Ok(false) => {
425                    self.set_status_message(t!("whitespace.no_trailing").to_string());
426                }
427                Err(e) => {
428                    self.set_status_message(
429                        t!("error.trim_whitespace_failed", error = e).to_string(),
430                    );
431                }
432            },
433            Action::EnsureFinalNewline => match self.ensure_final_newline() {
434                Ok(true) => {
435                    self.set_status_message(t!("whitespace.newline_added").to_string());
436                }
437                Ok(false) => {
438                    self.set_status_message(t!("whitespace.already_has_newline").to_string());
439                }
440                Err(e) => {
441                    self.set_status_message(
442                        t!("error.ensure_newline_failed", error = e).to_string(),
443                    );
444                }
445            },
446            Action::Copy => {
447                // Check if there's an active popup with text selection
448                let state = self.active_state();
449                if let Some(popup) = state.popups.top() {
450                    if popup.has_selection() {
451                        if let Some(text) = popup.get_selected_text() {
452                            self.clipboard.copy(text);
453                            self.set_status_message(t!("clipboard.copied").to_string());
454                            return Ok(());
455                        }
456                    }
457                }
458                // Check if active buffer is a composite buffer
459                let buffer_id = self.active_buffer();
460                if self.is_composite_buffer(buffer_id) {
461                    if let Some(_handled) = self.handle_composite_action(buffer_id, &Action::Copy) {
462                        return Ok(());
463                    }
464                }
465                self.copy_selection()
466            }
467            Action::CopyWithTheme(theme) => self.copy_selection_with_theme(&theme),
468            Action::Cut => {
469                if self.is_editing_disabled() {
470                    self.set_status_message(t!("buffer.editing_disabled").to_string());
471                    return Ok(());
472                }
473                self.cut_selection()
474            }
475            Action::Paste => {
476                if self.is_editing_disabled() {
477                    self.set_status_message(t!("buffer.editing_disabled").to_string());
478                    return Ok(());
479                }
480                self.paste()
481            }
482            Action::YankWordForward => self.yank_word_forward(),
483            Action::YankWordBackward => self.yank_word_backward(),
484            Action::YankToLineEnd => self.yank_to_line_end(),
485            Action::YankToLineStart => self.yank_to_line_start(),
486            Action::YankViWordEnd => self.yank_vi_word_end(),
487            Action::Undo => {
488                self.handle_undo();
489            }
490            Action::Redo => {
491                self.handle_redo();
492            }
493            Action::ShowHelp => {
494                self.open_help_manual();
495            }
496            Action::ShowKeyboardShortcuts => {
497                self.open_keyboard_shortcuts();
498            }
499            Action::ShowWarnings => {
500                self.show_warnings_popup();
501            }
502            Action::ShowStatusLog => {
503                self.open_status_log();
504            }
505            Action::ShowLspStatus => {
506                self.show_lsp_status_popup();
507            }
508            Action::ClearWarnings => {
509                self.clear_warnings();
510            }
511            Action::CommandPalette => {
512                // CommandPalette now delegates to QuickOpen (which starts with ">" prefix
513                // for command mode). Toggle if already open.
514                if let Some(prompt) = &self.prompt {
515                    if prompt.prompt_type == PromptType::QuickOpen {
516                        self.cancel_prompt();
517                        return Ok(());
518                    }
519                }
520                self.start_quick_open();
521            }
522            Action::QuickOpen => {
523                // Toggle Quick Open: close if already open, otherwise open it
524                if let Some(prompt) = &self.prompt {
525                    if prompt.prompt_type == PromptType::QuickOpen {
526                        self.cancel_prompt();
527                        return Ok(());
528                    }
529                }
530
531                // Start Quick Open with file suggestions (default mode)
532                self.start_quick_open();
533            }
534            Action::ToggleLineWrap => {
535                self.config.editor.line_wrap = !self.config.editor.line_wrap;
536
537                // Update all viewports to reflect the new line wrap setting,
538                // respecting per-language overrides
539                let leaf_ids: Vec<_> = self.split_view_states.keys().copied().collect();
540                for leaf_id in leaf_ids {
541                    let buffer_id = self
542                        .split_manager
543                        .get_buffer_id(leaf_id.into())
544                        .unwrap_or(BufferId(0));
545                    let effective_wrap = self.resolve_line_wrap_for_buffer(buffer_id);
546                    let wrap_column = self.resolve_wrap_column_for_buffer(buffer_id);
547                    if let Some(view_state) = self.split_view_states.get_mut(&leaf_id) {
548                        view_state.viewport.line_wrap_enabled = effective_wrap;
549                        view_state.viewport.wrap_indent = self.config.editor.wrap_indent;
550                        view_state.viewport.wrap_column = wrap_column;
551                    }
552                }
553
554                let state = if self.config.editor.line_wrap {
555                    t!("view.state_enabled").to_string()
556                } else {
557                    t!("view.state_disabled").to_string()
558                };
559                self.set_status_message(t!("view.line_wrap_state", state = state).to_string());
560            }
561            Action::ToggleCurrentLineHighlight => {
562                self.config.editor.highlight_current_line =
563                    !self.config.editor.highlight_current_line;
564
565                // Update all splits
566                let leaf_ids: Vec<_> = self.split_view_states.keys().copied().collect();
567                for leaf_id in leaf_ids {
568                    if let Some(view_state) = self.split_view_states.get_mut(&leaf_id) {
569                        view_state.highlight_current_line =
570                            self.config.editor.highlight_current_line;
571                    }
572                }
573
574                let state = if self.config.editor.highlight_current_line {
575                    t!("view.state_enabled").to_string()
576                } else {
577                    t!("view.state_disabled").to_string()
578                };
579                self.set_status_message(
580                    t!("view.current_line_highlight_state", state = state).to_string(),
581                );
582            }
583            Action::ToggleReadOnly => {
584                let buffer_id = self.active_buffer();
585                let is_now_read_only = self
586                    .buffer_metadata
587                    .get(&buffer_id)
588                    .map(|m| !m.read_only)
589                    .unwrap_or(false);
590                self.mark_buffer_read_only(buffer_id, is_now_read_only);
591
592                let state_str = if is_now_read_only {
593                    t!("view.state_enabled").to_string()
594                } else {
595                    t!("view.state_disabled").to_string()
596                };
597                self.set_status_message(t!("view.read_only_state", state = state_str).to_string());
598            }
599            Action::TogglePageView => {
600                self.handle_toggle_page_view();
601            }
602            Action::SetPageWidth => {
603                let active_split = self.split_manager.active_split();
604                let current = self
605                    .split_view_states
606                    .get(&active_split)
607                    .and_then(|v| v.compose_width.map(|w| w.to_string()))
608                    .unwrap_or_default();
609                self.start_prompt_with_initial_text(
610                    "Page width (empty = viewport): ".to_string(),
611                    PromptType::SetPageWidth,
612                    current,
613                );
614            }
615            Action::SetBackground => {
616                let default_path = self
617                    .ansi_background_path
618                    .as_ref()
619                    .and_then(|p| {
620                        p.strip_prefix(&self.working_dir)
621                            .ok()
622                            .map(|rel| rel.to_string_lossy().to_string())
623                    })
624                    .unwrap_or_else(|| DEFAULT_BACKGROUND_FILE.to_string());
625
626                self.start_prompt_with_initial_text(
627                    "Background file: ".to_string(),
628                    PromptType::SetBackgroundFile,
629                    default_path,
630                );
631            }
632            Action::SetBackgroundBlend => {
633                let default_amount = format!("{:.2}", self.background_fade);
634                self.start_prompt_with_initial_text(
635                    "Background blend (0-1): ".to_string(),
636                    PromptType::SetBackgroundBlend,
637                    default_amount,
638                );
639            }
640            Action::LspCompletion => {
641                self.request_completion();
642            }
643            Action::DabbrevExpand => {
644                self.dabbrev_expand();
645            }
646            Action::LspGotoDefinition => {
647                self.request_goto_definition()?;
648            }
649            Action::LspRename => {
650                self.start_rename()?;
651            }
652            Action::LspHover => {
653                self.request_hover()?;
654            }
655            Action::LspReferences => {
656                self.request_references()?;
657            }
658            Action::LspSignatureHelp => {
659                self.request_signature_help();
660            }
661            Action::LspCodeActions => {
662                self.request_code_actions()?;
663            }
664            Action::LspRestart => {
665                self.handle_lsp_restart();
666            }
667            Action::LspStop => {
668                self.handle_lsp_stop();
669            }
670            Action::LspToggleForBuffer => {
671                self.handle_lsp_toggle_for_buffer();
672            }
673            Action::ToggleInlayHints => {
674                self.toggle_inlay_hints();
675            }
676            Action::DumpConfig => {
677                self.dump_config();
678            }
679            Action::SelectTheme => {
680                self.start_select_theme_prompt();
681            }
682            Action::InspectThemeAtCursor => {
683                self.inspect_theme_at_cursor();
684            }
685            Action::SelectKeybindingMap => {
686                self.start_select_keybinding_map_prompt();
687            }
688            Action::SelectCursorStyle => {
689                self.start_select_cursor_style_prompt();
690            }
691            Action::SelectLocale => {
692                self.start_select_locale_prompt();
693            }
694            Action::Search => {
695                // If already in a search-related prompt, Ctrl+F acts like Enter (confirm search)
696                let is_search_prompt = self.prompt.as_ref().is_some_and(|p| {
697                    matches!(
698                        p.prompt_type,
699                        PromptType::Search
700                            | PromptType::ReplaceSearch
701                            | PromptType::QueryReplaceSearch
702                    )
703                });
704
705                if is_search_prompt {
706                    self.confirm_prompt();
707                } else {
708                    self.start_search_prompt(
709                        t!("file.search_prompt").to_string(),
710                        PromptType::Search,
711                        false,
712                    );
713                }
714            }
715            Action::Replace => {
716                // Use same flow as query-replace, just with confirm_each defaulting to false
717                self.start_search_prompt(
718                    t!("file.replace_prompt").to_string(),
719                    PromptType::ReplaceSearch,
720                    false,
721                );
722            }
723            Action::QueryReplace => {
724                // Enable confirm mode by default for query-replace
725                self.search_confirm_each = true;
726                self.start_search_prompt(
727                    "Query replace: ".to_string(),
728                    PromptType::QueryReplaceSearch,
729                    false,
730                );
731            }
732            Action::FindInSelection => {
733                self.start_search_prompt(
734                    t!("file.search_prompt").to_string(),
735                    PromptType::Search,
736                    true,
737                );
738            }
739            Action::FindNext => {
740                self.find_next();
741            }
742            Action::FindPrevious => {
743                self.find_previous();
744            }
745            Action::FindSelectionNext => {
746                self.find_selection_next();
747            }
748            Action::FindSelectionPrevious => {
749                self.find_selection_previous();
750            }
751            Action::AddCursorNextMatch => self.add_cursor_at_next_match(),
752            Action::AddCursorAbove => self.add_cursor_above(),
753            Action::AddCursorBelow => self.add_cursor_below(),
754            Action::NextBuffer => self.next_buffer(),
755            Action::PrevBuffer => self.prev_buffer(),
756            Action::SwitchToPreviousTab => self.switch_to_previous_tab(),
757            Action::SwitchToTabByName => self.start_switch_to_tab_prompt(),
758
759            // Tab scrolling (manual scroll - don't auto-adjust)
760            Action::ScrollTabsLeft => {
761                let active_split_id = self.split_manager.active_split();
762                if let Some(view_state) = self.split_view_states.get_mut(&active_split_id) {
763                    view_state.tab_scroll_offset = view_state.tab_scroll_offset.saturating_sub(5);
764                    self.set_status_message(t!("status.scrolled_tabs_left").to_string());
765                }
766            }
767            Action::ScrollTabsRight => {
768                let active_split_id = self.split_manager.active_split();
769                if let Some(view_state) = self.split_view_states.get_mut(&active_split_id) {
770                    view_state.tab_scroll_offset = view_state.tab_scroll_offset.saturating_add(5);
771                    self.set_status_message(t!("status.scrolled_tabs_right").to_string());
772                }
773            }
774            Action::NavigateBack => self.navigate_back(),
775            Action::NavigateForward => self.navigate_forward(),
776            Action::SplitHorizontal => self.split_pane_horizontal(),
777            Action::SplitVertical => self.split_pane_vertical(),
778            Action::CloseSplit => self.close_active_split(),
779            Action::NextSplit => self.next_split(),
780            Action::PrevSplit => self.prev_split(),
781            Action::IncreaseSplitSize => self.adjust_split_size(0.05),
782            Action::DecreaseSplitSize => self.adjust_split_size(-0.05),
783            Action::ToggleMaximizeSplit => self.toggle_maximize_split(),
784            Action::ToggleFileExplorer => self.toggle_file_explorer(),
785            Action::ToggleMenuBar => self.toggle_menu_bar(),
786            Action::ToggleTabBar => self.toggle_tab_bar(),
787            Action::ToggleStatusBar => self.toggle_status_bar(),
788            Action::TogglePromptLine => self.toggle_prompt_line(),
789            Action::ToggleVerticalScrollbar => self.toggle_vertical_scrollbar(),
790            Action::ToggleHorizontalScrollbar => self.toggle_horizontal_scrollbar(),
791            Action::ToggleLineNumbers => self.toggle_line_numbers(),
792            Action::ToggleScrollSync => self.toggle_scroll_sync(),
793            Action::ToggleMouseCapture => self.toggle_mouse_capture(),
794            Action::ToggleMouseHover => self.toggle_mouse_hover(),
795            Action::ToggleDebugHighlights => self.toggle_debug_highlights(),
796            // Rulers
797            Action::AddRuler => {
798                self.start_prompt(t!("rulers.add_prompt").to_string(), PromptType::AddRuler);
799            }
800            Action::RemoveRuler => {
801                self.start_remove_ruler_prompt();
802            }
803            // Buffer settings
804            Action::SetTabSize => {
805                let current = self
806                    .buffers
807                    .get(&self.active_buffer())
808                    .map(|s| s.buffer_settings.tab_size.to_string())
809                    .unwrap_or_else(|| "4".to_string());
810                self.start_prompt_with_initial_text(
811                    "Tab size: ".to_string(),
812                    PromptType::SetTabSize,
813                    current,
814                );
815            }
816            Action::SetLineEnding => {
817                self.start_set_line_ending_prompt();
818            }
819            Action::SetEncoding => {
820                self.start_set_encoding_prompt();
821            }
822            Action::ReloadWithEncoding => {
823                self.start_reload_with_encoding_prompt();
824            }
825            Action::SetLanguage => {
826                self.start_set_language_prompt();
827            }
828            Action::ToggleIndentationStyle => {
829                if let Some(state) = self.buffers.get_mut(&self.active_buffer()) {
830                    state.buffer_settings.use_tabs = !state.buffer_settings.use_tabs;
831                    let status = if state.buffer_settings.use_tabs {
832                        "Indentation: Tabs"
833                    } else {
834                        "Indentation: Spaces"
835                    };
836                    self.set_status_message(status.to_string());
837                }
838            }
839            Action::ToggleTabIndicators | Action::ToggleWhitespaceIndicators => {
840                if let Some(state) = self.buffers.get_mut(&self.active_buffer()) {
841                    state.buffer_settings.whitespace.toggle_all();
842                    let status = if state.buffer_settings.whitespace.any_visible() {
843                        t!("toggle.whitespace_indicators_shown")
844                    } else {
845                        t!("toggle.whitespace_indicators_hidden")
846                    };
847                    self.set_status_message(status.to_string());
848                }
849            }
850            Action::ResetBufferSettings => self.reset_buffer_settings(),
851            Action::FocusFileExplorer => self.focus_file_explorer(),
852            Action::FocusEditor => self.focus_editor(),
853            Action::FileExplorerUp => self.file_explorer_navigate_up(),
854            Action::FileExplorerDown => self.file_explorer_navigate_down(),
855            Action::FileExplorerPageUp => self.file_explorer_page_up(),
856            Action::FileExplorerPageDown => self.file_explorer_page_down(),
857            Action::FileExplorerExpand => self.file_explorer_toggle_expand(),
858            Action::FileExplorerCollapse => self.file_explorer_collapse(),
859            Action::FileExplorerOpen => self.file_explorer_open_file()?,
860            Action::FileExplorerRefresh => self.file_explorer_refresh(),
861            Action::FileExplorerNewFile => self.file_explorer_new_file(),
862            Action::FileExplorerNewDirectory => self.file_explorer_new_directory(),
863            Action::FileExplorerDelete => self.file_explorer_delete(),
864            Action::FileExplorerRename => self.file_explorer_rename(),
865            Action::FileExplorerToggleHidden => self.file_explorer_toggle_hidden(),
866            Action::FileExplorerToggleGitignored => self.file_explorer_toggle_gitignored(),
867            Action::FileExplorerSearchClear => self.file_explorer_search_clear(),
868            Action::FileExplorerSearchBackspace => self.file_explorer_search_pop_char(),
869            Action::RemoveSecondaryCursors => {
870                // Convert action to events and apply them
871                if let Some(events) = self.action_to_events(Action::RemoveSecondaryCursors) {
872                    // Wrap in batch for atomic undo
873                    let batch = Event::Batch {
874                        events: events.clone(),
875                        description: "Remove secondary cursors".to_string(),
876                    };
877                    self.active_event_log_mut().append(batch.clone());
878                    self.apply_event_to_active_buffer(&batch);
879
880                    // Ensure the primary cursor is visible after removing secondary cursors
881                    let active_split = self.split_manager.active_split();
882                    let active_buffer = self.active_buffer();
883                    if let Some(view_state) = self.split_view_states.get_mut(&active_split) {
884                        let state = self.buffers.get_mut(&active_buffer).unwrap();
885                        view_state.ensure_cursor_visible(&mut state.buffer, &state.marker_list);
886                    }
887                }
888            }
889
890            // Menu navigation actions
891            Action::MenuActivate => {
892                self.handle_menu_activate();
893            }
894            Action::MenuClose => {
895                self.handle_menu_close();
896            }
897            Action::MenuLeft => {
898                self.handle_menu_left();
899            }
900            Action::MenuRight => {
901                self.handle_menu_right();
902            }
903            Action::MenuUp => {
904                self.handle_menu_up();
905            }
906            Action::MenuDown => {
907                self.handle_menu_down();
908            }
909            Action::MenuExecute => {
910                if let Some(action) = self.handle_menu_execute() {
911                    return self.handle_action(action);
912                }
913            }
914            Action::MenuOpen(menu_name) => {
915                if self.config.editor.menu_bar_mnemonics {
916                    self.handle_menu_open(&menu_name);
917                }
918            }
919
920            Action::SwitchKeybindingMap(map_name) => {
921                // Check if the map exists (either built-in or user-defined)
922                let is_builtin =
923                    matches!(map_name.as_str(), "default" | "emacs" | "vscode" | "macos");
924                let is_user_defined = self.config.keybinding_maps.contains_key(&map_name);
925
926                if is_builtin || is_user_defined {
927                    // Update the active keybinding map in config
928                    self.config.active_keybinding_map = map_name.clone().into();
929
930                    // Reload the keybinding resolver with the new map
931                    *self.keybindings.write().unwrap() =
932                        crate::input::keybindings::KeybindingResolver::new(&self.config);
933
934                    self.set_status_message(
935                        t!("view.keybindings_switched", map = map_name).to_string(),
936                    );
937                } else {
938                    self.set_status_message(
939                        t!("view.keybindings_unknown", map = map_name).to_string(),
940                    );
941                }
942            }
943
944            Action::SmartHome => {
945                // In composite (diff) views, use LineStart movement
946                let buffer_id = self.active_buffer();
947                if self.is_composite_buffer(buffer_id) {
948                    if let Some(_handled) =
949                        self.handle_composite_action(buffer_id, &Action::SmartHome)
950                    {
951                        return Ok(());
952                    }
953                }
954                self.smart_home();
955            }
956            Action::ToggleComment => {
957                self.toggle_comment();
958            }
959            Action::ToggleFold => {
960                self.toggle_fold_at_cursor();
961            }
962            Action::GoToMatchingBracket => {
963                self.goto_matching_bracket();
964            }
965            Action::JumpToNextError => {
966                self.jump_to_next_error();
967            }
968            Action::JumpToPreviousError => {
969                self.jump_to_previous_error();
970            }
971            Action::SetBookmark(key) => {
972                self.set_bookmark(key);
973            }
974            Action::JumpToBookmark(key) => {
975                self.jump_to_bookmark(key);
976            }
977            Action::ClearBookmark(key) => {
978                self.clear_bookmark(key);
979            }
980            Action::ListBookmarks => {
981                self.list_bookmarks();
982            }
983            Action::ToggleSearchCaseSensitive => {
984                self.search_case_sensitive = !self.search_case_sensitive;
985                let state = if self.search_case_sensitive {
986                    "enabled"
987                } else {
988                    "disabled"
989                };
990                self.set_status_message(
991                    t!("search.case_sensitive_state", state = state).to_string(),
992                );
993                // Update incremental highlights if in search prompt, otherwise re-run completed search
994                // Check prompt FIRST since we want to use current prompt input, not stale search_state
995                if let Some(prompt) = &self.prompt {
996                    if matches!(
997                        prompt.prompt_type,
998                        PromptType::Search
999                            | PromptType::ReplaceSearch
1000                            | PromptType::QueryReplaceSearch
1001                    ) {
1002                        let query = prompt.input.clone();
1003                        self.update_search_highlights(&query);
1004                    }
1005                } else if let Some(search_state) = &self.search_state {
1006                    let query = search_state.query.clone();
1007                    self.perform_search(&query);
1008                }
1009            }
1010            Action::ToggleSearchWholeWord => {
1011                self.search_whole_word = !self.search_whole_word;
1012                let state = if self.search_whole_word {
1013                    "enabled"
1014                } else {
1015                    "disabled"
1016                };
1017                self.set_status_message(t!("search.whole_word_state", state = state).to_string());
1018                // Update incremental highlights if in search prompt, otherwise re-run completed search
1019                // Check prompt FIRST since we want to use current prompt input, not stale search_state
1020                if let Some(prompt) = &self.prompt {
1021                    if matches!(
1022                        prompt.prompt_type,
1023                        PromptType::Search
1024                            | PromptType::ReplaceSearch
1025                            | PromptType::QueryReplaceSearch
1026                    ) {
1027                        let query = prompt.input.clone();
1028                        self.update_search_highlights(&query);
1029                    }
1030                } else if let Some(search_state) = &self.search_state {
1031                    let query = search_state.query.clone();
1032                    self.perform_search(&query);
1033                }
1034            }
1035            Action::ToggleSearchRegex => {
1036                self.search_use_regex = !self.search_use_regex;
1037                let state = if self.search_use_regex {
1038                    "enabled"
1039                } else {
1040                    "disabled"
1041                };
1042                self.set_status_message(t!("search.regex_state", state = state).to_string());
1043                // Update incremental highlights if in search prompt, otherwise re-run completed search
1044                // Check prompt FIRST since we want to use current prompt input, not stale search_state
1045                if let Some(prompt) = &self.prompt {
1046                    if matches!(
1047                        prompt.prompt_type,
1048                        PromptType::Search
1049                            | PromptType::ReplaceSearch
1050                            | PromptType::QueryReplaceSearch
1051                    ) {
1052                        let query = prompt.input.clone();
1053                        self.update_search_highlights(&query);
1054                    }
1055                } else if let Some(search_state) = &self.search_state {
1056                    let query = search_state.query.clone();
1057                    self.perform_search(&query);
1058                }
1059            }
1060            Action::ToggleSearchConfirmEach => {
1061                self.search_confirm_each = !self.search_confirm_each;
1062                let state = if self.search_confirm_each {
1063                    "enabled"
1064                } else {
1065                    "disabled"
1066                };
1067                self.set_status_message(t!("search.confirm_each_state", state = state).to_string());
1068            }
1069            Action::FileBrowserToggleHidden => {
1070                // Toggle hidden files in file browser (handled via file_open_toggle_hidden)
1071                self.file_open_toggle_hidden();
1072            }
1073            Action::StartMacroRecording => {
1074                // This is a no-op; use ToggleMacroRecording instead
1075                self.set_status_message(
1076                    "Use Ctrl+Shift+R to start recording (will prompt for register)".to_string(),
1077                );
1078            }
1079            Action::StopMacroRecording => {
1080                self.stop_macro_recording();
1081            }
1082            Action::PlayMacro(key) => {
1083                self.play_macro(key);
1084            }
1085            Action::ToggleMacroRecording(key) => {
1086                self.toggle_macro_recording(key);
1087            }
1088            Action::ShowMacro(key) => {
1089                self.show_macro_in_buffer(key);
1090            }
1091            Action::ListMacros => {
1092                self.list_macros_in_buffer();
1093            }
1094            Action::PromptRecordMacro => {
1095                self.start_prompt("Record macro (0-9): ".to_string(), PromptType::RecordMacro);
1096            }
1097            Action::PromptPlayMacro => {
1098                self.start_prompt("Play macro (0-9): ".to_string(), PromptType::PlayMacro);
1099            }
1100            Action::PlayLastMacro => {
1101                if let Some(key) = self.last_macro_register {
1102                    self.play_macro(key);
1103                } else {
1104                    self.set_status_message(t!("status.no_macro_recorded").to_string());
1105                }
1106            }
1107            Action::PromptSetBookmark => {
1108                self.start_prompt("Set bookmark (0-9): ".to_string(), PromptType::SetBookmark);
1109            }
1110            Action::PromptJumpToBookmark => {
1111                self.start_prompt(
1112                    "Jump to bookmark (0-9): ".to_string(),
1113                    PromptType::JumpToBookmark,
1114                );
1115            }
1116            Action::CompositeNextHunk => {
1117                let buf = self.active_buffer();
1118                self.composite_next_hunk_active(buf);
1119            }
1120            Action::CompositePrevHunk => {
1121                let buf = self.active_buffer();
1122                self.composite_prev_hunk_active(buf);
1123            }
1124            Action::None => {}
1125            Action::DeleteBackward => {
1126                if self.is_editing_disabled() {
1127                    self.set_status_message(t!("buffer.editing_disabled").to_string());
1128                    return Ok(());
1129                }
1130                // Normal backspace handling
1131                if let Some(events) = self.action_to_events(Action::DeleteBackward) {
1132                    if events.len() > 1 {
1133                        // Multi-cursor: use optimized bulk edit (O(n) instead of O(n²))
1134                        let description = "Delete backward".to_string();
1135                        if let Some(bulk_edit) = self.apply_events_as_bulk_edit(events, description)
1136                        {
1137                            self.active_event_log_mut().append(bulk_edit);
1138                        }
1139                    } else {
1140                        for event in events {
1141                            self.active_event_log_mut().append(event.clone());
1142                            self.apply_event_to_active_buffer(&event);
1143                        }
1144                    }
1145                }
1146            }
1147            Action::PluginAction(action_name) => {
1148                tracing::debug!("handle_action: PluginAction('{}')", action_name);
1149                // Execute the plugin callback via TypeScript plugin thread
1150                // Use non-blocking version to avoid deadlock with async plugin ops
1151                #[cfg(feature = "plugins")]
1152                if let Some(result) = self.plugin_manager.execute_action_async(&action_name) {
1153                    match result {
1154                        Ok(receiver) => {
1155                            // Store pending action for processing in main loop
1156                            self.pending_plugin_actions
1157                                .push((action_name.clone(), receiver));
1158                        }
1159                        Err(e) => {
1160                            self.set_status_message(
1161                                t!("view.plugin_error", error = e.to_string()).to_string(),
1162                            );
1163                            tracing::error!("Plugin action error: {}", e);
1164                        }
1165                    }
1166                } else {
1167                    self.set_status_message(t!("status.plugin_manager_unavailable").to_string());
1168                }
1169                #[cfg(not(feature = "plugins"))]
1170                {
1171                    let _ = action_name;
1172                    self.set_status_message(
1173                        "Plugins not available (compiled without plugin support)".to_string(),
1174                    );
1175                }
1176            }
1177            Action::LoadPluginFromBuffer => {
1178                #[cfg(feature = "plugins")]
1179                {
1180                    let buffer_id = self.active_buffer();
1181                    let state = self.active_state();
1182                    let buffer = &state.buffer;
1183                    let total = buffer.total_bytes();
1184                    let content =
1185                        String::from_utf8_lossy(&buffer.slice_bytes(0..total)).to_string();
1186
1187                    // Determine if TypeScript from file extension, default to TS
1188                    let is_ts = buffer
1189                        .file_path()
1190                        .and_then(|p| p.extension())
1191                        .and_then(|e| e.to_str())
1192                        .map(|e| e == "ts" || e == "tsx")
1193                        .unwrap_or(true);
1194
1195                    // Derive plugin name from buffer filename
1196                    let name = buffer
1197                        .file_path()
1198                        .and_then(|p| p.file_name())
1199                        .and_then(|s| s.to_str())
1200                        .map(|s| s.to_string())
1201                        .unwrap_or_else(|| "buffer-plugin".to_string());
1202
1203                    match self
1204                        .plugin_manager
1205                        .load_plugin_from_source(&content, &name, is_ts)
1206                    {
1207                        Ok(()) => {
1208                            self.set_status_message(format!(
1209                                "Plugin '{}' loaded from buffer",
1210                                name
1211                            ));
1212                        }
1213                        Err(e) => {
1214                            self.set_status_message(format!("Failed to load plugin: {}", e));
1215                            tracing::error!("LoadPluginFromBuffer error: {}", e);
1216                        }
1217                    }
1218
1219                    // Set up plugin dev workspace for LSP support
1220                    self.setup_plugin_dev_lsp(buffer_id, &content);
1221                }
1222                #[cfg(not(feature = "plugins"))]
1223                {
1224                    self.set_status_message(
1225                        "Plugins not available (compiled without plugin support)".to_string(),
1226                    );
1227                }
1228            }
1229            Action::OpenTerminal => {
1230                self.open_terminal();
1231            }
1232            Action::CloseTerminal => {
1233                self.close_terminal();
1234            }
1235            Action::FocusTerminal => {
1236                // If viewing a terminal buffer, switch to terminal mode
1237                if self.is_terminal_buffer(self.active_buffer()) {
1238                    self.terminal_mode = true;
1239                    self.key_context = KeyContext::Terminal;
1240                    self.set_status_message(t!("status.terminal_mode_enabled").to_string());
1241                }
1242            }
1243            Action::TerminalEscape => {
1244                // Exit terminal mode back to editor
1245                if self.terminal_mode {
1246                    self.terminal_mode = false;
1247                    self.key_context = KeyContext::Normal;
1248                    self.set_status_message(t!("status.terminal_mode_disabled").to_string());
1249                }
1250            }
1251            Action::ToggleKeyboardCapture => {
1252                // Toggle keyboard capture mode in terminal
1253                if self.terminal_mode {
1254                    self.keyboard_capture = !self.keyboard_capture;
1255                    if self.keyboard_capture {
1256                        self.set_status_message(
1257                            "Keyboard capture ON - all keys go to terminal (F9 to toggle)"
1258                                .to_string(),
1259                        );
1260                    } else {
1261                        self.set_status_message(
1262                            "Keyboard capture OFF - UI bindings active (F9 to toggle)".to_string(),
1263                        );
1264                    }
1265                }
1266            }
1267            Action::TerminalPaste => {
1268                // Paste clipboard contents into terminal as a single batch
1269                if self.terminal_mode {
1270                    if let Some(text) = self.clipboard.paste() {
1271                        self.send_terminal_input(text.as_bytes());
1272                    }
1273                }
1274            }
1275            Action::ShellCommand => {
1276                // Run shell command on buffer/selection, output to new buffer
1277                self.start_shell_command_prompt(false);
1278            }
1279            Action::ShellCommandReplace => {
1280                // Run shell command on buffer/selection, replace content
1281                self.start_shell_command_prompt(true);
1282            }
1283            Action::OpenSettings => {
1284                self.open_settings();
1285            }
1286            Action::CloseSettings => {
1287                // Check if there are unsaved changes
1288                let has_changes = self
1289                    .settings_state
1290                    .as_ref()
1291                    .is_some_and(|s| s.has_changes());
1292                if has_changes {
1293                    // Show confirmation dialog
1294                    if let Some(ref mut state) = self.settings_state {
1295                        state.show_confirm_dialog();
1296                    }
1297                } else {
1298                    self.close_settings(false);
1299                }
1300            }
1301            Action::SettingsSave => {
1302                self.save_settings();
1303            }
1304            Action::SettingsReset => {
1305                if let Some(ref mut state) = self.settings_state {
1306                    state.reset_current_to_default();
1307                }
1308            }
1309            Action::SettingsInherit => {
1310                if let Some(ref mut state) = self.settings_state {
1311                    state.set_current_to_null();
1312                }
1313            }
1314            Action::SettingsToggleFocus => {
1315                if let Some(ref mut state) = self.settings_state {
1316                    state.toggle_focus();
1317                }
1318            }
1319            Action::SettingsActivate => {
1320                self.settings_activate_current();
1321            }
1322            Action::SettingsSearch => {
1323                if let Some(ref mut state) = self.settings_state {
1324                    state.start_search();
1325                }
1326            }
1327            Action::SettingsHelp => {
1328                if let Some(ref mut state) = self.settings_state {
1329                    state.toggle_help();
1330                }
1331            }
1332            Action::SettingsIncrement => {
1333                self.settings_increment_current();
1334            }
1335            Action::SettingsDecrement => {
1336                self.settings_decrement_current();
1337            }
1338            Action::CalibrateInput => {
1339                self.open_calibration_wizard();
1340            }
1341            Action::EventDebug => {
1342                self.open_event_debug();
1343            }
1344            Action::OpenKeybindingEditor => {
1345                self.open_keybinding_editor();
1346            }
1347            Action::PromptConfirm => {
1348                if let Some((input, prompt_type, selected_index)) = self.confirm_prompt() {
1349                    use super::prompt_actions::PromptResult;
1350                    match self.handle_prompt_confirm_input(input, prompt_type, selected_index) {
1351                        PromptResult::ExecuteAction(action) => {
1352                            return self.handle_action(action);
1353                        }
1354                        PromptResult::EarlyReturn => {
1355                            return Ok(());
1356                        }
1357                        PromptResult::Done => {}
1358                    }
1359                }
1360            }
1361            Action::PromptConfirmWithText(ref text) => {
1362                // For macro playback: set the prompt text before confirming
1363                if let Some(ref mut prompt) = self.prompt {
1364                    prompt.set_input(text.clone());
1365                    self.update_prompt_suggestions();
1366                }
1367                if let Some((input, prompt_type, selected_index)) = self.confirm_prompt() {
1368                    use super::prompt_actions::PromptResult;
1369                    match self.handle_prompt_confirm_input(input, prompt_type, selected_index) {
1370                        PromptResult::ExecuteAction(action) => {
1371                            return self.handle_action(action);
1372                        }
1373                        PromptResult::EarlyReturn => {
1374                            return Ok(());
1375                        }
1376                        PromptResult::Done => {}
1377                    }
1378                }
1379            }
1380            Action::PopupConfirm => {
1381                use super::popup_actions::PopupConfirmResult;
1382                if let PopupConfirmResult::EarlyReturn = self.handle_popup_confirm() {
1383                    return Ok(());
1384                }
1385            }
1386            Action::PopupCancel => {
1387                self.handle_popup_cancel();
1388            }
1389            Action::InsertChar(c) => {
1390                if self.is_prompting() {
1391                    return self.handle_insert_char_prompt(c);
1392                } else if self.key_context == KeyContext::FileExplorer {
1393                    self.file_explorer_search_push_char(c);
1394                } else {
1395                    self.handle_insert_char_editor(c)?;
1396                }
1397            }
1398            // Prompt clipboard actions
1399            Action::PromptCopy => {
1400                if let Some(prompt) = &self.prompt {
1401                    let text = prompt.selected_text().unwrap_or_else(|| prompt.get_text());
1402                    if !text.is_empty() {
1403                        self.clipboard.copy(text);
1404                        self.set_status_message(t!("clipboard.copied").to_string());
1405                    }
1406                }
1407            }
1408            Action::PromptCut => {
1409                if let Some(prompt) = &self.prompt {
1410                    let text = prompt.selected_text().unwrap_or_else(|| prompt.get_text());
1411                    if !text.is_empty() {
1412                        self.clipboard.copy(text);
1413                    }
1414                }
1415                if let Some(prompt) = self.prompt.as_mut() {
1416                    if prompt.has_selection() {
1417                        prompt.delete_selection();
1418                    } else {
1419                        prompt.clear();
1420                    }
1421                }
1422                self.set_status_message(t!("clipboard.cut").to_string());
1423                self.update_prompt_suggestions();
1424            }
1425            Action::PromptPaste => {
1426                if let Some(text) = self.clipboard.paste() {
1427                    if let Some(prompt) = self.prompt.as_mut() {
1428                        prompt.insert_str(&text);
1429                    }
1430                    self.update_prompt_suggestions();
1431                }
1432            }
1433            _ => {
1434                // TODO: Why do we have this catch-all? It seems like actions should either:
1435                // 1. Be handled explicitly above (like InsertChar, PopupConfirm, etc.)
1436                // 2. Or be converted to events consistently
1437                // This catch-all makes it unclear which actions go through event conversion
1438                // vs. direct handling. Consider making this explicit or removing the pattern.
1439                self.apply_action_as_events(action)?;
1440            }
1441        }
1442
1443        Ok(())
1444    }
1445
1446    /// Handle mouse wheel scroll event
1447    pub(super) fn handle_mouse_scroll(
1448        &mut self,
1449        col: u16,
1450        row: u16,
1451        delta: i32,
1452    ) -> AnyhowResult<()> {
1453        // Notify plugins of mouse scroll so they can handle it for virtual buffers
1454        let buffer_id = self.active_buffer();
1455        self.plugin_manager.run_hook(
1456            "mouse_scroll",
1457            fresh_core::hooks::HookArgs::MouseScroll {
1458                buffer_id,
1459                delta,
1460                col,
1461                row,
1462            },
1463        );
1464
1465        // Check if scroll is over the file explorer
1466        if let Some(explorer_area) = self.cached_layout.file_explorer_area {
1467            if col >= explorer_area.x
1468                && col < explorer_area.x + explorer_area.width
1469                && row >= explorer_area.y
1470                && row < explorer_area.y + explorer_area.height
1471            {
1472                // Scroll the file explorer
1473                if let Some(explorer) = &mut self.file_explorer {
1474                    let count = explorer.visible_count();
1475                    if count == 0 {
1476                        return Ok(());
1477                    }
1478
1479                    // Get current selected index
1480                    let current_index = explorer.get_selected_index().unwrap_or(0);
1481
1482                    // Calculate new index based on scroll delta
1483                    let new_index = if delta < 0 {
1484                        // Scroll up (negative delta)
1485                        current_index.saturating_sub(delta.unsigned_abs() as usize)
1486                    } else {
1487                        // Scroll down (positive delta)
1488                        (current_index + delta as usize).min(count - 1)
1489                    };
1490
1491                    // Set the new selection
1492                    if let Some(node_id) = explorer.get_node_at_index(new_index) {
1493                        explorer.set_selected(Some(node_id));
1494                        explorer.update_scroll_for_selection();
1495                    }
1496                }
1497                return Ok(());
1498            }
1499        }
1500
1501        // Scroll the split under the mouse pointer (not necessarily the focused split).
1502        // Fall back to the active split if the pointer isn't over any split area.
1503        let (target_split, buffer_id) = self
1504            .split_at_position(col, row)
1505            .unwrap_or_else(|| (self.split_manager.active_split(), self.active_buffer()));
1506
1507        // Check if this is a composite buffer - if so, use composite scroll
1508        if self.is_composite_buffer(buffer_id) {
1509            let max_row = self
1510                .composite_buffers
1511                .get(&buffer_id)
1512                .map(|c| c.row_count().saturating_sub(1))
1513                .unwrap_or(0);
1514            if let Some(view_state) = self
1515                .composite_view_states
1516                .get_mut(&(target_split, buffer_id))
1517            {
1518                view_state.scroll(delta as isize, max_row);
1519                tracing::trace!(
1520                    "handle_mouse_scroll (composite): delta={}, scroll_row={}",
1521                    delta,
1522                    view_state.scroll_row
1523                );
1524            }
1525            return Ok(());
1526        }
1527
1528        // Get view_transform tokens from SplitViewState (if any)
1529        let view_transform_tokens = self
1530            .split_view_states
1531            .get(&target_split)
1532            .and_then(|vs| vs.view_transform.as_ref())
1533            .map(|vt| vt.tokens.clone());
1534
1535        // Get mutable references to both buffer state and view state
1536        let state = self.buffers.get_mut(&buffer_id);
1537        let view_state = self.split_view_states.get_mut(&target_split);
1538
1539        if let (Some(state), Some(view_state)) = (state, view_state) {
1540            let buffer = &mut state.buffer;
1541            let top_byte_before = view_state.viewport.top_byte;
1542            if let Some(tokens) = view_transform_tokens {
1543                // Use view-aware scrolling with the transform's tokens
1544                use crate::view::ui::view_pipeline::ViewLineIterator;
1545                let tab_size = self.config.editor.tab_size;
1546                let view_lines: Vec<_> =
1547                    ViewLineIterator::new(&tokens, false, false, tab_size, false).collect();
1548                view_state
1549                    .viewport
1550                    .scroll_view_lines(&view_lines, delta as isize);
1551            } else {
1552                // No view transform - use traditional buffer-based scrolling
1553                if delta < 0 {
1554                    // Scroll up
1555                    let lines_to_scroll = delta.unsigned_abs() as usize;
1556                    view_state.viewport.scroll_up(buffer, lines_to_scroll);
1557                } else {
1558                    // Scroll down
1559                    let lines_to_scroll = delta as usize;
1560                    view_state.viewport.scroll_down(buffer, lines_to_scroll);
1561                }
1562            }
1563            // Skip ensure_visible so the scroll position isn't undone during render
1564            view_state.viewport.set_skip_ensure_visible();
1565
1566            if let Some(folds) = view_state.keyed_states.get(&buffer_id).map(|bs| &bs.folds) {
1567                if !folds.is_empty() {
1568                    let top_line = buffer.get_line_number(view_state.viewport.top_byte);
1569                    if let Some(range) = folds
1570                        .resolved_ranges(buffer, &state.marker_list)
1571                        .iter()
1572                        .find(|r| top_line >= r.start_line && top_line <= r.end_line)
1573                    {
1574                        let target_line = if delta >= 0 {
1575                            range.end_line.saturating_add(1)
1576                        } else {
1577                            range.header_line
1578                        };
1579                        let target_byte = buffer
1580                            .line_start_offset(target_line)
1581                            .unwrap_or_else(|| buffer.len());
1582                        view_state.viewport.top_byte = target_byte;
1583                        view_state.viewport.top_view_line_offset = 0;
1584                    }
1585                }
1586            }
1587            tracing::trace!(
1588                "handle_mouse_scroll: delta={}, top_byte {} -> {}",
1589                delta,
1590                top_byte_before,
1591                view_state.viewport.top_byte
1592            );
1593        }
1594
1595        Ok(())
1596    }
1597
1598    /// Handle horizontal scroll (Shift+ScrollWheel or native ScrollLeft/ScrollRight)
1599    pub(super) fn handle_horizontal_scroll(
1600        &mut self,
1601        col: u16,
1602        row: u16,
1603        delta: i32,
1604    ) -> AnyhowResult<()> {
1605        let target_split = self
1606            .split_at_position(col, row)
1607            .map(|(id, _)| id)
1608            .unwrap_or_else(|| self.split_manager.active_split());
1609
1610        if let Some(view_state) = self.split_view_states.get_mut(&target_split) {
1611            // Don't scroll horizontally when line wrap is enabled
1612            if view_state.viewport.line_wrap_enabled {
1613                return Ok(());
1614            }
1615
1616            let columns_to_scroll = delta.unsigned_abs() as usize;
1617            if delta < 0 {
1618                // Scroll left
1619                view_state.viewport.left_column = view_state
1620                    .viewport
1621                    .left_column
1622                    .saturating_sub(columns_to_scroll);
1623            } else {
1624                // Scroll right - clamp to max_line_length_seen
1625                let visible_width = view_state.viewport.width as usize;
1626                let max_scroll = view_state
1627                    .viewport
1628                    .max_line_length_seen
1629                    .saturating_sub(visible_width);
1630                let new_left = view_state
1631                    .viewport
1632                    .left_column
1633                    .saturating_add(columns_to_scroll);
1634                view_state.viewport.left_column = new_left.min(max_scroll);
1635            }
1636            // Skip ensure_visible so the scroll position isn't undone during render
1637            view_state.viewport.set_skip_ensure_visible();
1638        }
1639
1640        Ok(())
1641    }
1642
1643    /// Handle scrollbar drag with relative movement (when dragging from thumb)
1644    pub(super) fn handle_scrollbar_drag_relative(
1645        &mut self,
1646        row: u16,
1647        split_id: LeafId,
1648        buffer_id: BufferId,
1649        scrollbar_rect: ratatui::layout::Rect,
1650    ) -> AnyhowResult<()> {
1651        let drag_start_row = match self.mouse_state.drag_start_row {
1652            Some(r) => r,
1653            None => return Ok(()), // No drag start, shouldn't happen
1654        };
1655
1656        // Handle composite buffers - use row-based scrolling
1657        if self.is_composite_buffer(buffer_id) {
1658            return self.handle_composite_scrollbar_drag_relative(
1659                row,
1660                drag_start_row,
1661                split_id,
1662                buffer_id,
1663                scrollbar_rect,
1664            );
1665        }
1666
1667        let drag_start_top_byte = match self.mouse_state.drag_start_top_byte {
1668            Some(b) => b,
1669            None => return Ok(()), // No drag start, shouldn't happen
1670        };
1671
1672        let drag_start_view_line_offset = self.mouse_state.drag_start_view_line_offset.unwrap_or(0);
1673
1674        // Calculate the offset in rows (still used for large files)
1675        let row_offset = (row as i32) - (drag_start_row as i32);
1676
1677        // Get viewport height from SplitViewState
1678        let viewport_height = self
1679            .split_view_states
1680            .get(&split_id)
1681            .map(|vs| vs.viewport.height as usize)
1682            .unwrap_or(10);
1683
1684        // Check if line wrapping is enabled
1685        let line_wrap_enabled = self
1686            .split_view_states
1687            .get(&split_id)
1688            .map(|vs| vs.viewport.line_wrap_enabled)
1689            .unwrap_or(false);
1690
1691        let viewport_width = self
1692            .split_view_states
1693            .get(&split_id)
1694            .map(|vs| vs.viewport.width as usize)
1695            .unwrap_or(80);
1696
1697        // Get the buffer state and calculate target position using RELATIVE movement
1698        // Returns (byte_position, view_line_offset) for proper positioning within wrapped lines
1699        let scroll_position = if let Some(state) = self.buffers.get_mut(&buffer_id) {
1700            let scrollbar_height = scrollbar_rect.height as usize;
1701            if scrollbar_height == 0 {
1702                return Ok(());
1703            }
1704
1705            let buffer_len = state.buffer.len();
1706            let large_file_threshold = self.config.editor.large_file_threshold_bytes as usize;
1707
1708            // Use relative movement: calculate scroll change based on row_offset from drag start
1709            if buffer_len <= large_file_threshold {
1710                // When line wrapping is enabled, use visual row calculations
1711                if line_wrap_enabled {
1712                    Self::calculate_scrollbar_drag_relative_visual(
1713                        &mut state.buffer,
1714                        row,
1715                        scrollbar_rect.y,
1716                        scrollbar_height,
1717                        drag_start_row,
1718                        drag_start_top_byte,
1719                        drag_start_view_line_offset,
1720                        viewport_height,
1721                        viewport_width,
1722                    )
1723                } else {
1724                    // Small file without line wrap: thumb follows mouse
1725                    let total_lines = if buffer_len > 0 {
1726                        state.buffer.get_line_number(buffer_len.saturating_sub(1)) + 1
1727                    } else {
1728                        1
1729                    };
1730
1731                    let max_scroll_line = total_lines.saturating_sub(viewport_height);
1732
1733                    if max_scroll_line == 0 || scrollbar_height <= 1 {
1734                        // File fits in viewport, no scrolling
1735                        (0, 0)
1736                    } else {
1737                        // Find the starting line number from drag_start_top_byte
1738                        let start_line = state.buffer.get_line_number(drag_start_top_byte);
1739
1740                        // Calculate thumb size (same formula as scrollbar rendering)
1741                        let thumb_size_raw = (viewport_height as f64 / total_lines as f64
1742                            * scrollbar_height as f64)
1743                            .ceil() as usize;
1744                        let max_thumb_size = (scrollbar_height as f64 * 0.8).floor() as usize;
1745                        let thumb_size = thumb_size_raw
1746                            .max(1)
1747                            .min(max_thumb_size)
1748                            .min(scrollbar_height);
1749
1750                        // Calculate max thumb start position (same as scrollbar rendering)
1751                        let max_thumb_start = scrollbar_height.saturating_sub(thumb_size);
1752
1753                        if max_thumb_start == 0 {
1754                            // Thumb fills the track, no dragging possible
1755                            (0, 0)
1756                        } else {
1757                            // Calculate where the thumb was at drag start
1758                            let start_scroll_ratio =
1759                                start_line.min(max_scroll_line) as f64 / max_scroll_line as f64;
1760                            let thumb_row_at_start = scrollbar_rect.y as f64
1761                                + start_scroll_ratio * max_thumb_start as f64;
1762
1763                            // Calculate click offset (where on thumb we clicked)
1764                            let click_offset = drag_start_row as f64 - thumb_row_at_start;
1765
1766                            // Target thumb position based on current mouse position
1767                            let target_thumb_row = row as f64 - click_offset;
1768
1769                            // Map target thumb position to scroll ratio
1770                            let target_scroll_ratio = ((target_thumb_row
1771                                - scrollbar_rect.y as f64)
1772                                / max_thumb_start as f64)
1773                                .clamp(0.0, 1.0);
1774
1775                            // Map scroll ratio to target line
1776                            let target_line =
1777                                (target_scroll_ratio * max_scroll_line as f64).round() as usize;
1778                            let target_line = target_line.min(max_scroll_line);
1779
1780                            // Find byte position of target line
1781                            let target_byte = state
1782                                .buffer
1783                                .line_start_offset(target_line)
1784                                .unwrap_or(drag_start_top_byte);
1785
1786                            (target_byte, 0)
1787                        }
1788                    }
1789                }
1790            } else {
1791                // Large file: use byte-based relative movement
1792                let bytes_per_pixel = buffer_len as f64 / scrollbar_height as f64;
1793                let byte_offset = (row_offset as f64 * bytes_per_pixel) as i64;
1794
1795                let new_top_byte = if byte_offset >= 0 {
1796                    drag_start_top_byte.saturating_add(byte_offset as usize)
1797                } else {
1798                    drag_start_top_byte.saturating_sub((-byte_offset) as usize)
1799                };
1800
1801                // Clamp to valid range using byte-based max (avoid iterating entire buffer)
1802                let new_top_byte = new_top_byte.min(buffer_len.saturating_sub(1));
1803
1804                // Find the line start for this byte position
1805                let iter = state.buffer.line_iterator(new_top_byte, 80);
1806                (iter.current_position(), 0)
1807            }
1808        } else {
1809            return Ok(());
1810        };
1811
1812        // Set viewport top to this position in SplitViewState
1813        if let Some(view_state) = self.split_view_states.get_mut(&split_id) {
1814            view_state.viewport.top_byte = scroll_position.0;
1815            view_state.viewport.top_view_line_offset = scroll_position.1;
1816            // Skip ensure_visible so the scroll position isn't undone during render
1817            view_state.viewport.set_skip_ensure_visible();
1818        }
1819
1820        // Move cursor to be visible in the new viewport (after releasing the state borrow)
1821        self.move_cursor_to_visible_area(split_id, buffer_id);
1822
1823        Ok(())
1824    }
1825
1826    /// Handle scrollbar jump (clicking on track or absolute positioning)
1827    pub(super) fn handle_scrollbar_jump(
1828        &mut self,
1829        _col: u16,
1830        row: u16,
1831        split_id: LeafId,
1832        buffer_id: BufferId,
1833        scrollbar_rect: ratatui::layout::Rect,
1834    ) -> AnyhowResult<()> {
1835        // Calculate which line to scroll to based on mouse position
1836        let scrollbar_height = scrollbar_rect.height as usize;
1837        if scrollbar_height == 0 {
1838            return Ok(());
1839        }
1840
1841        // Get relative position in scrollbar (0.0 to 1.0)
1842        // Divide by (height - 1) to map first row to 0.0 and last row to 1.0
1843        let relative_row = row.saturating_sub(scrollbar_rect.y);
1844        let ratio = if scrollbar_height > 1 {
1845            ((relative_row as f64) / ((scrollbar_height - 1) as f64)).clamp(0.0, 1.0)
1846        } else {
1847            0.0
1848        };
1849
1850        // Handle composite buffers - use row-based scrolling
1851        if self.is_composite_buffer(buffer_id) {
1852            return self.handle_composite_scrollbar_jump(
1853                ratio,
1854                split_id,
1855                buffer_id,
1856                scrollbar_rect,
1857            );
1858        }
1859
1860        // Get viewport height from SplitViewState
1861        let viewport_height = self
1862            .split_view_states
1863            .get(&split_id)
1864            .map(|vs| vs.viewport.height as usize)
1865            .unwrap_or(10);
1866
1867        // Check if line wrapping is enabled
1868        let line_wrap_enabled = self
1869            .split_view_states
1870            .get(&split_id)
1871            .map(|vs| vs.viewport.line_wrap_enabled)
1872            .unwrap_or(false);
1873
1874        let viewport_width = self
1875            .split_view_states
1876            .get(&split_id)
1877            .map(|vs| vs.viewport.width as usize)
1878            .unwrap_or(80);
1879
1880        // Get the buffer state and calculate scroll position
1881        // Returns (byte_position, view_line_offset) for proper positioning within wrapped lines
1882        let scroll_position = if let Some(state) = self.buffers.get_mut(&buffer_id) {
1883            let buffer_len = state.buffer.len();
1884            let large_file_threshold = self.config.editor.large_file_threshold_bytes as usize;
1885
1886            // For small files, use precise line-based calculations
1887            // For large files, fall back to byte-based estimation
1888            if buffer_len <= large_file_threshold {
1889                // When line wrapping is enabled, use visual row calculations
1890                if line_wrap_enabled {
1891                    // calculate_scrollbar_jump_visual already handles max scroll limiting
1892                    // and returns both byte position and view line offset
1893                    Self::calculate_scrollbar_jump_visual(
1894                        &mut state.buffer,
1895                        ratio,
1896                        viewport_height,
1897                        viewport_width,
1898                    )
1899                } else {
1900                    // Small file without line wrap: use line-based calculation for precision
1901                    let total_lines = if buffer_len > 0 {
1902                        state.buffer.get_line_number(buffer_len.saturating_sub(1)) + 1
1903                    } else {
1904                        1
1905                    };
1906
1907                    let max_scroll_line = total_lines.saturating_sub(viewport_height);
1908
1909                    let target_byte = if max_scroll_line == 0 {
1910                        // File fits in viewport, no scrolling
1911                        0
1912                    } else {
1913                        // Map ratio to target line
1914                        let target_line = (ratio * max_scroll_line as f64).round() as usize;
1915                        let target_line = target_line.min(max_scroll_line);
1916
1917                        // Find byte position of target line
1918                        // We need to iterate 'target_line' times to skip past lines 0..target_line-1,
1919                        // then one more time to get the position of line 'target_line'
1920                        let mut iter = state.buffer.line_iterator(0, 80);
1921                        let mut line_byte = 0;
1922
1923                        for _ in 0..target_line {
1924                            if let Some((pos, _content)) = iter.next_line() {
1925                                line_byte = pos;
1926                            } else {
1927                                break;
1928                            }
1929                        }
1930
1931                        // Get the position of the target line
1932                        if let Some((pos, _)) = iter.next_line() {
1933                            pos
1934                        } else {
1935                            line_byte // Reached end of buffer
1936                        }
1937                    };
1938
1939                    // Find the line start for this byte position
1940                    let iter = state.buffer.line_iterator(target_byte, 80);
1941                    let line_start = iter.current_position();
1942
1943                    // Apply scroll limiting
1944                    let max_top_byte =
1945                        Self::calculate_max_scroll_position(&mut state.buffer, viewport_height);
1946                    (line_start.min(max_top_byte), 0)
1947                }
1948            } else {
1949                // Large file: use byte-based estimation (original logic)
1950                let target_byte = (buffer_len as f64 * ratio) as usize;
1951                let target_byte = target_byte.min(buffer_len.saturating_sub(1));
1952
1953                // Find the line start for this byte position
1954                let iter = state.buffer.line_iterator(target_byte, 80);
1955                let line_start = iter.current_position();
1956
1957                (line_start.min(buffer_len.saturating_sub(1)), 0)
1958            }
1959        } else {
1960            return Ok(());
1961        };
1962
1963        // Set viewport top to this position in SplitViewState
1964        if let Some(view_state) = self.split_view_states.get_mut(&split_id) {
1965            view_state.viewport.top_byte = scroll_position.0;
1966            view_state.viewport.top_view_line_offset = scroll_position.1;
1967            // Skip ensure_visible so the scroll position isn't undone during render
1968            view_state.viewport.set_skip_ensure_visible();
1969        }
1970
1971        // Move cursor to be visible in the new viewport (after releasing the state borrow)
1972        self.move_cursor_to_visible_area(split_id, buffer_id);
1973
1974        Ok(())
1975    }
1976
1977    /// Handle scrollbar jump (click on track) for composite buffers.
1978    /// Maps the click ratio to a row-based scroll position.
1979    fn handle_composite_scrollbar_jump(
1980        &mut self,
1981        ratio: f64,
1982        split_id: LeafId,
1983        buffer_id: BufferId,
1984        scrollbar_rect: ratatui::layout::Rect,
1985    ) -> AnyhowResult<()> {
1986        let total_rows = self
1987            .composite_buffers
1988            .get(&buffer_id)
1989            .map(|c| c.row_count())
1990            .unwrap_or(0);
1991        let content_height = scrollbar_rect.height.saturating_sub(1) as usize;
1992        let max_scroll_row = total_rows.saturating_sub(content_height);
1993        let target_row = (ratio * max_scroll_row as f64).round() as usize;
1994        let target_row = target_row.min(max_scroll_row);
1995
1996        if let Some(view_state) = self.composite_view_states.get_mut(&(split_id, buffer_id)) {
1997            view_state.set_scroll_row(target_row, max_scroll_row);
1998        }
1999        Ok(())
2000    }
2001
2002    /// Handle scrollbar thumb drag for composite buffers.
2003    /// Uses relative movement from the drag start position.
2004    fn handle_composite_scrollbar_drag_relative(
2005        &mut self,
2006        row: u16,
2007        drag_start_row: u16,
2008        split_id: LeafId,
2009        buffer_id: BufferId,
2010        scrollbar_rect: ratatui::layout::Rect,
2011    ) -> AnyhowResult<()> {
2012        let drag_start_scroll_row = match self.mouse_state.drag_start_composite_scroll_row {
2013            Some(r) => r,
2014            None => return Ok(()),
2015        };
2016
2017        let total_rows = self
2018            .composite_buffers
2019            .get(&buffer_id)
2020            .map(|c| c.row_count())
2021            .unwrap_or(0);
2022        let content_height = scrollbar_rect.height.saturating_sub(1) as usize;
2023        let max_scroll_row = total_rows.saturating_sub(content_height);
2024
2025        if max_scroll_row == 0 {
2026            return Ok(());
2027        }
2028
2029        let scrollbar_height = scrollbar_rect.height as usize;
2030        if scrollbar_height <= 1 {
2031            return Ok(());
2032        }
2033
2034        // Calculate thumb size (same formula as render_composite_scrollbar)
2035        let thumb_size_raw =
2036            (content_height as f64 / total_rows as f64 * scrollbar_height as f64).ceil() as usize;
2037        let max_thumb_size = (scrollbar_height as f64 * 0.8).floor() as usize;
2038        let thumb_size = thumb_size_raw
2039            .max(1)
2040            .min(max_thumb_size)
2041            .min(scrollbar_height);
2042        let max_thumb_start = scrollbar_height.saturating_sub(thumb_size);
2043
2044        if max_thumb_start == 0 {
2045            return Ok(());
2046        }
2047
2048        // Calculate where the thumb was at drag start
2049        let start_scroll_ratio =
2050            drag_start_scroll_row.min(max_scroll_row) as f64 / max_scroll_row as f64;
2051        let thumb_row_at_start =
2052            scrollbar_rect.y as f64 + start_scroll_ratio * max_thumb_start as f64;
2053
2054        // Calculate click offset (where on thumb we clicked)
2055        let click_offset = drag_start_row as f64 - thumb_row_at_start;
2056
2057        // Target thumb position based on current mouse position
2058        let target_thumb_row = row as f64 - click_offset;
2059
2060        // Map target thumb position to scroll ratio
2061        let target_scroll_ratio =
2062            ((target_thumb_row - scrollbar_rect.y as f64) / max_thumb_start as f64).clamp(0.0, 1.0);
2063
2064        // Map scroll ratio to target row
2065        let target_row = (target_scroll_ratio * max_scroll_row as f64).round() as usize;
2066        let target_row = target_row.min(max_scroll_row);
2067
2068        if let Some(view_state) = self.composite_view_states.get_mut(&(split_id, buffer_id)) {
2069            view_state.set_scroll_row(target_row, max_scroll_row);
2070        }
2071        Ok(())
2072    }
2073
2074    /// Move the cursor to a visible position within the current viewport
2075    /// This is called after scrollbar operations to ensure the cursor is in view
2076    pub(super) fn move_cursor_to_visible_area(&mut self, split_id: LeafId, buffer_id: BufferId) {
2077        // Get viewport info from SplitViewState
2078        let (top_byte, viewport_height) =
2079            if let Some(view_state) = self.split_view_states.get(&split_id) {
2080                (
2081                    view_state.viewport.top_byte,
2082                    view_state.viewport.height as usize,
2083                )
2084            } else {
2085                return;
2086            };
2087
2088        if let Some(state) = self.buffers.get_mut(&buffer_id) {
2089            let buffer_len = state.buffer.len();
2090
2091            // Find the bottom byte of the viewport
2092            // We iterate through viewport_height lines starting from top_byte
2093            let mut iter = state.buffer.line_iterator(top_byte, 80);
2094            let mut bottom_byte = buffer_len;
2095
2096            // Consume viewport_height lines to find where the visible area ends
2097            for _ in 0..viewport_height {
2098                if let Some((pos, line)) = iter.next_line() {
2099                    // The bottom of this line is at pos + line.len()
2100                    bottom_byte = pos + line.len();
2101                } else {
2102                    // Reached end of buffer
2103                    bottom_byte = buffer_len;
2104                    break;
2105                }
2106            }
2107
2108            // Check if cursor is outside visible range and move it if needed
2109            if let Some(view_state) = self.split_view_states.get_mut(&split_id) {
2110                let cursor_pos = view_state.cursors.primary().position;
2111                if cursor_pos < top_byte || cursor_pos > bottom_byte {
2112                    // Move cursor to the top of the viewport
2113                    let cursor = view_state.cursors.primary_mut();
2114                    cursor.position = top_byte;
2115                    // Keep the existing sticky_column value so vertical navigation preserves column
2116                }
2117            }
2118        }
2119    }
2120
2121    /// Calculate the maximum allowed scroll position
2122    /// Ensures the last line is always at the bottom unless the buffer is smaller than viewport
2123    pub(super) fn calculate_max_scroll_position(
2124        buffer: &mut crate::model::buffer::Buffer,
2125        viewport_height: usize,
2126    ) -> usize {
2127        if viewport_height == 0 {
2128            return 0;
2129        }
2130
2131        let buffer_len = buffer.len();
2132        if buffer_len == 0 {
2133            return 0;
2134        }
2135
2136        // Count total lines in buffer
2137        let mut line_count = 0;
2138        let mut iter = buffer.line_iterator(0, 80);
2139        while iter.next_line().is_some() {
2140            line_count += 1;
2141        }
2142
2143        // If buffer has fewer lines than viewport, can't scroll at all
2144        if line_count <= viewport_height {
2145            return 0;
2146        }
2147
2148        // Calculate how many lines from the start we can scroll
2149        // We want to be able to scroll so that the last line is at the bottom
2150        let scrollable_lines = line_count.saturating_sub(viewport_height);
2151
2152        // Find the byte position of the line at scrollable_lines offset
2153        let mut iter = buffer.line_iterator(0, 80);
2154        let mut current_line = 0;
2155        let mut max_byte_pos = 0;
2156
2157        while current_line < scrollable_lines {
2158            if let Some((pos, _content)) = iter.next_line() {
2159                max_byte_pos = pos;
2160                current_line += 1;
2161            } else {
2162                break;
2163            }
2164        }
2165
2166        max_byte_pos
2167    }
2168
2169    /// Calculate scrollbar jump position using visual rows (for line-wrapped content)
2170    /// Returns the byte position to scroll to based on the scroll ratio
2171    /// Calculate scroll position for visual-row-aware scrollbar jump.
2172    /// Returns (byte_position, view_line_offset) for proper positioning within wrapped lines.
2173    fn calculate_scrollbar_jump_visual(
2174        buffer: &mut crate::model::buffer::Buffer,
2175        ratio: f64,
2176        viewport_height: usize,
2177        viewport_width: usize,
2178    ) -> (usize, usize) {
2179        use crate::primitives::line_wrapping::{wrap_line, WrapConfig};
2180
2181        let buffer_len = buffer.len();
2182        if buffer_len == 0 || viewport_height == 0 {
2183            return (0, 0);
2184        }
2185
2186        // Calculate gutter width (estimate based on line count)
2187        let line_count = buffer.line_count().unwrap_or(1);
2188        let digits = (line_count as f64).log10().floor() as usize + 1;
2189        let gutter_width = 1 + digits.max(4) + 3; // indicator + digits + separator
2190
2191        let wrap_config = WrapConfig::new(viewport_width, gutter_width, true, true);
2192
2193        // Count total visual rows and build a map of visual row -> (line_byte, offset_in_line)
2194        let mut total_visual_rows = 0;
2195        let mut visual_row_positions: Vec<(usize, usize)> = Vec::new(); // (line_start_byte, visual_row_offset)
2196
2197        let mut iter = buffer.line_iterator(0, 80);
2198        while let Some((line_start, content)) = iter.next_line() {
2199            let line_content = content.trim_end_matches(['\n', '\r']).to_string();
2200            let segments = wrap_line(&line_content, &wrap_config);
2201            let visual_rows_in_line = segments.len().max(1);
2202
2203            for offset in 0..visual_rows_in_line {
2204                visual_row_positions.push((line_start, offset));
2205            }
2206            total_visual_rows += visual_rows_in_line;
2207        }
2208
2209        if total_visual_rows == 0 {
2210            return (0, 0);
2211        }
2212
2213        // Calculate max scroll visual row (leave viewport_height rows visible at bottom)
2214        let max_scroll_row = total_visual_rows.saturating_sub(viewport_height);
2215
2216        if max_scroll_row == 0 {
2217            // Content fits in viewport, no scrolling needed
2218            return (0, 0);
2219        }
2220
2221        // Map ratio to target visual row
2222        let target_row = (ratio * max_scroll_row as f64).round() as usize;
2223        let target_row = target_row.min(max_scroll_row);
2224
2225        // Get the byte position and offset for this visual row
2226        if target_row < visual_row_positions.len() {
2227            visual_row_positions[target_row]
2228        } else {
2229            // Fallback to last position
2230            visual_row_positions.last().copied().unwrap_or((0, 0))
2231        }
2232    }
2233
2234    /// Calculate scroll position for visual-row-aware scrollbar drag.
2235    /// The thumb follows the mouse position, accounting for where on the thumb the user clicked.
2236    /// Returns (byte_position, view_line_offset) for proper positioning within wrapped lines.
2237    #[allow(clippy::too_many_arguments)]
2238    fn calculate_scrollbar_drag_relative_visual(
2239        buffer: &mut crate::model::buffer::Buffer,
2240        current_row: u16,
2241        scrollbar_y: u16,
2242        scrollbar_height: usize,
2243        drag_start_row: u16,
2244        drag_start_top_byte: usize,
2245        drag_start_view_line_offset: usize,
2246        viewport_height: usize,
2247        viewport_width: usize,
2248    ) -> (usize, usize) {
2249        use crate::primitives::line_wrapping::{wrap_line, WrapConfig};
2250
2251        let buffer_len = buffer.len();
2252        if buffer_len == 0 || viewport_height == 0 || scrollbar_height <= 1 {
2253            return (0, 0);
2254        }
2255
2256        // Calculate gutter width (estimate based on line count)
2257        let line_count = buffer.line_count().unwrap_or(1);
2258        let digits = (line_count as f64).log10().floor() as usize + 1;
2259        let gutter_width = 1 + digits.max(4) + 3; // indicator + digits + separator
2260
2261        let wrap_config = WrapConfig::new(viewport_width, gutter_width, true, true);
2262
2263        // Build visual row positions map
2264        let mut total_visual_rows = 0;
2265        let mut visual_row_positions: Vec<(usize, usize)> = Vec::new();
2266
2267        let mut iter = buffer.line_iterator(0, 80);
2268        while let Some((line_start, content)) = iter.next_line() {
2269            let line_content = content.trim_end_matches(['\n', '\r']).to_string();
2270            let segments = wrap_line(&line_content, &wrap_config);
2271            let visual_rows_in_line = segments.len().max(1);
2272
2273            for offset in 0..visual_rows_in_line {
2274                visual_row_positions.push((line_start, offset));
2275            }
2276            total_visual_rows += visual_rows_in_line;
2277        }
2278
2279        if total_visual_rows == 0 {
2280            return (0, 0);
2281        }
2282
2283        let max_scroll_row = total_visual_rows.saturating_sub(viewport_height);
2284        if max_scroll_row == 0 {
2285            return (0, 0);
2286        }
2287
2288        // Find the visual row corresponding to drag_start_top_byte + view_line_offset
2289        // First find the line start, then add the offset for wrapped lines
2290        let line_start_visual_row = visual_row_positions
2291            .iter()
2292            .position(|(byte, _)| *byte >= drag_start_top_byte)
2293            .unwrap_or(0);
2294        let start_visual_row =
2295            (line_start_visual_row + drag_start_view_line_offset).min(max_scroll_row);
2296
2297        // Calculate thumb size (same formula as scrollbar rendering)
2298        let thumb_size_raw = (viewport_height as f64 / total_visual_rows as f64
2299            * scrollbar_height as f64)
2300            .ceil() as usize;
2301        let max_thumb_size = (scrollbar_height as f64 * 0.8).floor() as usize;
2302        let thumb_size = thumb_size_raw
2303            .max(1)
2304            .min(max_thumb_size)
2305            .min(scrollbar_height);
2306
2307        // Calculate max thumb start position (same as scrollbar rendering)
2308        let max_thumb_start = scrollbar_height.saturating_sub(thumb_size);
2309
2310        // Calculate where the thumb was (in scrollbar coordinates) at drag start
2311        // Using the same formula as scrollbar rendering: thumb_start = scroll_ratio * max_thumb_start
2312        let start_scroll_ratio = start_visual_row as f64 / max_scroll_row as f64;
2313        let thumb_row_at_start = scrollbar_y as f64 + start_scroll_ratio * max_thumb_start as f64;
2314
2315        // Calculate click offset (where on the thumb we clicked)
2316        let click_offset = drag_start_row as f64 - thumb_row_at_start;
2317
2318        // Calculate target thumb position based on current mouse position
2319        let target_thumb_row = current_row as f64 - click_offset;
2320
2321        // Map target thumb position to scroll ratio (inverse of thumb_start formula)
2322        let target_scroll_ratio = if max_thumb_start > 0 {
2323            ((target_thumb_row - scrollbar_y as f64) / max_thumb_start as f64).clamp(0.0, 1.0)
2324        } else {
2325            0.0
2326        };
2327
2328        // Map scroll ratio to visual row
2329        let target_row = (target_scroll_ratio * max_scroll_row as f64).round() as usize;
2330        let target_row = target_row.min(max_scroll_row);
2331
2332        // Get the byte position and offset for this visual row
2333        if target_row < visual_row_positions.len() {
2334            visual_row_positions[target_row]
2335        } else {
2336            visual_row_positions.last().copied().unwrap_or((0, 0))
2337        }
2338    }
2339
2340    /// Calculate buffer byte position from screen coordinates
2341    ///
2342    /// When `compose_width` is set and narrower than the content area, the
2343    /// content is centered with left padding.  View-line mappings are built
2344    /// relative to that compose render area, so the same offset must be
2345    /// applied here when converting screen coordinates.
2346    ///
2347    /// Returns None if the position cannot be determined (e.g., click in gutter for click handler)
2348    #[allow(clippy::too_many_arguments)]
2349    pub(crate) fn screen_to_buffer_position(
2350        col: u16,
2351        row: u16,
2352        content_rect: ratatui::layout::Rect,
2353        gutter_width: u16,
2354        cached_mappings: &Option<Vec<crate::app::types::ViewLineMapping>>,
2355        fallback_position: usize,
2356        allow_gutter_click: bool,
2357        compose_width: Option<u16>,
2358    ) -> Option<usize> {
2359        // Adjust content_rect for compose layout centering
2360        let content_rect = Self::adjust_content_rect_for_compose(content_rect, compose_width);
2361
2362        // Calculate relative position in content area
2363        let content_col = col.saturating_sub(content_rect.x);
2364        let content_row = row.saturating_sub(content_rect.y);
2365
2366        tracing::trace!(
2367            col,
2368            row,
2369            ?content_rect,
2370            gutter_width,
2371            content_col,
2372            content_row,
2373            num_mappings = cached_mappings.as_ref().map(|m| m.len()),
2374            "screen_to_buffer_position"
2375        );
2376
2377        // Handle gutter clicks
2378        let text_col = if content_col < gutter_width {
2379            if !allow_gutter_click {
2380                return None; // Click handler skips gutter clicks
2381            }
2382            0 // Drag handler uses position 0 of the line
2383        } else {
2384            content_col.saturating_sub(gutter_width) as usize
2385        };
2386
2387        // Use cached view line mappings for accurate position lookup
2388        let visual_row = content_row as usize;
2389
2390        // Helper to get position from a line mapping at a given visual column
2391        let position_from_mapping =
2392            |line_mapping: &crate::app::types::ViewLineMapping, col: usize| -> usize {
2393                if col < line_mapping.visual_to_char.len() {
2394                    // Use O(1) lookup: visual column -> char index -> source byte
2395                    if let Some(byte_pos) = line_mapping.source_byte_at_visual_col(col) {
2396                        return byte_pos;
2397                    }
2398                    // Column maps to virtual/injected content - find nearest real position
2399                    for c in (0..col).rev() {
2400                        if let Some(byte_pos) = line_mapping.source_byte_at_visual_col(c) {
2401                            return byte_pos;
2402                        }
2403                    }
2404                    line_mapping.line_end_byte
2405                } else {
2406                    // Click is past end of visible content
2407                    // For empty lines (only a newline), return the line start position
2408                    // to keep cursor on this line rather than jumping to the next line
2409                    if line_mapping.visual_to_char.len() <= 1 {
2410                        // Empty or newline-only line - return first source byte if available
2411                        if let Some(Some(first_byte)) = line_mapping.char_source_bytes.first() {
2412                            return *first_byte;
2413                        }
2414                    }
2415                    line_mapping.line_end_byte
2416                }
2417            };
2418
2419        let position = cached_mappings
2420            .as_ref()
2421            .and_then(|mappings| {
2422                if let Some(line_mapping) = mappings.get(visual_row) {
2423                    // Click is on a visible line
2424                    Some(position_from_mapping(line_mapping, text_col))
2425                } else if !mappings.is_empty() {
2426                    // Click is below last visible line - use the last line at the clicked column
2427                    let last_mapping = mappings.last().unwrap();
2428                    Some(position_from_mapping(last_mapping, text_col))
2429                } else {
2430                    None
2431                }
2432            })
2433            .unwrap_or(fallback_position);
2434
2435        Some(position)
2436    }
2437
2438    pub(super) fn adjust_content_rect_for_compose(
2439        content_rect: ratatui::layout::Rect,
2440        compose_width: Option<u16>,
2441    ) -> ratatui::layout::Rect {
2442        if let Some(cw) = compose_width {
2443            let clamped = cw.min(content_rect.width).max(1);
2444            if clamped < content_rect.width {
2445                let pad_total = content_rect.width - clamped;
2446                let left_pad = pad_total / 2;
2447                ratatui::layout::Rect::new(
2448                    content_rect.x + left_pad,
2449                    content_rect.y,
2450                    clamped,
2451                    content_rect.height,
2452                )
2453            } else {
2454                content_rect
2455            }
2456        } else {
2457            content_rect
2458        }
2459    }
2460
2461    /// Check whether a gutter click at `target_position` should toggle a fold.
2462    /// Returns `Some(target_position)` (the byte to fold at) or `None`.
2463    fn fold_toggle_byte_from_position(
2464        state: &crate::state::EditorState,
2465        collapsed_header_bytes: &std::collections::BTreeMap<usize, Option<String>>,
2466        target_position: usize,
2467        content_col: u16,
2468        gutter_width: u16,
2469    ) -> Option<usize> {
2470        if content_col >= gutter_width {
2471            return None;
2472        }
2473
2474        use crate::view::folding::indent_folding;
2475        let line_start = indent_folding::find_line_start_byte(&state.buffer, target_position);
2476
2477        // Already collapsed → allow toggling (unfold)
2478        if collapsed_header_bytes.contains_key(&line_start) {
2479            return Some(target_position);
2480        }
2481
2482        // Check LSP folding ranges first (line-based comparison unavoidable)
2483        if !state.folding_ranges.is_empty() {
2484            let line = state.buffer.get_line_number(target_position);
2485            let has_lsp_fold = state.folding_ranges.iter().any(|range| {
2486                let start_line = range.start_line as usize;
2487                let end_line = range.end_line as usize;
2488                start_line == line && end_line > start_line
2489            });
2490            if has_lsp_fold {
2491                return Some(target_position);
2492            }
2493        }
2494
2495        // Fallback: indent-based foldable detection on bytes when LSP ranges are empty
2496        if state.folding_ranges.is_empty() {
2497            let tab_size = state.buffer_settings.tab_size;
2498            let max_scan = crate::config::INDENT_FOLD_INDICATOR_MAX_SCAN;
2499            let max_bytes = max_scan * state.buffer.estimated_line_length();
2500            if indent_folding::indent_fold_end_byte(&state.buffer, line_start, tab_size, max_bytes)
2501                .is_some()
2502            {
2503                return Some(target_position);
2504            }
2505        }
2506
2507        None
2508    }
2509
2510    pub(super) fn fold_toggle_line_at_screen_position(
2511        &self,
2512        col: u16,
2513        row: u16,
2514    ) -> Option<(BufferId, usize)> {
2515        for (split_id, buffer_id, content_rect, _scrollbar_rect, _thumb_start, _thumb_end) in
2516            &self.cached_layout.split_areas
2517        {
2518            if col < content_rect.x
2519                || col >= content_rect.x + content_rect.width
2520                || row < content_rect.y
2521                || row >= content_rect.y + content_rect.height
2522            {
2523                continue;
2524            }
2525
2526            if self.is_terminal_buffer(*buffer_id) || self.is_composite_buffer(*buffer_id) {
2527                continue;
2528            }
2529
2530            let (gutter_width, collapsed_header_bytes) = {
2531                let state = self.buffers.get(buffer_id)?;
2532                let headers = self
2533                    .split_view_states
2534                    .get(split_id)
2535                    .map(|vs| {
2536                        vs.folds
2537                            .collapsed_header_bytes(&state.buffer, &state.marker_list)
2538                    })
2539                    .unwrap_or_default();
2540                (state.margins.left_total_width() as u16, headers)
2541            };
2542
2543            let cached_mappings = self.cached_layout.view_line_mappings.get(split_id).cloned();
2544            let fallback = self
2545                .split_view_states
2546                .get(split_id)
2547                .map(|vs| vs.viewport.top_byte)
2548                .unwrap_or(0);
2549            let compose_width = self
2550                .split_view_states
2551                .get(split_id)
2552                .and_then(|vs| vs.compose_width);
2553
2554            let target_position = Self::screen_to_buffer_position(
2555                col,
2556                row,
2557                *content_rect,
2558                gutter_width,
2559                &cached_mappings,
2560                fallback,
2561                true,
2562                compose_width,
2563            )?;
2564
2565            let adjusted_rect = Self::adjust_content_rect_for_compose(*content_rect, compose_width);
2566            let content_col = col.saturating_sub(adjusted_rect.x);
2567            let state = self.buffers.get(buffer_id)?;
2568            if let Some(byte_pos) = Self::fold_toggle_byte_from_position(
2569                state,
2570                &collapsed_header_bytes,
2571                target_position,
2572                content_col,
2573                gutter_width,
2574            ) {
2575                return Some((*buffer_id, byte_pos));
2576            }
2577        }
2578
2579        None
2580    }
2581
2582    /// Handle click in editor content area
2583    pub(super) fn handle_editor_click(
2584        &mut self,
2585        col: u16,
2586        row: u16,
2587        split_id: crate::model::event::LeafId,
2588        buffer_id: BufferId,
2589        content_rect: ratatui::layout::Rect,
2590        modifiers: crossterm::event::KeyModifiers,
2591    ) -> AnyhowResult<()> {
2592        use crate::model::event::{CursorId, Event};
2593        use crossterm::event::KeyModifiers;
2594        // Build modifiers string for plugins
2595        let modifiers_str = if modifiers.contains(KeyModifiers::SHIFT) {
2596            "shift".to_string()
2597        } else {
2598            String::new()
2599        };
2600
2601        // Dispatch MouseClick hook to plugins
2602        // Plugins can handle clicks on their virtual buffers
2603        if self.plugin_manager.has_hook_handlers("mouse_click") {
2604            self.plugin_manager.run_hook(
2605                "mouse_click",
2606                HookArgs::MouseClick {
2607                    column: col,
2608                    row,
2609                    button: "left".to_string(),
2610                    modifiers: modifiers_str,
2611                    content_x: content_rect.x,
2612                    content_y: content_rect.y,
2613                },
2614            );
2615        }
2616
2617        // Focus this split (handles terminal mode exit, tab state, etc.)
2618        self.focus_split(split_id, buffer_id);
2619
2620        // Handle composite buffer clicks specially
2621        if self.is_composite_buffer(buffer_id) {
2622            return self.handle_composite_click(col, row, split_id, buffer_id, content_rect);
2623        }
2624
2625        // Ensure key context is Normal for non-terminal buffers
2626        // This handles the edge case where split/buffer don't change but we clicked from FileExplorer
2627        if !self.is_terminal_buffer(buffer_id) {
2628            self.key_context = crate::input::keybindings::KeyContext::Normal;
2629        }
2630
2631        // Get cached view line mappings for this split (before mutable borrow of buffers)
2632        let cached_mappings = self
2633            .cached_layout
2634            .view_line_mappings
2635            .get(&split_id)
2636            .cloned();
2637
2638        // Get fallback from SplitViewState viewport
2639        let fallback = self
2640            .split_view_states
2641            .get(&split_id)
2642            .map(|vs| vs.viewport.top_byte)
2643            .unwrap_or(0);
2644
2645        // Get compose width for this split (adjusts content rect for centered layout)
2646        let compose_width = self
2647            .split_view_states
2648            .get(&split_id)
2649            .and_then(|vs| vs.compose_width);
2650
2651        // Calculate clicked position in buffer
2652        let (toggle_fold_byte, onclick_action, target_position, cursor_snapshot) =
2653            if let Some(state) = self.buffers.get(&buffer_id) {
2654                let gutter_width = state.margins.left_total_width() as u16;
2655
2656                let Some(target_position) = Self::screen_to_buffer_position(
2657                    col,
2658                    row,
2659                    content_rect,
2660                    gutter_width,
2661                    &cached_mappings,
2662                    fallback,
2663                    true, // Allow gutter clicks - position cursor at start of line
2664                    compose_width,
2665                ) else {
2666                    return Ok(());
2667                };
2668
2669                // Toggle fold on gutter click if this line is foldable/collapsed
2670                let adjusted_rect =
2671                    Self::adjust_content_rect_for_compose(content_rect, compose_width);
2672                let content_col = col.saturating_sub(adjusted_rect.x);
2673                let collapsed_header_bytes = self
2674                    .split_view_states
2675                    .get(&split_id)
2676                    .map(|vs| {
2677                        vs.folds
2678                            .collapsed_header_bytes(&state.buffer, &state.marker_list)
2679                    })
2680                    .unwrap_or_default();
2681                let toggle_fold_byte = Self::fold_toggle_byte_from_position(
2682                    state,
2683                    &collapsed_header_bytes,
2684                    target_position,
2685                    content_col,
2686                    gutter_width,
2687                );
2688
2689                let cursor_snapshot = self
2690                    .split_view_states
2691                    .get(&split_id)
2692                    .map(|vs| {
2693                        let cursor = vs.cursors.primary();
2694                        (
2695                            vs.cursors.primary_id(),
2696                            cursor.position,
2697                            cursor.anchor,
2698                            cursor.sticky_column,
2699                            cursor.deselect_on_move,
2700                        )
2701                    })
2702                    .unwrap_or((CursorId(0), 0, None, 0, true));
2703
2704                // Check for onClick text property at this position
2705                // This enables clickable UI elements in virtual buffers
2706                let onclick_action = state
2707                    .text_properties
2708                    .get_at(target_position)
2709                    .iter()
2710                    .find_map(|prop| {
2711                        prop.get("onClick")
2712                            .and_then(|v| v.as_str())
2713                            .map(|s| s.to_string())
2714                    });
2715
2716                (
2717                    toggle_fold_byte,
2718                    onclick_action,
2719                    target_position,
2720                    cursor_snapshot,
2721                )
2722            } else {
2723                return Ok(());
2724            };
2725
2726        if toggle_fold_byte.is_some() {
2727            self.toggle_fold_at_byte(buffer_id, target_position);
2728            return Ok(());
2729        }
2730
2731        let (primary_cursor_id, old_position, old_anchor, old_sticky_column, deselect_on_move) =
2732            cursor_snapshot;
2733
2734        if let Some(action_name) = onclick_action {
2735            // Execute the action associated with this clickable element
2736            tracing::debug!(
2737                "onClick triggered at position {}: action={}",
2738                target_position,
2739                action_name
2740            );
2741            let empty_args = std::collections::HashMap::new();
2742            if let Some(action) = Action::from_str(&action_name, &empty_args) {
2743                return self.handle_action(action);
2744            }
2745            return Ok(());
2746        }
2747
2748        // Move cursor to clicked position (respect shift for selection)
2749        // Both modifiers supported since some terminals intercept shift+click.
2750        let extend_selection =
2751            modifiers.contains(KeyModifiers::SHIFT) || modifiers.contains(KeyModifiers::CONTROL);
2752        let new_anchor = if extend_selection {
2753            Some(old_anchor.unwrap_or(old_position))
2754        } else if deselect_on_move {
2755            None
2756        } else {
2757            old_anchor
2758        };
2759
2760        let new_sticky_column = self
2761            .buffers
2762            .get(&buffer_id)
2763            .and_then(|state| state.buffer.offset_to_position(target_position))
2764            .map(|pos| pos.column)
2765            .unwrap_or(0);
2766
2767        let event = Event::MoveCursor {
2768            cursor_id: primary_cursor_id,
2769            old_position,
2770            new_position: target_position,
2771            old_anchor,
2772            new_anchor,
2773            old_sticky_column,
2774            new_sticky_column,
2775        };
2776
2777        self.active_event_log_mut().append(event.clone());
2778        self.apply_event_to_active_buffer(&event);
2779        self.track_cursor_movement(&event);
2780
2781        // Start text selection drag for potential mouse drag
2782        self.mouse_state.dragging_text_selection = true;
2783        self.mouse_state.drag_selection_split = Some(split_id);
2784        self.mouse_state.drag_selection_anchor = Some(new_anchor.unwrap_or(target_position));
2785
2786        Ok(())
2787    }
2788
2789    /// Handle click in file explorer
2790    pub(super) fn handle_file_explorer_click(
2791        &mut self,
2792        col: u16,
2793        row: u16,
2794        explorer_area: ratatui::layout::Rect,
2795    ) -> AnyhowResult<()> {
2796        // Check if click is on the title bar (first row)
2797        if row == explorer_area.y {
2798            // Check if click is on close button (× at right side of title bar)
2799            // Close button is at position: explorer_area.x + explorer_area.width - 3 to -1
2800            let close_button_x = explorer_area.x + explorer_area.width.saturating_sub(3);
2801            if col >= close_button_x && col < explorer_area.x + explorer_area.width {
2802                self.toggle_file_explorer();
2803                return Ok(());
2804            }
2805        }
2806
2807        // Focus file explorer
2808        self.key_context = crate::input::keybindings::KeyContext::FileExplorer;
2809
2810        // Calculate which item was clicked (accounting for border and title)
2811        // The file explorer has a 1-line border at top and bottom
2812        let relative_row = row.saturating_sub(explorer_area.y + 1); // +1 for top border
2813
2814        if let Some(ref mut explorer) = self.file_explorer {
2815            let display_nodes = explorer.get_display_nodes();
2816            let scroll_offset = explorer.get_scroll_offset();
2817            let clicked_index = (relative_row as usize) + scroll_offset;
2818
2819            if clicked_index < display_nodes.len() {
2820                let (node_id, _indent) = display_nodes[clicked_index];
2821
2822                // Select this node
2823                explorer.set_selected(Some(node_id));
2824
2825                // Check if it's a file or directory
2826                let node = explorer.tree().get_node(node_id);
2827                if let Some(node) = node {
2828                    if node.is_dir() {
2829                        // Toggle expand/collapse using the existing method
2830                        self.file_explorer_toggle_expand();
2831                    } else if node.is_file() {
2832                        // Open the file but keep focus on file explorer (single click)
2833                        // Double-click or Enter will focus the editor
2834                        let path = node.entry.path.clone();
2835                        let name = node.entry.name.clone();
2836                        match self.open_file(&path) {
2837                            Ok(_) => {
2838                                self.set_status_message(
2839                                    rust_i18n::t!("explorer.opened_file", name = &name).to_string(),
2840                                );
2841                            }
2842                            Err(e) => {
2843                                // Check if this is a large file encoding confirmation error
2844                                if let Some(confirmation) = e.downcast_ref::<
2845                                    crate::model::buffer::LargeFileEncodingConfirmation,
2846                                >() {
2847                                    self.start_large_file_encoding_confirmation(confirmation);
2848                                } else {
2849                                    self.set_status_message(
2850                                        rust_i18n::t!("file.error_opening", error = e.to_string())
2851                                            .to_string(),
2852                                    );
2853                                }
2854                            }
2855                        }
2856                    }
2857                }
2858            }
2859        }
2860
2861        Ok(())
2862    }
2863
2864    /// Start the line ending selection prompt
2865    fn start_set_line_ending_prompt(&mut self) {
2866        use crate::model::buffer::LineEnding;
2867
2868        let current_line_ending = self.active_state().buffer.line_ending();
2869
2870        let options = [
2871            (LineEnding::LF, "LF", "Unix/Linux/Mac"),
2872            (LineEnding::CRLF, "CRLF", "Windows"),
2873            (LineEnding::CR, "CR", "Classic Mac"),
2874        ];
2875
2876        let current_index = options
2877            .iter()
2878            .position(|(le, _, _)| *le == current_line_ending)
2879            .unwrap_or(0);
2880
2881        let suggestions: Vec<crate::input::commands::Suggestion> = options
2882            .iter()
2883            .map(|(le, name, desc)| {
2884                let is_current = *le == current_line_ending;
2885                crate::input::commands::Suggestion {
2886                    text: format!("{} ({})", name, desc),
2887                    description: if is_current {
2888                        Some("current".to_string())
2889                    } else {
2890                        None
2891                    },
2892                    value: Some(name.to_string()),
2893                    disabled: false,
2894                    keybinding: None,
2895                    source: None,
2896                }
2897            })
2898            .collect();
2899
2900        self.prompt = Some(crate::view::prompt::Prompt::with_suggestions(
2901            "Line ending: ".to_string(),
2902            PromptType::SetLineEnding,
2903            suggestions,
2904        ));
2905
2906        if let Some(prompt) = self.prompt.as_mut() {
2907            if !prompt.suggestions.is_empty() {
2908                prompt.selected_suggestion = Some(current_index);
2909                let (_, name, desc) = options[current_index];
2910                prompt.input = format!("{} ({})", name, desc);
2911                prompt.cursor_pos = prompt.input.len();
2912                prompt.selection_anchor = Some(0);
2913            }
2914        }
2915    }
2916
2917    /// Start the encoding selection prompt
2918    fn start_set_encoding_prompt(&mut self) {
2919        use crate::model::buffer::Encoding;
2920
2921        let current_encoding = self.active_state().buffer.encoding();
2922
2923        let suggestions: Vec<crate::input::commands::Suggestion> = Encoding::all()
2924            .iter()
2925            .map(|enc| {
2926                let is_current = *enc == current_encoding;
2927                crate::input::commands::Suggestion {
2928                    text: format!("{} ({})", enc.display_name(), enc.description()),
2929                    description: if is_current {
2930                        Some("current".to_string())
2931                    } else {
2932                        None
2933                    },
2934                    value: Some(enc.display_name().to_string()),
2935                    disabled: false,
2936                    keybinding: None,
2937                    source: None,
2938                }
2939            })
2940            .collect();
2941
2942        let current_index = Encoding::all()
2943            .iter()
2944            .position(|enc| *enc == current_encoding)
2945            .unwrap_or(0);
2946
2947        self.prompt = Some(crate::view::prompt::Prompt::with_suggestions(
2948            "Encoding: ".to_string(),
2949            PromptType::SetEncoding,
2950            suggestions,
2951        ));
2952
2953        if let Some(prompt) = self.prompt.as_mut() {
2954            if !prompt.suggestions.is_empty() {
2955                prompt.selected_suggestion = Some(current_index);
2956                let enc = Encoding::all()[current_index];
2957                prompt.input = format!("{} ({})", enc.display_name(), enc.description());
2958                prompt.cursor_pos = prompt.input.len();
2959                // Select all text so typing immediately replaces it
2960                prompt.selection_anchor = Some(0);
2961            }
2962        }
2963    }
2964
2965    /// Start the reload with encoding prompt
2966    ///
2967    /// Prompts user to select an encoding, then reloads the current file with that encoding.
2968    /// Requires the buffer to have no unsaved modifications.
2969    fn start_reload_with_encoding_prompt(&mut self) {
2970        use crate::model::buffer::Encoding;
2971
2972        // Check if buffer has a file path
2973        let has_file = self
2974            .buffers
2975            .get(&self.active_buffer())
2976            .and_then(|s| s.buffer.file_path())
2977            .is_some();
2978
2979        if !has_file {
2980            self.set_status_message("Cannot reload: buffer has no file".to_string());
2981            return;
2982        }
2983
2984        // Check for unsaved modifications
2985        let is_modified = self
2986            .buffers
2987            .get(&self.active_buffer())
2988            .map(|s| s.buffer.is_modified())
2989            .unwrap_or(false);
2990
2991        if is_modified {
2992            self.set_status_message(
2993                "Cannot reload: buffer has unsaved modifications (save first)".to_string(),
2994            );
2995            return;
2996        }
2997
2998        let current_encoding = self.active_state().buffer.encoding();
2999
3000        let suggestions: Vec<crate::input::commands::Suggestion> = Encoding::all()
3001            .iter()
3002            .map(|enc| {
3003                let is_current = *enc == current_encoding;
3004                crate::input::commands::Suggestion {
3005                    text: format!("{} ({})", enc.display_name(), enc.description()),
3006                    description: if is_current {
3007                        Some("current".to_string())
3008                    } else {
3009                        None
3010                    },
3011                    value: Some(enc.display_name().to_string()),
3012                    disabled: false,
3013                    keybinding: None,
3014                    source: None,
3015                }
3016            })
3017            .collect();
3018
3019        let current_index = Encoding::all()
3020            .iter()
3021            .position(|enc| *enc == current_encoding)
3022            .unwrap_or(0);
3023
3024        self.prompt = Some(crate::view::prompt::Prompt::with_suggestions(
3025            "Reload with encoding: ".to_string(),
3026            PromptType::ReloadWithEncoding,
3027            suggestions,
3028        ));
3029
3030        if let Some(prompt) = self.prompt.as_mut() {
3031            if !prompt.suggestions.is_empty() {
3032                prompt.selected_suggestion = Some(current_index);
3033                let enc = Encoding::all()[current_index];
3034                prompt.input = format!("{} ({})", enc.display_name(), enc.description());
3035                prompt.cursor_pos = prompt.input.len();
3036                prompt.selection_anchor = Some(0);
3037            }
3038        }
3039    }
3040
3041    /// Start the language selection prompt
3042    fn start_set_language_prompt(&mut self) {
3043        use crate::input::commands::CommandSource;
3044
3045        let current_language = self.active_state().language.clone();
3046
3047        // Build a reverse map from syntect display name -> (config key, source label)
3048        // so we can show extra columns in the language selector popup.
3049        let mut syntax_to_config: std::collections::HashMap<String, (String, &str)> =
3050            std::collections::HashMap::new();
3051        for (lang_id, lang_config) in &self.config.languages {
3052            if let Some(syntax) = self
3053                .grammar_registry
3054                .find_syntax_for_lang_config(lang_config)
3055            {
3056                syntax_to_config
3057                    .entry(syntax.name.clone())
3058                    .or_insert((lang_id.clone(), "config"));
3059            }
3060        }
3061
3062        // Build suggestions from all available syntect syntaxes + Plain Text option
3063        let mut suggestions: Vec<crate::input::commands::Suggestion> = vec![
3064            // Plain Text option (no syntax highlighting)
3065            crate::input::commands::Suggestion {
3066                text: "Plain Text".to_string(),
3067                description: if current_language == "text" || current_language == "Plain Text" {
3068                    Some("current".to_string())
3069                } else {
3070                    None
3071                },
3072                value: Some("Plain Text".to_string()),
3073                disabled: false,
3074                keybinding: Some("text".to_string()),
3075                source: Some(CommandSource::Builtin),
3076            },
3077        ];
3078
3079        // Entries: (display_name, config_key, source, value_for_selection)
3080        // display_name = syntect syntax name or config lang_id
3081        // config_key = the key from config.json languages section (if any)
3082        // source = "config" or "builtin"
3083        struct LangEntry {
3084            display_name: String,
3085            config_key: String,
3086            source: &'static str,
3087        }
3088
3089        let mut entries: Vec<LangEntry> = Vec::new();
3090
3091        // Add all available syntaxes from the grammar registry
3092        for syntax_name in self.grammar_registry.available_syntaxes() {
3093            if syntax_name == "Plain Text" {
3094                continue;
3095            }
3096            let (config_key, source) = syntax_to_config
3097                .get(syntax_name)
3098                .map(|(k, s)| (k.clone(), *s))
3099                .unwrap_or_else(|| (syntax_name.to_lowercase(), "builtin"));
3100            entries.push(LangEntry {
3101                display_name: syntax_name.to_string(),
3102                config_key,
3103                source,
3104            });
3105        }
3106
3107        // Add user-configured languages that don't have a matching syntect grammar
3108        let entry_names_lower: std::collections::HashSet<String> = entries
3109            .iter()
3110            .map(|e| e.display_name.to_lowercase())
3111            .collect();
3112        for (lang_id, lang_config) in &self.config.languages {
3113            let has_grammar = !lang_config.grammar.is_empty()
3114                && self
3115                    .grammar_registry
3116                    .find_syntax_by_name(&lang_config.grammar)
3117                    .is_some();
3118            if !has_grammar && !entry_names_lower.contains(&lang_id.to_lowercase()) {
3119                entries.push(LangEntry {
3120                    display_name: lang_id.clone(),
3121                    config_key: lang_id.clone(),
3122                    source: "config",
3123                });
3124            }
3125        }
3126
3127        // Sort alphabetically for easier navigation
3128        entries.sort_unstable_by(|a, b| {
3129            a.display_name
3130                .to_lowercase()
3131                .cmp(&b.display_name.to_lowercase())
3132        });
3133
3134        let mut current_index_found = None;
3135        for entry in &entries {
3136            let is_current =
3137                entry.config_key == current_language || entry.display_name == current_language;
3138            if is_current {
3139                current_index_found = Some(suggestions.len());
3140            }
3141
3142            let description = if is_current {
3143                format!("{} (current)", entry.config_key)
3144            } else {
3145                entry.config_key.clone()
3146            };
3147
3148            let source = if entry.source == "config" {
3149                Some(CommandSource::Plugin("config".to_string()))
3150            } else {
3151                Some(CommandSource::Builtin)
3152            };
3153
3154            suggestions.push(crate::input::commands::Suggestion {
3155                text: entry.display_name.clone(),
3156                description: Some(description),
3157                value: Some(entry.display_name.clone()),
3158                disabled: false,
3159                keybinding: None,
3160                source,
3161            });
3162        }
3163
3164        // Find current language index
3165        let current_index = current_index_found.unwrap_or(0);
3166
3167        self.prompt = Some(crate::view::prompt::Prompt::with_suggestions(
3168            "Language: ".to_string(),
3169            PromptType::SetLanguage,
3170            suggestions,
3171        ));
3172
3173        if let Some(prompt) = self.prompt.as_mut() {
3174            if !prompt.suggestions.is_empty() {
3175                prompt.selected_suggestion = Some(current_index);
3176                // Don't set input - keep it empty so typing filters the list
3177                // The selected suggestion shows the current language
3178            }
3179        }
3180    }
3181
3182    /// Start the theme selection prompt with available themes
3183    fn start_select_theme_prompt(&mut self) {
3184        let available_themes = self.theme_registry.list();
3185        let current_theme_key = &self.config.theme.0;
3186
3187        // Find the index of the current theme (match by key first, then name)
3188        let current_index = available_themes
3189            .iter()
3190            .position(|info| info.key == *current_theme_key)
3191            .or_else(|| {
3192                let normalized = crate::view::theme::normalize_theme_name(current_theme_key);
3193                available_themes.iter().position(|info| {
3194                    crate::view::theme::normalize_theme_name(&info.name) == normalized
3195                })
3196            })
3197            .unwrap_or(0);
3198
3199        let suggestions: Vec<crate::input::commands::Suggestion> = available_themes
3200            .iter()
3201            .map(|info| {
3202                let is_current = Some(info) == available_themes.get(current_index);
3203                // Build a short display key for the description column.
3204                // - file:// URLs: strip prefix to show path relative to user themes dir
3205                // - https:// URLs: strip scheme
3206                let display_key: std::borrow::Cow<'_, str> =
3207                    if let Some(path_str) = info.key.strip_prefix("file://") {
3208                        let path = std::path::Path::new(path_str);
3209                        let themes_dir = self.dir_context.themes_dir();
3210                        path.strip_prefix(&themes_dir)
3211                            .map(|rel| rel.to_string_lossy())
3212                            .unwrap_or_else(|_| path.to_string_lossy())
3213                    } else if let Some(rest) = info.key.strip_prefix("https://") {
3214                        std::borrow::Cow::Borrowed(rest)
3215                    } else if let Some(rest) = info.key.strip_prefix("http://") {
3216                        std::borrow::Cow::Borrowed(rest)
3217                    } else {
3218                        std::borrow::Cow::Borrowed(info.key.as_str())
3219                    };
3220                let description = if is_current {
3221                    Some(format!("{} (current)", display_key))
3222                } else {
3223                    Some(display_key.to_string())
3224                };
3225                crate::input::commands::Suggestion {
3226                    text: info.name.clone(),
3227                    description,
3228                    value: Some(info.key.clone()),
3229                    disabled: false,
3230                    keybinding: None,
3231                    source: None,
3232                }
3233            })
3234            .collect();
3235
3236        self.prompt = Some(crate::view::prompt::Prompt::with_suggestions(
3237            "Select theme: ".to_string(),
3238            PromptType::SelectTheme {
3239                original_theme: current_theme_key.clone(),
3240            },
3241            suggestions,
3242        ));
3243
3244        if let Some(prompt) = self.prompt.as_mut() {
3245            if !prompt.suggestions.is_empty() {
3246                prompt.selected_suggestion = Some(current_index);
3247                // Set input to match selected theme key
3248                if let Some(suggestion) = prompt.suggestions.get(current_index) {
3249                    prompt.input = suggestion.get_value().to_string();
3250                } else {
3251                    prompt.input = current_theme_key.to_string();
3252                }
3253                prompt.cursor_pos = prompt.input.len();
3254                // Select all so typing replaces the pre-filled value
3255                prompt.selection_anchor = Some(0);
3256            }
3257        }
3258    }
3259
3260    /// Apply a theme by key (or name for backward compat) and persist to config
3261    pub(super) fn apply_theme(&mut self, key_or_name: &str) {
3262        if !key_or_name.is_empty() {
3263            if let Some(theme) = self.theme_registry.get_cloned(key_or_name) {
3264                self.theme = theme;
3265
3266                // Set terminal cursor color to match theme
3267                self.theme.set_terminal_cursor_color();
3268
3269                // Re-apply all overlays so colors match the new theme
3270                // (diagnostic and semantic token overlays bake RGB at creation time).
3271                self.reapply_all_overlays();
3272
3273                // Resolve to the canonical registry key so that subsequent
3274                // lookups (plugins, restart) use the exact key, not a name
3275                // that might be ambiguous.
3276                let resolved = self
3277                    .theme_registry
3278                    .resolve_key(key_or_name)
3279                    .unwrap_or(key_or_name)
3280                    .to_string();
3281                self.config.theme = resolved.into();
3282
3283                // Persist to config file
3284                self.save_theme_to_config();
3285
3286                self.set_status_message(
3287                    t!("view.theme_changed", theme = self.theme.name.clone()).to_string(),
3288                );
3289            } else {
3290                self.set_status_message(format!("Theme '{}' not found", key_or_name));
3291            }
3292        }
3293    }
3294
3295    /// Re-apply all stored diagnostics and semantic tokens with the current
3296    /// theme colors. Both overlay types bake RGB values at creation time, so
3297    /// they must be rebuilt when the theme changes.
3298    fn reapply_all_overlays(&mut self) {
3299        // --- Diagnostics ---
3300        crate::services::lsp::diagnostics::invalidate_cache_all();
3301        let entries: Vec<(String, Vec<lsp_types::Diagnostic>)> = self
3302            .stored_diagnostics
3303            .iter()
3304            .map(|(uri, diags)| (uri.clone(), diags.clone()))
3305            .collect();
3306        for (uri, diagnostics) in entries {
3307            if let Some(buffer_id) = self.find_buffer_by_uri(&uri) {
3308                if let Some(state) = self.buffers.get_mut(&buffer_id) {
3309                    crate::services::lsp::diagnostics::apply_diagnostics_to_state_cached(
3310                        state,
3311                        &diagnostics,
3312                        &self.theme,
3313                    );
3314                }
3315            }
3316        }
3317
3318        // --- Semantic tokens ---
3319        let buffer_ids: Vec<_> = self.buffers.keys().cloned().collect();
3320        for buffer_id in buffer_ids {
3321            let tokens = self
3322                .buffers
3323                .get(&buffer_id)
3324                .and_then(|s| s.semantic_tokens.as_ref())
3325                .map(|store| store.tokens.clone());
3326            if let Some(tokens) = tokens {
3327                if let Some(state) = self.buffers.get_mut(&buffer_id) {
3328                    crate::services::lsp::semantic_tokens::apply_semantic_tokens_to_state(
3329                        state,
3330                        &tokens,
3331                        &self.theme,
3332                    );
3333                }
3334            }
3335        }
3336    }
3337
3338    /// Preview a theme by key or name (without persisting to config)
3339    /// Used for live preview when navigating theme selection
3340    pub(super) fn preview_theme(&mut self, key_or_name: &str) {
3341        if !key_or_name.is_empty() {
3342            if let Some(theme) = self.theme_registry.get_cloned(key_or_name) {
3343                if theme.name != self.theme.name {
3344                    self.theme = theme;
3345                    self.theme.set_terminal_cursor_color();
3346                    self.reapply_all_overlays();
3347                }
3348            }
3349        }
3350    }
3351
3352    /// Save the current theme setting to the user's config file
3353    fn save_theme_to_config(&mut self) {
3354        // Create the directory if it doesn't exist
3355        if let Err(e) = self.filesystem.create_dir_all(&self.dir_context.config_dir) {
3356            tracing::warn!("Failed to create config directory: {}", e);
3357            return;
3358        }
3359
3360        // Save the theme using explicit changes to avoid the issue where
3361        // changing to the default theme doesn't persist (because save_to_layer
3362        // computes delta vs defaults and sees no difference).
3363        let resolver = ConfigResolver::new(self.dir_context.clone(), self.working_dir.clone());
3364        let config_path = resolver.user_config_path();
3365        tracing::info!(
3366            "Saving theme '{}' to user config at {}",
3367            self.config.theme.0,
3368            config_path.display()
3369        );
3370
3371        let mut changes = std::collections::HashMap::new();
3372        changes.insert(
3373            "/theme".to_string(),
3374            serde_json::Value::String(self.config.theme.0.clone()),
3375        );
3376
3377        match resolver.save_changes_to_layer(
3378            &changes,
3379            &std::collections::HashSet::new(),
3380            ConfigLayer::User,
3381        ) {
3382            Ok(()) => {
3383                tracing::info!("Theme saved successfully to {}", config_path.display());
3384            }
3385            Err(e) => {
3386                tracing::warn!("Failed to save theme to config: {}", e);
3387            }
3388        }
3389    }
3390
3391    /// Start the keybinding map selection prompt with available maps
3392    fn start_select_keybinding_map_prompt(&mut self) {
3393        // Built-in keybinding maps
3394        let builtin_maps = vec!["default", "emacs", "vscode", "macos"];
3395
3396        // Collect user-defined keybinding maps from config
3397        let user_maps: Vec<&str> = self
3398            .config
3399            .keybinding_maps
3400            .keys()
3401            .map(|s| s.as_str())
3402            .collect();
3403
3404        // Combine built-in and user maps
3405        let mut all_maps: Vec<&str> = builtin_maps;
3406        for map in &user_maps {
3407            if !all_maps.contains(map) {
3408                all_maps.push(map);
3409            }
3410        }
3411
3412        let current_map = &self.config.active_keybinding_map;
3413
3414        // Find the index of the current keybinding map
3415        let current_index = all_maps
3416            .iter()
3417            .position(|name| *name == current_map)
3418            .unwrap_or(0);
3419
3420        let suggestions: Vec<crate::input::commands::Suggestion> = all_maps
3421            .iter()
3422            .map(|map_name| {
3423                let is_current = *map_name == current_map;
3424                crate::input::commands::Suggestion {
3425                    text: map_name.to_string(),
3426                    description: if is_current {
3427                        Some("(current)".to_string())
3428                    } else {
3429                        None
3430                    },
3431                    value: Some(map_name.to_string()),
3432                    disabled: false,
3433                    keybinding: None,
3434                    source: None,
3435                }
3436            })
3437            .collect();
3438
3439        self.prompt = Some(crate::view::prompt::Prompt::with_suggestions(
3440            "Select keybinding map: ".to_string(),
3441            PromptType::SelectKeybindingMap,
3442            suggestions,
3443        ));
3444
3445        if let Some(prompt) = self.prompt.as_mut() {
3446            if !prompt.suggestions.is_empty() {
3447                prompt.selected_suggestion = Some(current_index);
3448                prompt.input = current_map.to_string();
3449                prompt.cursor_pos = prompt.input.len();
3450                prompt.selection_anchor = Some(0);
3451            }
3452        }
3453    }
3454
3455    /// Apply a keybinding map by name and persist it to config
3456    pub(super) fn apply_keybinding_map(&mut self, map_name: &str) {
3457        if map_name.is_empty() {
3458            return;
3459        }
3460
3461        // Check if the map exists (either built-in or user-defined)
3462        let is_builtin = matches!(map_name, "default" | "emacs" | "vscode" | "macos");
3463        let is_user_defined = self.config.keybinding_maps.contains_key(map_name);
3464
3465        if is_builtin || is_user_defined {
3466            // Update the active keybinding map in config
3467            self.config.active_keybinding_map = map_name.to_string().into();
3468
3469            // Reload the keybinding resolver with the new map
3470            *self.keybindings.write().unwrap() =
3471                crate::input::keybindings::KeybindingResolver::new(&self.config);
3472
3473            // Persist to config file
3474            self.save_keybinding_map_to_config();
3475
3476            self.set_status_message(t!("view.keybindings_switched", map = map_name).to_string());
3477        } else {
3478            self.set_status_message(t!("view.keybindings_unknown", map = map_name).to_string());
3479        }
3480    }
3481
3482    /// Save the current keybinding map setting to the user's config file
3483    fn save_keybinding_map_to_config(&mut self) {
3484        // Create the directory if it doesn't exist
3485        if let Err(e) = self.filesystem.create_dir_all(&self.dir_context.config_dir) {
3486            tracing::warn!("Failed to create config directory: {}", e);
3487            return;
3488        }
3489
3490        // Save the config using the resolver
3491        let resolver = ConfigResolver::new(self.dir_context.clone(), self.working_dir.clone());
3492        if let Err(e) = resolver.save_to_layer(&self.config, ConfigLayer::User) {
3493            tracing::warn!("Failed to save keybinding map to config: {}", e);
3494        }
3495    }
3496
3497    /// Start the cursor style selection prompt
3498    fn start_select_cursor_style_prompt(&mut self) {
3499        use crate::config::CursorStyle;
3500
3501        let current_style = self.config.editor.cursor_style;
3502
3503        // Build suggestions from available cursor styles
3504        let suggestions: Vec<crate::input::commands::Suggestion> = CursorStyle::OPTIONS
3505            .iter()
3506            .zip(CursorStyle::DESCRIPTIONS.iter())
3507            .map(|(style_name, description)| {
3508                let is_current = *style_name == current_style.as_str();
3509                crate::input::commands::Suggestion {
3510                    text: description.to_string(),
3511                    description: if is_current {
3512                        Some("(current)".to_string())
3513                    } else {
3514                        None
3515                    },
3516                    value: Some(style_name.to_string()),
3517                    disabled: false,
3518                    keybinding: None,
3519                    source: None,
3520                }
3521            })
3522            .collect();
3523
3524        // Find the index of the current cursor style
3525        let current_index = CursorStyle::OPTIONS
3526            .iter()
3527            .position(|s| *s == current_style.as_str())
3528            .unwrap_or(0);
3529
3530        self.prompt = Some(crate::view::prompt::Prompt::with_suggestions(
3531            "Select cursor style: ".to_string(),
3532            PromptType::SelectCursorStyle,
3533            suggestions,
3534        ));
3535
3536        if let Some(prompt) = self.prompt.as_mut() {
3537            if !prompt.suggestions.is_empty() {
3538                prompt.selected_suggestion = Some(current_index);
3539                prompt.input = CursorStyle::DESCRIPTIONS[current_index].to_string();
3540                prompt.cursor_pos = prompt.input.len();
3541                prompt.selection_anchor = Some(0);
3542            }
3543        }
3544    }
3545
3546    /// Apply a cursor style and persist it to config
3547    pub(super) fn apply_cursor_style(&mut self, style_name: &str) {
3548        use crate::config::CursorStyle;
3549
3550        if let Some(style) = CursorStyle::parse(style_name) {
3551            // Update the config in memory
3552            self.config.editor.cursor_style = style;
3553
3554            // Apply the cursor style to the terminal
3555            if self.session_mode {
3556                // In session mode, queue the escape sequence to be sent to the client
3557                self.queue_escape_sequences(style.to_escape_sequence());
3558            } else {
3559                // In normal mode, write directly to stdout
3560                use std::io::stdout;
3561                // Best-effort cursor style change to stdout.
3562                #[allow(clippy::let_underscore_must_use)]
3563                let _ = crossterm::execute!(stdout(), style.to_crossterm_style());
3564            }
3565
3566            // Persist to config file
3567            self.save_cursor_style_to_config();
3568
3569            // Find the description for the status message
3570            let description = CursorStyle::OPTIONS
3571                .iter()
3572                .zip(CursorStyle::DESCRIPTIONS.iter())
3573                .find(|(name, _)| **name == style_name)
3574                .map(|(_, desc)| *desc)
3575                .unwrap_or(style_name);
3576
3577            self.set_status_message(
3578                t!("view.cursor_style_changed", style = description).to_string(),
3579            );
3580        }
3581    }
3582
3583    /// Start the remove ruler prompt with current rulers as suggestions
3584    fn start_remove_ruler_prompt(&mut self) {
3585        let active_split = self.split_manager.active_split();
3586        let rulers = self
3587            .split_view_states
3588            .get(&active_split)
3589            .map(|vs| vs.rulers.clone())
3590            .unwrap_or_default();
3591
3592        if rulers.is_empty() {
3593            self.set_status_message(t!("rulers.none_configured").to_string());
3594            return;
3595        }
3596
3597        let suggestions: Vec<crate::input::commands::Suggestion> = rulers
3598            .iter()
3599            .map(|&col| crate::input::commands::Suggestion {
3600                text: format!("Column {}", col),
3601                description: None,
3602                value: Some(col.to_string()),
3603                disabled: false,
3604                keybinding: None,
3605                source: None,
3606            })
3607            .collect();
3608
3609        self.prompt = Some(crate::view::prompt::Prompt::with_suggestions(
3610            t!("rulers.remove_prompt").to_string(),
3611            PromptType::RemoveRuler,
3612            suggestions,
3613        ));
3614    }
3615
3616    /// Save the current cursor style setting to the user's config file
3617    fn save_cursor_style_to_config(&mut self) {
3618        // Create the directory if it doesn't exist
3619        if let Err(e) = self.filesystem.create_dir_all(&self.dir_context.config_dir) {
3620            tracing::warn!("Failed to create config directory: {}", e);
3621            return;
3622        }
3623
3624        // Save the config using the resolver
3625        let resolver = ConfigResolver::new(self.dir_context.clone(), self.working_dir.clone());
3626        if let Err(e) = resolver.save_to_layer(&self.config, ConfigLayer::User) {
3627            tracing::warn!("Failed to save cursor style to config: {}", e);
3628        }
3629    }
3630
3631    /// Start the locale selection prompt with available locales
3632    fn start_select_locale_prompt(&mut self) {
3633        let available_locales = crate::i18n::available_locales();
3634        let current_locale = crate::i18n::current_locale();
3635
3636        // Find the index of the current locale
3637        let current_index = available_locales
3638            .iter()
3639            .position(|name| *name == current_locale)
3640            .unwrap_or(0);
3641
3642        let suggestions: Vec<crate::input::commands::Suggestion> = available_locales
3643            .iter()
3644            .map(|locale_name| {
3645                let is_current = *locale_name == current_locale;
3646                let description = if let Some((english_name, native_name)) =
3647                    crate::i18n::locale_display_name(locale_name)
3648                {
3649                    if english_name == native_name {
3650                        // Same name (e.g., English/English)
3651                        if is_current {
3652                            format!("{} (current)", english_name)
3653                        } else {
3654                            english_name.to_string()
3655                        }
3656                    } else {
3657                        // Different names (e.g., German/Deutsch)
3658                        if is_current {
3659                            format!("{} / {} (current)", english_name, native_name)
3660                        } else {
3661                            format!("{} / {}", english_name, native_name)
3662                        }
3663                    }
3664                } else {
3665                    // Unknown locale
3666                    if is_current {
3667                        "(current)".to_string()
3668                    } else {
3669                        String::new()
3670                    }
3671                };
3672                crate::input::commands::Suggestion {
3673                    text: locale_name.to_string(),
3674                    description: if description.is_empty() {
3675                        None
3676                    } else {
3677                        Some(description)
3678                    },
3679                    value: Some(locale_name.to_string()),
3680                    disabled: false,
3681                    keybinding: None,
3682                    source: None,
3683                }
3684            })
3685            .collect();
3686
3687        self.prompt = Some(crate::view::prompt::Prompt::with_suggestions(
3688            t!("locale.select_prompt").to_string(),
3689            PromptType::SelectLocale,
3690            suggestions,
3691        ));
3692
3693        if let Some(prompt) = self.prompt.as_mut() {
3694            if !prompt.suggestions.is_empty() {
3695                prompt.selected_suggestion = Some(current_index);
3696                // Start with empty input to show all options initially
3697                prompt.input = String::new();
3698                prompt.cursor_pos = 0;
3699            }
3700        }
3701    }
3702
3703    /// Apply a locale and persist it to config
3704    pub(super) fn apply_locale(&mut self, locale_name: &str) {
3705        if !locale_name.is_empty() {
3706            // Update the locale at runtime
3707            crate::i18n::set_locale(locale_name);
3708
3709            // Update the config in memory
3710            self.config.locale = crate::config::LocaleName(Some(locale_name.to_string()));
3711
3712            // Regenerate menus with the new locale
3713            self.menus = crate::config::MenuConfig::translated();
3714
3715            // Refresh command palette commands with new locale
3716            if let Ok(mut registry) = self.command_registry.write() {
3717                registry.refresh_builtin_commands();
3718            }
3719
3720            // Persist to config file
3721            self.save_locale_to_config();
3722
3723            self.set_status_message(t!("locale.changed", locale_name = locale_name).to_string());
3724        }
3725    }
3726
3727    /// Save the current locale setting to the user's config file
3728    fn save_locale_to_config(&mut self) {
3729        // Create the directory if it doesn't exist
3730        if let Err(e) = self.filesystem.create_dir_all(&self.dir_context.config_dir) {
3731            tracing::warn!("Failed to create config directory: {}", e);
3732            return;
3733        }
3734
3735        // Save the config using the resolver
3736        let resolver = ConfigResolver::new(self.dir_context.clone(), self.working_dir.clone());
3737        if let Err(e) = resolver.save_to_layer(&self.config, ConfigLayer::User) {
3738            tracing::warn!("Failed to save locale to config: {}", e);
3739        }
3740    }
3741
3742    /// Switch to the previously active tab in the current split.
3743    /// Handles both buffer tabs and group tabs via the focus-history LRU.
3744    fn switch_to_previous_tab(&mut self) {
3745        use crate::view::split::TabTarget;
3746        let active_split = self.split_manager.active_split();
3747        let previous_tab = self
3748            .split_view_states
3749            .get(&active_split)
3750            .and_then(|vs| vs.previous_tab());
3751
3752        match previous_tab {
3753            Some(TabTarget::Buffer(prev_id)) => {
3754                let is_valid = self
3755                    .split_view_states
3756                    .get(&active_split)
3757                    .is_some_and(|vs| vs.has_buffer(prev_id));
3758
3759                if is_valid && prev_id != self.active_buffer() {
3760                    self.position_history.commit_pending_movement();
3761                    let cursors = self.active_cursors();
3762                    let position = cursors.primary().position;
3763                    let anchor = cursors.primary().anchor;
3764                    self.position_history
3765                        .record_movement(self.active_buffer(), position, anchor);
3766                    self.position_history.commit_pending_movement();
3767                    self.set_active_buffer(prev_id);
3768                } else if !is_valid {
3769                    self.set_status_message(t!("status.previous_tab_closed").to_string());
3770                }
3771            }
3772            Some(TabTarget::Group(leaf_id)) => {
3773                if self.grouped_subtrees.contains_key(&leaf_id) {
3774                    self.activate_group_tab(leaf_id);
3775                } else {
3776                    self.set_status_message(t!("status.previous_tab_closed").to_string());
3777                }
3778            }
3779            None => {
3780                self.set_status_message(t!("status.no_previous_tab").to_string());
3781            }
3782        }
3783    }
3784
3785    /// Start the switch-to-tab-by-name prompt with suggestions from open buffers
3786    fn start_switch_to_tab_prompt(&mut self) {
3787        let active_split = self.split_manager.active_split();
3788        let open_buffers: Vec<BufferId> =
3789            if let Some(view_state) = self.split_view_states.get(&active_split) {
3790                view_state.buffer_tab_ids_vec()
3791            } else {
3792                return;
3793            };
3794
3795        if open_buffers.is_empty() {
3796            self.set_status_message(t!("status.no_tabs_in_split").to_string());
3797            return;
3798        }
3799
3800        // Find the current buffer's index
3801        let current_index = open_buffers
3802            .iter()
3803            .position(|&id| id == self.active_buffer())
3804            .unwrap_or(0);
3805
3806        let suggestions: Vec<crate::input::commands::Suggestion> = open_buffers
3807            .iter()
3808            .map(|&buffer_id| {
3809                let display_name = self
3810                    .buffer_metadata
3811                    .get(&buffer_id)
3812                    .map(|m| m.display_name.clone())
3813                    .unwrap_or_else(|| format!("Buffer {:?}", buffer_id));
3814
3815                let is_current = buffer_id == self.active_buffer();
3816                let is_modified = self
3817                    .buffers
3818                    .get(&buffer_id)
3819                    .is_some_and(|b| b.buffer.is_modified());
3820
3821                let description = match (is_current, is_modified) {
3822                    (true, true) => Some("(current, modified)".to_string()),
3823                    (true, false) => Some("(current)".to_string()),
3824                    (false, true) => Some("(modified)".to_string()),
3825                    (false, false) => None,
3826                };
3827
3828                crate::input::commands::Suggestion {
3829                    text: display_name,
3830                    description,
3831                    value: Some(buffer_id.0.to_string()),
3832                    disabled: false,
3833                    keybinding: None,
3834                    source: None,
3835                }
3836            })
3837            .collect();
3838
3839        self.prompt = Some(crate::view::prompt::Prompt::with_suggestions(
3840            "Switch to tab: ".to_string(),
3841            PromptType::SwitchToTab,
3842            suggestions,
3843        ));
3844
3845        if let Some(prompt) = self.prompt.as_mut() {
3846            if !prompt.suggestions.is_empty() {
3847                prompt.selected_suggestion = Some(current_index);
3848            }
3849        }
3850    }
3851
3852    /// Switch to a tab by its BufferId
3853    pub(crate) fn switch_to_tab(&mut self, buffer_id: BufferId) {
3854        // Verify the buffer exists and is open in the current split
3855        let active_split = self.split_manager.active_split();
3856        let is_valid = self
3857            .split_view_states
3858            .get(&active_split)
3859            .is_some_and(|vs| vs.has_buffer(buffer_id));
3860
3861        if !is_valid {
3862            self.set_status_message(t!("status.tab_not_found").to_string());
3863            return;
3864        }
3865
3866        if buffer_id != self.active_buffer() {
3867            // Save current position before switching
3868            self.position_history.commit_pending_movement();
3869
3870            let cursors = self.active_cursors();
3871            let position = cursors.primary().position;
3872            let anchor = cursors.primary().anchor;
3873            self.position_history
3874                .record_movement(self.active_buffer(), position, anchor);
3875            self.position_history.commit_pending_movement();
3876
3877            self.set_active_buffer(buffer_id);
3878        }
3879    }
3880
3881    /// Handle character insertion in prompt mode.
3882    fn handle_insert_char_prompt(&mut self, c: char) -> AnyhowResult<()> {
3883        // Check if this is the query-replace confirmation prompt
3884        if let Some(ref prompt) = self.prompt {
3885            if prompt.prompt_type == PromptType::QueryReplaceConfirm {
3886                return self.handle_interactive_replace_key(c);
3887            }
3888        }
3889
3890        // Reset history navigation when user starts typing
3891        // This allows them to press Up to get back to history items
3892        // Reset history navigation when typing in a prompt
3893        if let Some(ref prompt) = self.prompt {
3894            if let Some(key) = Self::prompt_type_to_history_key(&prompt.prompt_type) {
3895                if let Some(history) = self.prompt_histories.get_mut(&key) {
3896                    history.reset_navigation();
3897                }
3898            }
3899        }
3900
3901        if let Some(prompt) = self.prompt_mut() {
3902            // Use insert_str to properly handle selection deletion
3903            let s = c.to_string();
3904            prompt.insert_str(&s);
3905        }
3906        self.update_prompt_suggestions();
3907        Ok(())
3908    }
3909
3910    /// Handle character insertion in normal editor mode.
3911    fn handle_insert_char_editor(&mut self, c: char) -> AnyhowResult<()> {
3912        // Check if editing is disabled (show_cursors = false)
3913        if self.is_editing_disabled() {
3914            self.set_status_message(t!("buffer.editing_disabled").to_string());
3915            return Ok(());
3916        }
3917
3918        // Cancel any pending LSP requests since the text is changing
3919        self.cancel_pending_lsp_requests();
3920
3921        if let Some(events) = self.action_to_events(Action::InsertChar(c)) {
3922            if events.len() > 1 {
3923                // Multi-cursor: use optimized bulk edit (O(n) instead of O(n²))
3924                let description = format!("Insert '{}'", c);
3925                if let Some(bulk_edit) = self.apply_events_as_bulk_edit(events, description.clone())
3926                {
3927                    self.active_event_log_mut().append(bulk_edit);
3928                }
3929            } else {
3930                // Single cursor - apply normally
3931                for event in events {
3932                    self.active_event_log_mut().append(event.clone());
3933                    self.apply_event_to_active_buffer(&event);
3934                }
3935            }
3936        }
3937
3938        // Auto-trigger signature help on '(' and ','
3939        if c == '(' || c == ',' {
3940            self.request_signature_help();
3941        }
3942
3943        // Auto-trigger completion on trigger characters
3944        self.maybe_trigger_completion(c);
3945
3946        Ok(())
3947    }
3948
3949    /// Apply an action by converting it to events.
3950    ///
3951    /// This is the catch-all handler for actions that can be converted to buffer events
3952    /// (cursor movements, text edits, etc.). It handles batching for multi-cursor,
3953    /// position history tracking, and editing permission checks.
3954    fn apply_action_as_events(&mut self, action: Action) -> AnyhowResult<()> {
3955        // Check if active buffer is a composite buffer - handle scroll/movement specially
3956        let buffer_id = self.active_buffer();
3957        if self.is_composite_buffer(buffer_id) {
3958            if let Some(_handled) = self.handle_composite_action(buffer_id, &action) {
3959                return Ok(());
3960            }
3961        }
3962
3963        // Get description before moving action
3964        let action_description = format!("{:?}", action);
3965
3966        // Check if this is an editing action and editing is disabled
3967        let is_editing_action = matches!(
3968            action,
3969            Action::InsertNewline
3970                | Action::InsertTab
3971                | Action::DeleteForward
3972                | Action::DeleteWordBackward
3973                | Action::DeleteWordForward
3974                | Action::DeleteLine
3975                | Action::DuplicateLine
3976                | Action::MoveLineUp
3977                | Action::MoveLineDown
3978                | Action::DedentSelection
3979                | Action::ToggleComment
3980        );
3981
3982        if is_editing_action && self.is_editing_disabled() {
3983            self.set_status_message(t!("buffer.editing_disabled").to_string());
3984            return Ok(());
3985        }
3986
3987        if let Some(events) = self.action_to_events(action) {
3988            if events.len() > 1 {
3989                // Check if this batch contains buffer modifications
3990                let has_buffer_mods = events
3991                    .iter()
3992                    .any(|e| matches!(e, Event::Insert { .. } | Event::Delete { .. }));
3993
3994                if has_buffer_mods {
3995                    // Multi-cursor buffer edit: use optimized bulk edit (O(n) instead of O(n²))
3996                    if let Some(bulk_edit) =
3997                        self.apply_events_as_bulk_edit(events.clone(), action_description)
3998                    {
3999                        self.active_event_log_mut().append(bulk_edit);
4000                    }
4001                } else {
4002                    // Multi-cursor non-buffer operation: use Batch for atomic undo
4003                    let batch = Event::Batch {
4004                        events: events.clone(),
4005                        description: action_description,
4006                    };
4007                    self.active_event_log_mut().append(batch.clone());
4008                    self.apply_event_to_active_buffer(&batch);
4009                }
4010
4011                // Track position history for all events
4012                for event in &events {
4013                    self.track_cursor_movement(event);
4014                }
4015            } else {
4016                // Single cursor - apply normally
4017                for event in events {
4018                    self.log_and_apply_event(&event);
4019                    self.track_cursor_movement(&event);
4020                }
4021            }
4022        }
4023
4024        Ok(())
4025    }
4026
4027    /// Track cursor movement in position history if applicable.
4028    pub(super) fn track_cursor_movement(&mut self, event: &Event) {
4029        if self.in_navigation {
4030            return;
4031        }
4032
4033        if let Event::MoveCursor {
4034            new_position,
4035            new_anchor,
4036            ..
4037        } = event
4038        {
4039            self.position_history
4040                .record_movement(self.active_buffer(), *new_position, *new_anchor);
4041        }
4042    }
4043
4044    /// Route a key event through the CompositeInputRouter for a composite
4045    /// buffer.  Returns `Some(Ok(()))` if the event was handled (or blocked),
4046    /// `None` if the router returned `Unhandled` (let fallthrough continue).
4047    fn try_route_composite_key(
4048        &mut self,
4049        split_id: crate::model::event::LeafId,
4050        buffer_id: crate::model::event::BufferId,
4051        key_event: &crossterm::event::KeyEvent,
4052    ) -> Option<AnyhowResult<()>> {
4053        use crate::input::composite_router::{
4054            CompositeInputRouter, Direction, RoutedEvent, ScrollAction,
4055        };
4056
4057        let composite = self.composite_buffers.get(&buffer_id)?;
4058        let view_state = self.composite_view_states.get(&(split_id, buffer_id))?;
4059
4060        match CompositeInputRouter::route_key_event(composite, view_state, key_event) {
4061            RoutedEvent::Unhandled => None,
4062
4063            RoutedEvent::CompositeScroll(action) => {
4064                let delta = match action {
4065                    ScrollAction::Up(n) => -(n as isize),
4066                    ScrollAction::Down(n) => n as isize,
4067                    _ => return Some(Ok(())),
4068                };
4069                self.composite_scroll(split_id, buffer_id, delta);
4070                Some(Ok(()))
4071            }
4072
4073            RoutedEvent::SwitchPane(dir) => {
4074                match dir {
4075                    Direction::Next => self.composite_focus_next(split_id, buffer_id),
4076                    Direction::Prev => self.composite_focus_prev(split_id, buffer_id),
4077                }
4078                Some(Ok(()))
4079            }
4080
4081            // Anything else the router might return — let normal dispatch handle it
4082            _ => None,
4083        }
4084    }
4085}