Skip to main content

fresh/app/
input.rs

1use super::*;
2use crate::services::plugins::hooks::HookArgs;
3use anyhow::Result as AnyhowResult;
4use rust_i18n::t;
5impl Editor {
6    /// Determine the current keybinding context based on UI state
7    pub fn get_key_context(&self) -> crate::input::keybindings::KeyContext {
8        use crate::input::keybindings::KeyContext;
9
10        // Priority order: Settings > Menu > Prompt > Popup > Rename > Current context (FileExplorer or Normal)
11        if self.settings_state.as_ref().is_some_and(|s| s.visible) {
12            KeyContext::Settings
13        } else if self.menu_state.active_menu.is_some() {
14            KeyContext::Menu
15        } else if self.is_prompting() {
16            KeyContext::Prompt
17        } else if self.active_state().popups.is_visible() {
18            KeyContext::Popup
19        } else {
20            // Use the current context (can be FileExplorer or Normal)
21            self.key_context
22        }
23    }
24
25    /// Handle a key event and return whether it was handled
26    /// This is the central key handling logic used by both main.rs and tests
27    pub fn handle_key(
28        &mut self,
29        code: crossterm::event::KeyCode,
30        modifiers: crossterm::event::KeyModifiers,
31    ) -> AnyhowResult<()> {
32        use crate::input::keybindings::Action;
33
34        let _t_total = std::time::Instant::now();
35
36        tracing::trace!(
37            "Editor.handle_key: code={:?}, modifiers={:?}",
38            code,
39            modifiers
40        );
41
42        // Create key event for dispatch methods
43        let key_event = crossterm::event::KeyEvent::new(code, modifiers);
44
45        // Try terminal input dispatch first (handles terminal mode and re-entry)
46        if self.dispatch_terminal_input(&key_event).is_some() {
47            return Ok(());
48        }
49
50        // Clear skip_ensure_visible flag so cursor becomes visible after key press
51        // (scroll actions will set it again if needed)
52        let active_split = self.split_manager.active_split();
53        if let Some(view_state) = self.split_view_states.get_mut(&active_split) {
54            view_state.viewport.clear_skip_ensure_visible();
55        }
56
57        // Determine the current context first
58        let mut context = self.get_key_context();
59
60        // Special case: Hover and Signature Help popups should be dismissed on any key press
61        // EXCEPT for Ctrl+C when the popup has a text selection (allow copy first)
62        if matches!(context, crate::input::keybindings::KeyContext::Popup) {
63            // Check if the current popup is transient (hover, signature help)
64            let (is_transient_popup, has_selection) = {
65                let popup = self.active_state().popups.top();
66                (
67                    popup.is_some_and(|p| p.transient),
68                    popup.is_some_and(|p| p.has_selection()),
69                )
70            };
71
72            // Don't dismiss if popup has selection and user is pressing Ctrl+C (let them copy first)
73            let is_copy_key = key_event.code == crossterm::event::KeyCode::Char('c')
74                && key_event
75                    .modifiers
76                    .contains(crossterm::event::KeyModifiers::CONTROL);
77
78            if is_transient_popup && !(has_selection && is_copy_key) {
79                // Dismiss the popup on any key press (except Ctrl+C with selection)
80                self.hide_popup();
81                tracing::debug!("Dismissed transient popup on key press");
82                // Recalculate context now that popup is gone
83                context = self.get_key_context();
84            }
85        }
86
87        // Try hierarchical modal input dispatch first (Settings, Menu, Prompt, Popup)
88        if self.dispatch_modal_input(&key_event).is_some() {
89            return Ok(());
90        }
91
92        // Only check buffer mode keybindings if we're not in a higher-priority context
93        // (Menu, Prompt, Popup should take precedence over mode bindings)
94        let should_check_mode_bindings = matches!(
95            context,
96            crate::input::keybindings::KeyContext::Normal
97                | crate::input::keybindings::KeyContext::FileExplorer
98        );
99
100        if should_check_mode_bindings {
101            // If we're in a global editor mode, handle chords and keybindings
102            if let Some(ref mode_name) = self.editor_mode {
103                // First, try to resolve as a chord (multi-key sequence like "gg")
104                if let Some(action_name) = self.mode_registry.resolve_chord_keybinding(
105                    mode_name,
106                    &self.chord_state,
107                    code,
108                    modifiers,
109                ) {
110                    tracing::debug!("Mode chord resolved to action: {}", action_name);
111                    self.chord_state.clear();
112                    let action = Action::from_str(&action_name, &std::collections::HashMap::new())
113                        .unwrap_or(Action::PluginAction(action_name));
114                    return self.handle_action(action);
115                }
116
117                // Check if this could be the start of a chord sequence
118                let is_potential_chord = self.mode_registry.is_chord_prefix(
119                    mode_name,
120                    &self.chord_state,
121                    code,
122                    modifiers,
123                );
124
125                if is_potential_chord {
126                    // This could be the start of a chord - add to state and wait
127                    tracing::debug!("Potential chord prefix in editor mode");
128                    self.chord_state.push((code, modifiers));
129                    return Ok(());
130                }
131
132                // Not a chord - clear any pending chord state
133                if !self.chord_state.is_empty() {
134                    tracing::debug!("Chord sequence abandoned in mode, clearing state");
135                    self.chord_state.clear();
136                }
137            }
138
139            // Check buffer mode keybindings (for virtual buffers with custom modes)
140            // Mode keybindings resolve to Action names (see Action::from_str)
141            if let Some(action_name) = self.resolve_mode_keybinding(code, modifiers) {
142                let action = Action::from_str(&action_name, &std::collections::HashMap::new())
143                    .unwrap_or_else(|| Action::PluginAction(action_name.clone()));
144                return self.handle_action(action);
145            }
146
147            // If we're in a global editor mode, check if we should block unbound keys
148            if let Some(ref mode_name) = self.editor_mode {
149                // Check if this mode is read-only
150                // read_only=true (like vi-normal): unbound keys should be ignored
151                // read_only=false (like vi-insert): unbound keys should insert characters
152                if self.mode_registry.is_read_only(mode_name) {
153                    tracing::debug!(
154                        "Ignoring unbound key in read-only mode {:?}",
155                        self.editor_mode
156                    );
157                    return Ok(());
158                }
159                // Mode is not read-only, fall through to normal key handling
160                tracing::debug!(
161                    "Mode {:?} is not read-only, allowing key through",
162                    self.editor_mode
163                );
164            }
165        }
166
167        // Check for chord sequence matches first
168        let key_event = crossterm::event::KeyEvent::new(code, modifiers);
169        let chord_result = self
170            .keybindings
171            .resolve_chord(&self.chord_state, &key_event, context);
172
173        match chord_result {
174            crate::input::keybindings::ChordResolution::Complete(action) => {
175                // Complete chord match - execute action and clear chord state
176                tracing::debug!("Complete chord match -> Action: {:?}", action);
177                self.chord_state.clear();
178                return self.handle_action(action);
179            }
180            crate::input::keybindings::ChordResolution::Partial => {
181                // Partial match - add to chord state and wait for more keys
182                tracing::debug!("Partial chord match - waiting for next key");
183                self.chord_state.push((code, modifiers));
184                return Ok(());
185            }
186            crate::input::keybindings::ChordResolution::NoMatch => {
187                // No chord match - clear state and try regular resolution
188                if !self.chord_state.is_empty() {
189                    tracing::debug!("Chord sequence abandoned, clearing state");
190                    self.chord_state.clear();
191                }
192            }
193        }
194
195        // Regular single-key resolution
196        let action = self.keybindings.resolve(&key_event, context);
197
198        tracing::trace!("Context: {:?} -> Action: {:?}", context, action);
199
200        // Cancel pending LSP requests on user actions (except LSP actions themselves)
201        // This ensures stale completions don't show up after the user has moved on
202        match action {
203            Action::LspCompletion
204            | Action::LspGotoDefinition
205            | Action::LspReferences
206            | Action::LspHover
207            | Action::None => {
208                // Don't cancel for LSP actions or no-op
209            }
210            _ => {
211                // Cancel any pending LSP requests
212                self.cancel_pending_lsp_requests();
213            }
214        }
215
216        // Note: Modal components (Settings, Menu, Prompt, Popup, File Browser) are now
217        // handled by dispatch_modal_input using the InputHandler system.
218        // All remaining actions delegate to handle_action.
219        self.handle_action(action)
220    }
221
222    /// Handle an action (for normal mode and command execution)
223    pub(super) fn handle_action(&mut self, action: Action) -> AnyhowResult<()> {
224        use crate::input::keybindings::Action;
225
226        // Record action to macro if recording
227        self.record_macro_action(&action);
228
229        match action {
230            Action::Quit => self.quit(),
231            Action::ForceQuit => {
232                self.should_quit = true;
233            }
234            Action::Save => {
235                // Check if buffer has a file path - if not, redirect to SaveAs
236                if self.active_state().buffer.file_path().is_none() {
237                    self.start_prompt_with_initial_text(
238                        t!("file.save_as_prompt").to_string(),
239                        PromptType::SaveFileAs,
240                        String::new(),
241                    );
242                    self.init_file_open_state();
243                } else if self.check_save_conflict().is_some() {
244                    // Check if file was modified externally since we opened/saved it
245                    self.start_prompt(
246                        t!("file.file_changed_prompt").to_string(),
247                        PromptType::ConfirmSaveConflict,
248                    );
249                } else {
250                    self.save()?;
251                }
252            }
253            Action::SaveAs => {
254                // Get current filename as default suggestion
255                let current_path = self
256                    .active_state()
257                    .buffer
258                    .file_path()
259                    .map(|p| {
260                        // Make path relative to working_dir if possible
261                        p.strip_prefix(&self.working_dir)
262                            .unwrap_or(p)
263                            .to_string_lossy()
264                            .to_string()
265                    })
266                    .unwrap_or_default();
267                self.start_prompt_with_initial_text(
268                    t!("file.save_as_prompt").to_string(),
269                    PromptType::SaveFileAs,
270                    current_path,
271                );
272                self.init_file_open_state();
273            }
274            Action::Open => {
275                self.start_prompt(t!("file.open_prompt").to_string(), PromptType::OpenFile);
276                self.prefill_open_file_prompt();
277                self.init_file_open_state();
278            }
279            Action::SwitchProject => {
280                self.start_prompt(
281                    t!("file.switch_project_prompt").to_string(),
282                    PromptType::SwitchProject,
283                );
284                self.init_folder_open_state();
285            }
286            Action::GotoLine => self.start_prompt(
287                t!("file.goto_line_prompt").to_string(),
288                PromptType::GotoLine,
289            ),
290            Action::New => {
291                self.new_buffer();
292            }
293            Action::Close | Action::CloseTab => {
294                // Both Close and CloseTab use close_tab() which handles:
295                // - Closing the split if this is the last buffer and there are other splits
296                // - Prompting for unsaved changes
297                // - Properly closing the buffer
298                self.close_tab();
299            }
300            Action::Revert => {
301                // Check if buffer has unsaved changes - prompt for confirmation
302                if self.active_state().buffer.is_modified() {
303                    let revert_key = t!("prompt.key.revert").to_string();
304                    let cancel_key = t!("prompt.key.cancel").to_string();
305                    self.start_prompt(
306                        t!(
307                            "prompt.revert_confirm",
308                            revert_key = revert_key,
309                            cancel_key = cancel_key
310                        )
311                        .to_string(),
312                        PromptType::ConfirmRevert,
313                    );
314                } else {
315                    // No local changes, just revert
316                    if let Err(e) = self.revert_file() {
317                        self.set_status_message(
318                            t!("error.failed_to_revert", error = e.to_string()).to_string(),
319                        );
320                    }
321                }
322            }
323            Action::ToggleAutoRevert => {
324                self.toggle_auto_revert();
325            }
326            Action::FormatBuffer => {
327                if let Err(e) = self.format_buffer() {
328                    self.set_status_message(
329                        t!("error.format_failed", error = e.to_string()).to_string(),
330                    );
331                }
332            }
333            Action::TrimTrailingWhitespace => match self.trim_trailing_whitespace() {
334                Ok(true) => {
335                    self.set_status_message(t!("whitespace.trimmed").to_string());
336                }
337                Ok(false) => {
338                    self.set_status_message(t!("whitespace.no_trailing").to_string());
339                }
340                Err(e) => {
341                    self.set_status_message(
342                        t!("error.trim_whitespace_failed", error = e).to_string(),
343                    );
344                }
345            },
346            Action::EnsureFinalNewline => match self.ensure_final_newline() {
347                Ok(true) => {
348                    self.set_status_message(t!("whitespace.newline_added").to_string());
349                }
350                Ok(false) => {
351                    self.set_status_message(t!("whitespace.already_has_newline").to_string());
352                }
353                Err(e) => {
354                    self.set_status_message(
355                        t!("error.ensure_newline_failed", error = e).to_string(),
356                    );
357                }
358            },
359            Action::Copy => {
360                // Check if there's an active popup with text selection
361                let state = self.active_state();
362                if let Some(popup) = state.popups.top() {
363                    if popup.has_selection() {
364                        if let Some(text) = popup.get_selected_text() {
365                            self.clipboard.copy(text);
366                            self.set_status_message(t!("clipboard.copied").to_string());
367                            return Ok(());
368                        }
369                    }
370                }
371                // Check if active buffer is a composite buffer
372                let buffer_id = self.active_buffer();
373                if self.is_composite_buffer(buffer_id) {
374                    if let Some(_handled) = self.handle_composite_action(buffer_id, &Action::Copy) {
375                        return Ok(());
376                    }
377                }
378                self.copy_selection()
379            }
380            Action::CopyWithTheme(theme) => self.copy_selection_with_theme(&theme),
381            Action::Cut => {
382                if self.is_editing_disabled() {
383                    self.set_status_message(t!("buffer.editing_disabled").to_string());
384                    return Ok(());
385                }
386                self.cut_selection()
387            }
388            Action::Paste => {
389                if self.is_editing_disabled() {
390                    self.set_status_message(t!("buffer.editing_disabled").to_string());
391                    return Ok(());
392                }
393                self.paste()
394            }
395            Action::YankWordForward => self.yank_word_forward(),
396            Action::YankWordBackward => self.yank_word_backward(),
397            Action::YankToLineEnd => self.yank_to_line_end(),
398            Action::YankToLineStart => self.yank_to_line_start(),
399            Action::Undo => {
400                self.handle_undo();
401            }
402            Action::Redo => {
403                self.handle_redo();
404            }
405            Action::ShowHelp => {
406                self.open_help_manual();
407            }
408            Action::ShowKeyboardShortcuts => {
409                self.open_keyboard_shortcuts();
410            }
411            Action::ShowWarnings => {
412                self.show_warnings_popup();
413            }
414            Action::ShowStatusLog => {
415                self.open_status_log();
416            }
417            Action::ShowLspStatus => {
418                self.show_lsp_status_popup();
419            }
420            Action::ClearWarnings => {
421                self.clear_warnings();
422            }
423            Action::CommandPalette => {
424                // Toggle command palette: close if already open, otherwise open it
425                if let Some(prompt) = &self.prompt {
426                    if prompt.prompt_type == PromptType::Command {
427                        self.cancel_prompt();
428                        return Ok(());
429                    }
430                }
431
432                // Use the current context for filtering commands
433                let active_buffer_mode = self
434                    .buffer_metadata
435                    .get(&self.active_buffer())
436                    .and_then(|m| m.virtual_mode());
437                let suggestions = self.command_registry.read().unwrap().filter(
438                    "",
439                    self.key_context,
440                    &self.keybindings,
441                    self.has_active_selection(),
442                    &self.active_custom_contexts,
443                    active_buffer_mode,
444                );
445                self.start_prompt_with_suggestions(
446                    t!("file.command_prompt").to_string(),
447                    PromptType::Command,
448                    suggestions,
449                );
450            }
451            Action::QuickOpen => {
452                // Toggle Quick Open: close if already open, otherwise open it
453                if let Some(prompt) = &self.prompt {
454                    if prompt.prompt_type == PromptType::QuickOpen {
455                        self.cancel_prompt();
456                        return Ok(());
457                    }
458                }
459
460                // Start Quick Open with file suggestions (default mode)
461                self.start_quick_open();
462            }
463            Action::ToggleLineWrap => {
464                self.config.editor.line_wrap = !self.config.editor.line_wrap;
465
466                // Update all viewports to reflect the new line wrap setting
467                for view_state in self.split_view_states.values_mut() {
468                    view_state.viewport.line_wrap_enabled = self.config.editor.line_wrap;
469                }
470
471                let state = if self.config.editor.line_wrap {
472                    t!("view.state_enabled").to_string()
473                } else {
474                    t!("view.state_disabled").to_string()
475                };
476                self.set_status_message(t!("view.line_wrap_state", state = state).to_string());
477            }
478            Action::ToggleComposeMode => {
479                self.handle_toggle_compose_mode();
480            }
481            Action::SetComposeWidth => {
482                let active_split = self.split_manager.active_split();
483                let current = self
484                    .split_view_states
485                    .get(&active_split)
486                    .and_then(|v| v.compose_width.map(|w| w.to_string()))
487                    .unwrap_or_default();
488                self.start_prompt_with_initial_text(
489                    "Compose width (empty = viewport): ".to_string(),
490                    PromptType::SetComposeWidth,
491                    current,
492                );
493            }
494            Action::SetBackground => {
495                let default_path = self
496                    .ansi_background_path
497                    .as_ref()
498                    .and_then(|p| {
499                        p.strip_prefix(&self.working_dir)
500                            .ok()
501                            .map(|rel| rel.to_string_lossy().to_string())
502                    })
503                    .unwrap_or_else(|| DEFAULT_BACKGROUND_FILE.to_string());
504
505                self.start_prompt_with_initial_text(
506                    "Background file: ".to_string(),
507                    PromptType::SetBackgroundFile,
508                    default_path,
509                );
510            }
511            Action::SetBackgroundBlend => {
512                let default_amount = format!("{:.2}", self.background_fade);
513                self.start_prompt_with_initial_text(
514                    "Background blend (0-1): ".to_string(),
515                    PromptType::SetBackgroundBlend,
516                    default_amount,
517                );
518            }
519            Action::LspCompletion => {
520                self.request_completion()?;
521            }
522            Action::LspGotoDefinition => {
523                self.request_goto_definition()?;
524            }
525            Action::LspRename => {
526                self.start_rename()?;
527            }
528            Action::LspHover => {
529                self.request_hover()?;
530            }
531            Action::LspReferences => {
532                self.request_references()?;
533            }
534            Action::LspSignatureHelp => {
535                self.request_signature_help()?;
536            }
537            Action::LspCodeActions => {
538                self.request_code_actions()?;
539            }
540            Action::LspRestart => {
541                self.handle_lsp_restart();
542            }
543            Action::LspStop => {
544                self.handle_lsp_stop();
545            }
546            Action::ToggleInlayHints => {
547                self.toggle_inlay_hints();
548            }
549            Action::DumpConfig => {
550                self.dump_config();
551            }
552            Action::SelectTheme => {
553                self.start_select_theme_prompt();
554            }
555            Action::SelectKeybindingMap => {
556                self.start_select_keybinding_map_prompt();
557            }
558            Action::SelectCursorStyle => {
559                self.start_select_cursor_style_prompt();
560            }
561            Action::SelectLocale => {
562                self.start_select_locale_prompt();
563            }
564            Action::Search => {
565                // If already in a search-related prompt, Ctrl+F acts like Enter (confirm search)
566                let is_search_prompt = self.prompt.as_ref().is_some_and(|p| {
567                    matches!(
568                        p.prompt_type,
569                        PromptType::Search
570                            | PromptType::ReplaceSearch
571                            | PromptType::QueryReplaceSearch
572                    )
573                });
574
575                if is_search_prompt {
576                    self.confirm_prompt();
577                } else {
578                    self.start_search_prompt(
579                        t!("file.search_prompt").to_string(),
580                        PromptType::Search,
581                        false,
582                    );
583                }
584            }
585            Action::Replace => {
586                // Use same flow as query-replace, just with confirm_each defaulting to false
587                self.start_search_prompt(
588                    t!("file.replace_prompt").to_string(),
589                    PromptType::ReplaceSearch,
590                    false,
591                );
592            }
593            Action::QueryReplace => {
594                // Enable confirm mode by default for query-replace
595                self.search_confirm_each = true;
596                self.start_search_prompt(
597                    "Query replace: ".to_string(),
598                    PromptType::QueryReplaceSearch,
599                    false,
600                );
601            }
602            Action::FindInSelection => {
603                self.start_search_prompt(
604                    t!("file.search_prompt").to_string(),
605                    PromptType::Search,
606                    true,
607                );
608            }
609            Action::FindNext => {
610                self.find_next();
611            }
612            Action::FindPrevious => {
613                self.find_previous();
614            }
615            Action::FindSelectionNext => {
616                self.find_selection_next();
617            }
618            Action::FindSelectionPrevious => {
619                self.find_selection_previous();
620            }
621            Action::AddCursorNextMatch => self.add_cursor_at_next_match(),
622            Action::AddCursorAbove => self.add_cursor_above(),
623            Action::AddCursorBelow => self.add_cursor_below(),
624            Action::NextBuffer => self.next_buffer(),
625            Action::PrevBuffer => self.prev_buffer(),
626            Action::SwitchToPreviousTab => self.switch_to_previous_tab(),
627            Action::SwitchToTabByName => self.start_switch_to_tab_prompt(),
628
629            // Tab scrolling (manual scroll - don't auto-adjust)
630            Action::ScrollTabsLeft => {
631                let active_split_id = self.split_manager.active_split();
632                if let Some(view_state) = self.split_view_states.get_mut(&active_split_id) {
633                    view_state.tab_scroll_offset = view_state.tab_scroll_offset.saturating_sub(5);
634                    self.set_status_message(t!("status.scrolled_tabs_left").to_string());
635                }
636            }
637            Action::ScrollTabsRight => {
638                let active_split_id = self.split_manager.active_split();
639                if let Some(view_state) = self.split_view_states.get_mut(&active_split_id) {
640                    view_state.tab_scroll_offset = view_state.tab_scroll_offset.saturating_add(5);
641                    self.set_status_message(t!("status.scrolled_tabs_right").to_string());
642                }
643            }
644            Action::NavigateBack => self.navigate_back(),
645            Action::NavigateForward => self.navigate_forward(),
646            Action::SplitHorizontal => self.split_pane_horizontal(),
647            Action::SplitVertical => self.split_pane_vertical(),
648            Action::CloseSplit => self.close_active_split(),
649            Action::NextSplit => self.next_split(),
650            Action::PrevSplit => self.prev_split(),
651            Action::IncreaseSplitSize => self.adjust_split_size(0.05),
652            Action::DecreaseSplitSize => self.adjust_split_size(-0.05),
653            Action::ToggleMaximizeSplit => self.toggle_maximize_split(),
654            Action::ToggleFileExplorer => self.toggle_file_explorer(),
655            Action::ToggleMenuBar => self.toggle_menu_bar(),
656            Action::ToggleTabBar => self.toggle_tab_bar(),
657            Action::ToggleLineNumbers => self.toggle_line_numbers(),
658            Action::ToggleMouseCapture => self.toggle_mouse_capture(),
659            Action::ToggleMouseHover => self.toggle_mouse_hover(),
660            Action::ToggleDebugHighlights => self.toggle_debug_highlights(),
661            // Buffer settings
662            Action::SetTabSize => {
663                let current = self
664                    .buffers
665                    .get(&self.active_buffer())
666                    .map(|s| s.tab_size.to_string())
667                    .unwrap_or_else(|| "4".to_string());
668                self.start_prompt_with_initial_text(
669                    "Tab size: ".to_string(),
670                    PromptType::SetTabSize,
671                    current,
672                );
673            }
674            Action::SetLineEnding => {
675                self.start_set_line_ending_prompt();
676            }
677            Action::SetLanguage => {
678                self.start_set_language_prompt();
679            }
680            Action::ToggleIndentationStyle => {
681                if let Some(state) = self.buffers.get_mut(&self.active_buffer()) {
682                    state.use_tabs = !state.use_tabs;
683                    let status = if state.use_tabs {
684                        "Indentation: Tabs"
685                    } else {
686                        "Indentation: Spaces"
687                    };
688                    self.set_status_message(status.to_string());
689                }
690            }
691            Action::ToggleTabIndicators => {
692                if let Some(state) = self.buffers.get_mut(&self.active_buffer()) {
693                    state.show_whitespace_tabs = !state.show_whitespace_tabs;
694                    let status = if state.show_whitespace_tabs {
695                        "Tab indicators: Visible"
696                    } else {
697                        "Tab indicators: Hidden"
698                    };
699                    self.set_status_message(status.to_string());
700                }
701            }
702            Action::ResetBufferSettings => self.reset_buffer_settings(),
703            Action::FocusFileExplorer => self.focus_file_explorer(),
704            Action::FocusEditor => self.focus_editor(),
705            Action::FileExplorerUp => self.file_explorer_navigate_up(),
706            Action::FileExplorerDown => self.file_explorer_navigate_down(),
707            Action::FileExplorerPageUp => self.file_explorer_page_up(),
708            Action::FileExplorerPageDown => self.file_explorer_page_down(),
709            Action::FileExplorerExpand => self.file_explorer_toggle_expand(),
710            Action::FileExplorerCollapse => self.file_explorer_collapse(),
711            Action::FileExplorerOpen => self.file_explorer_open_file()?,
712            Action::FileExplorerRefresh => self.file_explorer_refresh(),
713            Action::FileExplorerNewFile => self.file_explorer_new_file(),
714            Action::FileExplorerNewDirectory => self.file_explorer_new_directory(),
715            Action::FileExplorerDelete => self.file_explorer_delete(),
716            Action::FileExplorerRename => self.file_explorer_rename(),
717            Action::FileExplorerToggleHidden => self.file_explorer_toggle_hidden(),
718            Action::FileExplorerToggleGitignored => self.file_explorer_toggle_gitignored(),
719            Action::RemoveSecondaryCursors => {
720                // Convert action to events and apply them
721                if let Some(events) = self.action_to_events(Action::RemoveSecondaryCursors) {
722                    // Wrap in batch for atomic undo
723                    let batch = Event::Batch {
724                        events: events.clone(),
725                        description: "Remove secondary cursors".to_string(),
726                    };
727                    self.active_event_log_mut().append(batch.clone());
728                    self.apply_event_to_active_buffer(&batch);
729
730                    // Ensure the primary cursor is visible after removing secondary cursors
731                    let active_split = self.split_manager.active_split();
732                    let active_buffer = self.active_buffer();
733                    if let Some(view_state) = self.split_view_states.get_mut(&active_split) {
734                        let state = self.buffers.get_mut(&active_buffer).unwrap();
735                        let primary = *state.cursors.primary();
736                        view_state
737                            .viewport
738                            .ensure_visible(&mut state.buffer, &primary);
739                    }
740                }
741            }
742
743            // Menu navigation actions
744            Action::MenuActivate => {
745                self.handle_menu_activate();
746            }
747            Action::MenuClose => {
748                self.handle_menu_close();
749            }
750            Action::MenuLeft => {
751                self.handle_menu_left();
752            }
753            Action::MenuRight => {
754                self.handle_menu_right();
755            }
756            Action::MenuUp => {
757                self.handle_menu_up();
758            }
759            Action::MenuDown => {
760                self.handle_menu_down();
761            }
762            Action::MenuExecute => {
763                if let Some(action) = self.handle_menu_execute() {
764                    return self.handle_action(action);
765                }
766            }
767            Action::MenuOpen(menu_name) => {
768                self.handle_menu_open(&menu_name);
769            }
770
771            Action::SwitchKeybindingMap(map_name) => {
772                // Check if the map exists (either built-in or user-defined)
773                let is_builtin =
774                    matches!(map_name.as_str(), "default" | "emacs" | "vscode" | "macos");
775                let is_user_defined = self.config.keybinding_maps.contains_key(&map_name);
776
777                if is_builtin || is_user_defined {
778                    // Update the active keybinding map in config
779                    self.config.active_keybinding_map = map_name.clone().into();
780
781                    // Reload the keybinding resolver with the new map
782                    self.keybindings =
783                        crate::input::keybindings::KeybindingResolver::new(&self.config);
784
785                    self.set_status_message(
786                        t!("view.keybindings_switched", map = map_name).to_string(),
787                    );
788                } else {
789                    self.set_status_message(
790                        t!("view.keybindings_unknown", map = map_name).to_string(),
791                    );
792                }
793            }
794
795            Action::SmartHome => {
796                self.smart_home();
797            }
798            Action::ToggleComment => {
799                self.toggle_comment();
800            }
801            Action::GoToMatchingBracket => {
802                self.goto_matching_bracket();
803            }
804            Action::JumpToNextError => {
805                self.jump_to_next_error();
806            }
807            Action::JumpToPreviousError => {
808                self.jump_to_previous_error();
809            }
810            Action::SetBookmark(key) => {
811                self.set_bookmark(key);
812            }
813            Action::JumpToBookmark(key) => {
814                self.jump_to_bookmark(key);
815            }
816            Action::ClearBookmark(key) => {
817                self.clear_bookmark(key);
818            }
819            Action::ListBookmarks => {
820                self.list_bookmarks();
821            }
822            Action::ToggleSearchCaseSensitive => {
823                self.search_case_sensitive = !self.search_case_sensitive;
824                let state = if self.search_case_sensitive {
825                    "enabled"
826                } else {
827                    "disabled"
828                };
829                self.set_status_message(
830                    t!("search.case_sensitive_state", state = state).to_string(),
831                );
832                // Update incremental highlights if in search prompt, otherwise re-run completed search
833                // Check prompt FIRST since we want to use current prompt input, not stale search_state
834                if let Some(prompt) = &self.prompt {
835                    if matches!(
836                        prompt.prompt_type,
837                        PromptType::Search
838                            | PromptType::ReplaceSearch
839                            | PromptType::QueryReplaceSearch
840                    ) {
841                        let query = prompt.input.clone();
842                        self.update_search_highlights(&query);
843                    }
844                } else if let Some(search_state) = &self.search_state {
845                    let query = search_state.query.clone();
846                    self.perform_search(&query);
847                }
848            }
849            Action::ToggleSearchWholeWord => {
850                self.search_whole_word = !self.search_whole_word;
851                let state = if self.search_whole_word {
852                    "enabled"
853                } else {
854                    "disabled"
855                };
856                self.set_status_message(t!("search.whole_word_state", state = state).to_string());
857                // Update incremental highlights if in search prompt, otherwise re-run completed search
858                // Check prompt FIRST since we want to use current prompt input, not stale search_state
859                if let Some(prompt) = &self.prompt {
860                    if matches!(
861                        prompt.prompt_type,
862                        PromptType::Search
863                            | PromptType::ReplaceSearch
864                            | PromptType::QueryReplaceSearch
865                    ) {
866                        let query = prompt.input.clone();
867                        self.update_search_highlights(&query);
868                    }
869                } else if let Some(search_state) = &self.search_state {
870                    let query = search_state.query.clone();
871                    self.perform_search(&query);
872                }
873            }
874            Action::ToggleSearchRegex => {
875                self.search_use_regex = !self.search_use_regex;
876                let state = if self.search_use_regex {
877                    "enabled"
878                } else {
879                    "disabled"
880                };
881                self.set_status_message(t!("search.regex_state", state = state).to_string());
882                // Update incremental highlights if in search prompt, otherwise re-run completed search
883                // Check prompt FIRST since we want to use current prompt input, not stale search_state
884                if let Some(prompt) = &self.prompt {
885                    if matches!(
886                        prompt.prompt_type,
887                        PromptType::Search
888                            | PromptType::ReplaceSearch
889                            | PromptType::QueryReplaceSearch
890                    ) {
891                        let query = prompt.input.clone();
892                        self.update_search_highlights(&query);
893                    }
894                } else if let Some(search_state) = &self.search_state {
895                    let query = search_state.query.clone();
896                    self.perform_search(&query);
897                }
898            }
899            Action::ToggleSearchConfirmEach => {
900                self.search_confirm_each = !self.search_confirm_each;
901                let state = if self.search_confirm_each {
902                    "enabled"
903                } else {
904                    "disabled"
905                };
906                self.set_status_message(t!("search.confirm_each_state", state = state).to_string());
907            }
908            Action::FileBrowserToggleHidden => {
909                // Toggle hidden files in file browser (handled via file_open_toggle_hidden)
910                self.file_open_toggle_hidden();
911            }
912            Action::StartMacroRecording => {
913                // This is a no-op; use ToggleMacroRecording instead
914                self.set_status_message(
915                    "Use Ctrl+Shift+R to start recording (will prompt for register)".to_string(),
916                );
917            }
918            Action::StopMacroRecording => {
919                self.stop_macro_recording();
920            }
921            Action::PlayMacro(key) => {
922                self.play_macro(key);
923            }
924            Action::ToggleMacroRecording(key) => {
925                self.toggle_macro_recording(key);
926            }
927            Action::ShowMacro(key) => {
928                self.show_macro_in_buffer(key);
929            }
930            Action::ListMacros => {
931                self.list_macros_in_buffer();
932            }
933            Action::PromptRecordMacro => {
934                self.start_prompt("Record macro (0-9): ".to_string(), PromptType::RecordMacro);
935            }
936            Action::PromptPlayMacro => {
937                self.start_prompt("Play macro (0-9): ".to_string(), PromptType::PlayMacro);
938            }
939            Action::PlayLastMacro => {
940                if let Some(key) = self.last_macro_register {
941                    self.play_macro(key);
942                } else {
943                    self.set_status_message(t!("status.no_macro_recorded").to_string());
944                }
945            }
946            Action::PromptSetBookmark => {
947                self.start_prompt("Set bookmark (0-9): ".to_string(), PromptType::SetBookmark);
948            }
949            Action::PromptJumpToBookmark => {
950                self.start_prompt(
951                    "Jump to bookmark (0-9): ".to_string(),
952                    PromptType::JumpToBookmark,
953                );
954            }
955            Action::None => {}
956            Action::DeleteBackward => {
957                if self.is_editing_disabled() {
958                    self.set_status_message(t!("buffer.editing_disabled").to_string());
959                    return Ok(());
960                }
961                // Normal backspace handling
962                if let Some(events) = self.action_to_events(Action::DeleteBackward) {
963                    if events.len() > 1 {
964                        // Multi-cursor: use optimized bulk edit (O(n) instead of O(n²))
965                        let description = "Delete backward".to_string();
966                        if let Some(bulk_edit) = self.apply_events_as_bulk_edit(events, description)
967                        {
968                            self.active_event_log_mut().append(bulk_edit);
969                        }
970                    } else {
971                        for event in events {
972                            self.active_event_log_mut().append(event.clone());
973                            self.apply_event_to_active_buffer(&event);
974                        }
975                    }
976                }
977            }
978            Action::PluginAction(action_name) => {
979                tracing::debug!("handle_action: PluginAction('{}')", action_name);
980                // Execute the plugin callback via TypeScript plugin thread
981                // Use non-blocking version to avoid deadlock with async plugin ops
982                #[cfg(feature = "plugins")]
983                if let Some(result) = self.plugin_manager.execute_action_async(&action_name) {
984                    match result {
985                        Ok(receiver) => {
986                            // Store pending action for processing in main loop
987                            self.pending_plugin_actions
988                                .push((action_name.clone(), receiver));
989                        }
990                        Err(e) => {
991                            self.set_status_message(
992                                t!("view.plugin_error", error = e.to_string()).to_string(),
993                            );
994                            tracing::error!("Plugin action error: {}", e);
995                        }
996                    }
997                } else {
998                    self.set_status_message(t!("status.plugin_manager_unavailable").to_string());
999                }
1000                #[cfg(not(feature = "plugins"))]
1001                {
1002                    let _ = action_name;
1003                    self.set_status_message(
1004                        "Plugins not available (compiled without plugin support)".to_string(),
1005                    );
1006                }
1007            }
1008            Action::OpenTerminal => {
1009                self.open_terminal();
1010            }
1011            Action::CloseTerminal => {
1012                self.close_terminal();
1013            }
1014            Action::FocusTerminal => {
1015                // If viewing a terminal buffer, switch to terminal mode
1016                if self.is_terminal_buffer(self.active_buffer()) {
1017                    self.terminal_mode = true;
1018                    self.key_context = KeyContext::Terminal;
1019                    self.set_status_message(t!("status.terminal_mode_enabled").to_string());
1020                }
1021            }
1022            Action::TerminalEscape => {
1023                // Exit terminal mode back to editor
1024                if self.terminal_mode {
1025                    self.terminal_mode = false;
1026                    self.key_context = KeyContext::Normal;
1027                    self.set_status_message(t!("status.terminal_mode_disabled").to_string());
1028                }
1029            }
1030            Action::ToggleKeyboardCapture => {
1031                // Toggle keyboard capture mode in terminal
1032                if self.terminal_mode {
1033                    self.keyboard_capture = !self.keyboard_capture;
1034                    if self.keyboard_capture {
1035                        self.set_status_message(
1036                            "Keyboard capture ON - all keys go to terminal (F9 to toggle)"
1037                                .to_string(),
1038                        );
1039                    } else {
1040                        self.set_status_message(
1041                            "Keyboard capture OFF - UI bindings active (F9 to toggle)".to_string(),
1042                        );
1043                    }
1044                }
1045            }
1046            Action::TerminalPaste => {
1047                // Paste clipboard contents into terminal as a single batch
1048                if self.terminal_mode {
1049                    if let Some(text) = self.clipboard.paste() {
1050                        self.send_terminal_input(text.as_bytes());
1051                    }
1052                }
1053            }
1054            Action::ShellCommand => {
1055                // Run shell command on buffer/selection, output to new buffer
1056                self.start_shell_command_prompt(false);
1057            }
1058            Action::ShellCommandReplace => {
1059                // Run shell command on buffer/selection, replace content
1060                self.start_shell_command_prompt(true);
1061            }
1062            Action::OpenSettings => {
1063                self.open_settings();
1064            }
1065            Action::CloseSettings => {
1066                // Check if there are unsaved changes
1067                let has_changes = self
1068                    .settings_state
1069                    .as_ref()
1070                    .is_some_and(|s| s.has_changes());
1071                if has_changes {
1072                    // Show confirmation dialog
1073                    if let Some(ref mut state) = self.settings_state {
1074                        state.show_confirm_dialog();
1075                    }
1076                } else {
1077                    self.close_settings(false);
1078                }
1079            }
1080            Action::SettingsSave => {
1081                self.save_settings();
1082            }
1083            Action::SettingsReset => {
1084                if let Some(ref mut state) = self.settings_state {
1085                    state.reset_current_to_default();
1086                }
1087            }
1088            Action::SettingsToggleFocus => {
1089                if let Some(ref mut state) = self.settings_state {
1090                    state.toggle_focus();
1091                }
1092            }
1093            Action::SettingsActivate => {
1094                self.settings_activate_current();
1095            }
1096            Action::SettingsSearch => {
1097                if let Some(ref mut state) = self.settings_state {
1098                    state.start_search();
1099                }
1100            }
1101            Action::SettingsHelp => {
1102                if let Some(ref mut state) = self.settings_state {
1103                    state.toggle_help();
1104                }
1105            }
1106            Action::SettingsIncrement => {
1107                self.settings_increment_current();
1108            }
1109            Action::SettingsDecrement => {
1110                self.settings_decrement_current();
1111            }
1112            Action::CalibrateInput => {
1113                self.open_calibration_wizard();
1114            }
1115            Action::EventDebug => {
1116                self.open_event_debug();
1117            }
1118            Action::PromptConfirm => {
1119                if let Some((input, prompt_type, selected_index)) = self.confirm_prompt() {
1120                    use super::prompt_actions::PromptResult;
1121                    match self.handle_prompt_confirm_input(input, prompt_type, selected_index) {
1122                        PromptResult::ExecuteAction(action) => {
1123                            return self.handle_action(action);
1124                        }
1125                        PromptResult::EarlyReturn => {
1126                            return Ok(());
1127                        }
1128                        PromptResult::Done => {}
1129                    }
1130                }
1131            }
1132            Action::PromptConfirmWithText(ref text) => {
1133                // For macro playback: set the prompt text before confirming
1134                if let Some(ref mut prompt) = self.prompt {
1135                    prompt.set_input(text.clone());
1136                    self.update_prompt_suggestions();
1137                }
1138                if let Some((input, prompt_type, selected_index)) = self.confirm_prompt() {
1139                    use super::prompt_actions::PromptResult;
1140                    match self.handle_prompt_confirm_input(input, prompt_type, selected_index) {
1141                        PromptResult::ExecuteAction(action) => {
1142                            return self.handle_action(action);
1143                        }
1144                        PromptResult::EarlyReturn => {
1145                            return Ok(());
1146                        }
1147                        PromptResult::Done => {}
1148                    }
1149                }
1150            }
1151            Action::PopupConfirm => {
1152                use super::popup_actions::PopupConfirmResult;
1153                if let PopupConfirmResult::EarlyReturn = self.handle_popup_confirm() {
1154                    return Ok(());
1155                }
1156            }
1157            Action::PopupCancel => {
1158                self.handle_popup_cancel();
1159            }
1160            Action::InsertChar(c) => {
1161                if self.is_prompting() {
1162                    return self.handle_insert_char_prompt(c);
1163                } else {
1164                    self.handle_insert_char_editor(c)?;
1165                }
1166            }
1167            // Prompt clipboard actions
1168            Action::PromptCopy => {
1169                if let Some(prompt) = &self.prompt {
1170                    let text = prompt.selected_text().unwrap_or_else(|| prompt.get_text());
1171                    if !text.is_empty() {
1172                        self.clipboard.copy(text);
1173                        self.set_status_message(t!("clipboard.copied").to_string());
1174                    }
1175                }
1176            }
1177            Action::PromptCut => {
1178                if let Some(prompt) = &self.prompt {
1179                    let text = prompt.selected_text().unwrap_or_else(|| prompt.get_text());
1180                    if !text.is_empty() {
1181                        self.clipboard.copy(text);
1182                    }
1183                }
1184                if let Some(prompt) = self.prompt.as_mut() {
1185                    if prompt.has_selection() {
1186                        prompt.delete_selection();
1187                    } else {
1188                        prompt.clear();
1189                    }
1190                }
1191                self.set_status_message(t!("clipboard.cut").to_string());
1192                self.update_prompt_suggestions();
1193            }
1194            Action::PromptPaste => {
1195                if let Some(text) = self.clipboard.paste() {
1196                    if let Some(prompt) = self.prompt.as_mut() {
1197                        prompt.insert_str(&text);
1198                    }
1199                    self.update_prompt_suggestions();
1200                }
1201            }
1202            _ => {
1203                // TODO: Why do we have this catch-all? It seems like actions should either:
1204                // 1. Be handled explicitly above (like InsertChar, PopupConfirm, etc.)
1205                // 2. Or be converted to events consistently
1206                // This catch-all makes it unclear which actions go through event conversion
1207                // vs. direct handling. Consider making this explicit or removing the pattern.
1208                self.apply_action_as_events(action)?;
1209            }
1210        }
1211
1212        Ok(())
1213    }
1214
1215    /// Handle mouse wheel scroll event
1216    pub(super) fn handle_mouse_scroll(
1217        &mut self,
1218        col: u16,
1219        row: u16,
1220        delta: i32,
1221    ) -> AnyhowResult<()> {
1222        // Sync viewport from EditorState to SplitViewState before scrolling.
1223        // This is necessary because rendering updates EditorState.viewport via ensure_visible,
1224        // but that change isn't automatically synced to SplitViewState. Without this sync,
1225        // mouse scroll would use a stale viewport position after keyboard navigation.
1226        // (Bug #248: Mouse wheel stopped working properly after keyboard use)
1227        self.sync_editor_state_to_split_view_state();
1228
1229        // Check if scroll is over the file explorer
1230        if let Some(explorer_area) = self.cached_layout.file_explorer_area {
1231            if col >= explorer_area.x
1232                && col < explorer_area.x + explorer_area.width
1233                && row >= explorer_area.y
1234                && row < explorer_area.y + explorer_area.height
1235            {
1236                // Scroll the file explorer
1237                if let Some(explorer) = &mut self.file_explorer {
1238                    let visible = explorer.tree().get_visible_nodes();
1239                    if visible.is_empty() {
1240                        return Ok(());
1241                    }
1242
1243                    // Get current selected index
1244                    let current_index = explorer.get_selected_index().unwrap_or(0);
1245
1246                    // Calculate new index based on scroll delta
1247                    let new_index = if delta < 0 {
1248                        // Scroll up (negative delta)
1249                        current_index.saturating_sub(delta.unsigned_abs() as usize)
1250                    } else {
1251                        // Scroll down (positive delta)
1252                        (current_index + delta as usize).min(visible.len() - 1)
1253                    };
1254
1255                    // Set the new selection
1256                    if let Some(node_id) = explorer.get_node_at_index(new_index) {
1257                        explorer.set_selected(Some(node_id));
1258                        explorer.update_scroll_for_selection();
1259                    }
1260                }
1261                return Ok(());
1262            }
1263        }
1264
1265        // Otherwise, scroll the editor in the active split
1266        // Use SplitViewState's viewport (View events go to SplitViewState, not EditorState)
1267        let active_split = self.split_manager.active_split();
1268        let buffer_id = self.active_buffer();
1269
1270        // Check if this is a composite buffer - if so, use composite scroll
1271        if self.is_composite_buffer(buffer_id) {
1272            let max_row = self
1273                .composite_buffers
1274                .get(&buffer_id)
1275                .map(|c| c.row_count().saturating_sub(1))
1276                .unwrap_or(0);
1277            if let Some(view_state) = self
1278                .composite_view_states
1279                .get_mut(&(active_split, buffer_id))
1280            {
1281                view_state.scroll(delta as isize, max_row);
1282                tracing::trace!(
1283                    "handle_mouse_scroll (composite): delta={}, scroll_row={}",
1284                    delta,
1285                    view_state.scroll_row
1286                );
1287            }
1288            return Ok(());
1289        }
1290
1291        // Get view_transform tokens from SplitViewState (if any)
1292        let view_transform_tokens = self
1293            .split_view_states
1294            .get(&active_split)
1295            .and_then(|vs| vs.view_transform.as_ref())
1296            .map(|vt| vt.tokens.clone());
1297
1298        // Get mutable references to both buffer and view state
1299        let buffer = self.buffers.get_mut(&buffer_id).map(|s| &mut s.buffer);
1300        let view_state = self.split_view_states.get_mut(&active_split);
1301
1302        if let (Some(buffer), Some(view_state)) = (buffer, view_state) {
1303            let top_byte_before = view_state.viewport.top_byte;
1304            if let Some(tokens) = view_transform_tokens {
1305                // Use view-aware scrolling with the transform's tokens
1306                use crate::view::ui::view_pipeline::ViewLineIterator;
1307                let tab_size = self.config.editor.tab_size;
1308                let view_lines: Vec<_> =
1309                    ViewLineIterator::new(&tokens, false, false, tab_size).collect();
1310                view_state
1311                    .viewport
1312                    .scroll_view_lines(&view_lines, delta as isize);
1313            } else {
1314                // No view transform - use traditional buffer-based scrolling
1315                if delta < 0 {
1316                    // Scroll up
1317                    let lines_to_scroll = delta.unsigned_abs() as usize;
1318                    view_state.viewport.scroll_up(buffer, lines_to_scroll);
1319                } else {
1320                    // Scroll down
1321                    let lines_to_scroll = delta as usize;
1322                    view_state.viewport.scroll_down(buffer, lines_to_scroll);
1323                }
1324            }
1325            // Skip ensure_visible so the scroll position isn't undone during render
1326            view_state.viewport.set_skip_ensure_visible();
1327            tracing::trace!(
1328                "handle_mouse_scroll: delta={}, top_byte {} -> {}",
1329                delta,
1330                top_byte_before,
1331                view_state.viewport.top_byte
1332            );
1333        }
1334
1335        Ok(())
1336    }
1337
1338    /// Handle scrollbar drag with relative movement (when dragging from thumb)
1339    pub(super) fn handle_scrollbar_drag_relative(
1340        &mut self,
1341        row: u16,
1342        split_id: SplitId,
1343        buffer_id: BufferId,
1344        scrollbar_rect: ratatui::layout::Rect,
1345    ) -> AnyhowResult<()> {
1346        let drag_start_row = match self.mouse_state.drag_start_row {
1347            Some(r) => r,
1348            None => return Ok(()), // No drag start, shouldn't happen
1349        };
1350
1351        let drag_start_top_byte = match self.mouse_state.drag_start_top_byte {
1352            Some(b) => b,
1353            None => return Ok(()), // No drag start, shouldn't happen
1354        };
1355
1356        // Calculate the offset in rows
1357        let row_offset = (row as i32) - (drag_start_row as i32);
1358
1359        // Get viewport height from SplitViewState
1360        let viewport_height = self
1361            .split_view_states
1362            .get(&split_id)
1363            .map(|vs| vs.viewport.height as usize)
1364            .unwrap_or(10);
1365
1366        // Get the buffer state and calculate target position
1367        let line_start = if let Some(state) = self.buffers.get_mut(&buffer_id) {
1368            let scrollbar_height = scrollbar_rect.height as usize;
1369            if scrollbar_height == 0 {
1370                return Ok(());
1371            }
1372
1373            let buffer_len = state.buffer.len();
1374            let large_file_threshold = self.config.editor.large_file_threshold_bytes as usize;
1375
1376            // For small files, use precise line-based calculations
1377            // For large files, fall back to byte-based estimation
1378            let new_top_byte = if buffer_len <= large_file_threshold {
1379                // Small file: use line-based calculation for precision
1380                // Count total lines
1381                let total_lines = if buffer_len > 0 {
1382                    state.buffer.get_line_number(buffer_len.saturating_sub(1)) + 1
1383                } else {
1384                    1
1385                };
1386
1387                // Calculate max scroll line
1388                let max_scroll_line = total_lines.saturating_sub(viewport_height);
1389
1390                if max_scroll_line == 0 {
1391                    // File fits in viewport, no scrolling
1392                    0
1393                } else {
1394                    // Calculate which line the mouse position corresponds to using linear interpolation
1395                    // Convert absolute mouse row to relative position within scrollbar
1396                    let relative_mouse_row = row.saturating_sub(scrollbar_rect.y) as usize;
1397                    // Divide by (height - 1) to map first row to 0.0 and last row to 1.0
1398                    let scroll_ratio = if scrollbar_height > 1 {
1399                        (relative_mouse_row as f64 / (scrollbar_height - 1) as f64).clamp(0.0, 1.0)
1400                    } else {
1401                        0.0
1402                    };
1403
1404                    // Map scroll ratio to target line
1405                    let target_line = (scroll_ratio * max_scroll_line as f64).round() as usize;
1406                    let target_line = target_line.min(max_scroll_line);
1407
1408                    // Find byte position of target line
1409                    // We need to iterate 'target_line' times to skip past lines 0..target_line-1,
1410                    // then one more time to get the position of line 'target_line'
1411                    let mut iter = state.buffer.line_iterator(0, 80);
1412                    let mut line_byte = 0;
1413
1414                    for _ in 0..target_line {
1415                        if let Some((pos, _content)) = iter.next_line() {
1416                            line_byte = pos;
1417                        } else {
1418                            break;
1419                        }
1420                    }
1421
1422                    // Get the position of the target line
1423                    if let Some((pos, _)) = iter.next_line() {
1424                        pos
1425                    } else {
1426                        line_byte // Reached end of buffer
1427                    }
1428                }
1429            } else {
1430                // Large file: use byte-based estimation (original logic)
1431                let bytes_per_pixel = buffer_len as f64 / scrollbar_height as f64;
1432                let byte_offset = (row_offset as f64 * bytes_per_pixel) as i64;
1433
1434                let new_top_byte = if byte_offset >= 0 {
1435                    drag_start_top_byte.saturating_add(byte_offset as usize)
1436                } else {
1437                    drag_start_top_byte.saturating_sub((-byte_offset) as usize)
1438                };
1439
1440                // Clamp to valid range using byte-based max (avoid iterating entire buffer)
1441                new_top_byte.min(buffer_len.saturating_sub(1))
1442            };
1443
1444            // Find the line start for this byte position
1445            let iter = state.buffer.line_iterator(new_top_byte, 80);
1446            iter.current_position()
1447        } else {
1448            return Ok(());
1449        };
1450
1451        // Set viewport top to this position in SplitViewState
1452        if let Some(view_state) = self.split_view_states.get_mut(&split_id) {
1453            view_state.viewport.top_byte = line_start;
1454            // Skip ensure_visible so the scroll position isn't undone during render
1455            view_state.viewport.set_skip_ensure_visible();
1456        }
1457
1458        // Move cursor to be visible in the new viewport (after releasing the state borrow)
1459        self.move_cursor_to_visible_area(split_id, buffer_id);
1460
1461        Ok(())
1462    }
1463
1464    /// Handle scrollbar jump (clicking on track or absolute positioning)
1465    pub(super) fn handle_scrollbar_jump(
1466        &mut self,
1467        _col: u16,
1468        row: u16,
1469        split_id: SplitId,
1470        buffer_id: BufferId,
1471        scrollbar_rect: ratatui::layout::Rect,
1472    ) -> AnyhowResult<()> {
1473        // Calculate which line to scroll to based on mouse position
1474        let scrollbar_height = scrollbar_rect.height as usize;
1475        if scrollbar_height == 0 {
1476            return Ok(());
1477        }
1478
1479        // Get relative position in scrollbar (0.0 to 1.0)
1480        // Divide by (height - 1) to map first row to 0.0 and last row to 1.0
1481        let relative_row = row.saturating_sub(scrollbar_rect.y);
1482        let ratio = if scrollbar_height > 1 {
1483            ((relative_row as f64) / ((scrollbar_height - 1) as f64)).clamp(0.0, 1.0)
1484        } else {
1485            0.0
1486        };
1487
1488        // Get viewport height from SplitViewState
1489        let viewport_height = self
1490            .split_view_states
1491            .get(&split_id)
1492            .map(|vs| vs.viewport.height as usize)
1493            .unwrap_or(10);
1494
1495        // Get the buffer state and calculate limited_line_start
1496        let limited_line_start = if let Some(state) = self.buffers.get_mut(&buffer_id) {
1497            let buffer_len = state.buffer.len();
1498            let large_file_threshold = self.config.editor.large_file_threshold_bytes as usize;
1499
1500            // For small files, use precise line-based calculations
1501            // For large files, fall back to byte-based estimation
1502            let target_byte = if buffer_len <= large_file_threshold {
1503                // Small file: use line-based calculation for precision
1504                let total_lines = if buffer_len > 0 {
1505                    state.buffer.get_line_number(buffer_len.saturating_sub(1)) + 1
1506                } else {
1507                    1
1508                };
1509
1510                let max_scroll_line = total_lines.saturating_sub(viewport_height);
1511
1512                if max_scroll_line == 0 {
1513                    // File fits in viewport, no scrolling
1514                    0
1515                } else {
1516                    // Map ratio to target line
1517                    let target_line = (ratio * max_scroll_line as f64).round() as usize;
1518                    let target_line = target_line.min(max_scroll_line);
1519
1520                    // Find byte position of target line
1521                    // We need to iterate 'target_line' times to skip past lines 0..target_line-1,
1522                    // then one more time to get the position of line 'target_line'
1523                    let mut iter = state.buffer.line_iterator(0, 80);
1524                    let mut line_byte = 0;
1525
1526                    for _ in 0..target_line {
1527                        if let Some((pos, _content)) = iter.next_line() {
1528                            line_byte = pos;
1529                        } else {
1530                            break;
1531                        }
1532                    }
1533
1534                    // Get the position of the target line
1535                    if let Some((pos, _)) = iter.next_line() {
1536                        pos
1537                    } else {
1538                        line_byte // Reached end of buffer
1539                    }
1540                }
1541            } else {
1542                // Large file: use byte-based estimation (original logic)
1543                let target_byte = (buffer_len as f64 * ratio) as usize;
1544                target_byte.min(buffer_len.saturating_sub(1))
1545            };
1546
1547            // Find the line start for this byte position
1548            let iter = state.buffer.line_iterator(target_byte, 80);
1549            let line_start = iter.current_position();
1550
1551            // Apply scroll limiting
1552            // Use viewport.height (constant allocated rows) not visible_line_count (varies with content)
1553            // For large files, use byte-based max to avoid iterating entire buffer
1554            let max_top_byte = if buffer_len <= large_file_threshold {
1555                Self::calculate_max_scroll_position(&mut state.buffer, viewport_height)
1556            } else {
1557                buffer_len.saturating_sub(1)
1558            };
1559            line_start.min(max_top_byte)
1560        } else {
1561            return Ok(());
1562        };
1563
1564        // Set viewport top to this position in SplitViewState
1565        if let Some(view_state) = self.split_view_states.get_mut(&split_id) {
1566            view_state.viewport.top_byte = limited_line_start;
1567            // Skip ensure_visible so the scroll position isn't undone during render
1568            view_state.viewport.set_skip_ensure_visible();
1569        }
1570
1571        // Move cursor to be visible in the new viewport (after releasing the state borrow)
1572        self.move_cursor_to_visible_area(split_id, buffer_id);
1573
1574        Ok(())
1575    }
1576
1577    /// Move the cursor to a visible position within the current viewport
1578    /// This is called after scrollbar operations to ensure the cursor is in view
1579    pub(super) fn move_cursor_to_visible_area(&mut self, split_id: SplitId, buffer_id: BufferId) {
1580        // Get viewport info from SplitViewState
1581        let (top_byte, viewport_height) =
1582            if let Some(view_state) = self.split_view_states.get(&split_id) {
1583                (
1584                    view_state.viewport.top_byte,
1585                    view_state.viewport.height as usize,
1586                )
1587            } else {
1588                return;
1589            };
1590
1591        if let Some(state) = self.buffers.get_mut(&buffer_id) {
1592            let buffer_len = state.buffer.len();
1593
1594            // Find the bottom byte of the viewport
1595            // We iterate through viewport_height lines starting from top_byte
1596            let mut iter = state.buffer.line_iterator(top_byte, 80);
1597            let mut bottom_byte = buffer_len;
1598
1599            // Consume viewport_height lines to find where the visible area ends
1600            for _ in 0..viewport_height {
1601                if let Some((pos, line)) = iter.next_line() {
1602                    // The bottom of this line is at pos + line.len()
1603                    bottom_byte = pos + line.len();
1604                } else {
1605                    // Reached end of buffer
1606                    bottom_byte = buffer_len;
1607                    break;
1608                }
1609            }
1610
1611            // Check if cursor is outside visible range and move it if needed
1612            let cursor_pos = state.cursors.primary().position;
1613            if cursor_pos < top_byte || cursor_pos > bottom_byte {
1614                // Move cursor to the top of the viewport
1615                let cursor = state.cursors.primary_mut();
1616                cursor.position = top_byte;
1617                // Keep the existing sticky_column value so vertical navigation preserves column
1618            }
1619        }
1620    }
1621
1622    /// Calculate the maximum allowed scroll position
1623    /// Ensures the last line is always at the bottom unless the buffer is smaller than viewport
1624    pub(super) fn calculate_max_scroll_position(
1625        buffer: &mut crate::model::buffer::Buffer,
1626        viewport_height: usize,
1627    ) -> usize {
1628        if viewport_height == 0 {
1629            return 0;
1630        }
1631
1632        let buffer_len = buffer.len();
1633        if buffer_len == 0 {
1634            return 0;
1635        }
1636
1637        // Count total lines in buffer
1638        let mut line_count = 0;
1639        let mut iter = buffer.line_iterator(0, 80);
1640        while iter.next_line().is_some() {
1641            line_count += 1;
1642        }
1643
1644        // If buffer has fewer lines than viewport, can't scroll at all
1645        if line_count <= viewport_height {
1646            return 0;
1647        }
1648
1649        // Calculate how many lines from the start we can scroll
1650        // We want to be able to scroll so that the last line is at the bottom
1651        let scrollable_lines = line_count.saturating_sub(viewport_height);
1652
1653        // Find the byte position of the line at scrollable_lines offset
1654        let mut iter = buffer.line_iterator(0, 80);
1655        let mut current_line = 0;
1656        let mut max_byte_pos = 0;
1657
1658        while current_line < scrollable_lines {
1659            if let Some((pos, _content)) = iter.next_line() {
1660                max_byte_pos = pos;
1661                current_line += 1;
1662            } else {
1663                break;
1664            }
1665        }
1666
1667        max_byte_pos
1668    }
1669
1670    /// Calculate buffer byte position from screen coordinates
1671    ///
1672    /// Returns None if the position cannot be determined (e.g., click in gutter for click handler)
1673    pub(crate) fn screen_to_buffer_position(
1674        col: u16,
1675        row: u16,
1676        content_rect: ratatui::layout::Rect,
1677        gutter_width: u16,
1678        cached_mappings: &Option<Vec<crate::app::types::ViewLineMapping>>,
1679        fallback_position: usize,
1680        allow_gutter_click: bool,
1681    ) -> Option<usize> {
1682        // Calculate relative position in content area
1683        let content_col = col.saturating_sub(content_rect.x);
1684        let content_row = row.saturating_sub(content_rect.y);
1685
1686        tracing::trace!(
1687            col,
1688            row,
1689            ?content_rect,
1690            gutter_width,
1691            content_col,
1692            content_row,
1693            num_mappings = cached_mappings.as_ref().map(|m| m.len()),
1694            "screen_to_buffer_position"
1695        );
1696
1697        // Handle gutter clicks
1698        let text_col = if content_col < gutter_width {
1699            if !allow_gutter_click {
1700                return None; // Click handler skips gutter clicks
1701            }
1702            0 // Drag handler uses position 0 of the line
1703        } else {
1704            content_col.saturating_sub(gutter_width) as usize
1705        };
1706
1707        // Use cached view line mappings for accurate position lookup
1708        let visual_row = content_row as usize;
1709
1710        // Helper to get position from a line mapping at a given visual column
1711        let position_from_mapping =
1712            |line_mapping: &crate::app::types::ViewLineMapping, col: usize| -> usize {
1713                if col < line_mapping.visual_to_char.len() {
1714                    // Use O(1) lookup: visual column -> char index -> source byte
1715                    if let Some(byte_pos) = line_mapping.source_byte_at_visual_col(col) {
1716                        return byte_pos;
1717                    }
1718                    // Column maps to virtual/injected content - find nearest real position
1719                    for c in (0..col).rev() {
1720                        if let Some(byte_pos) = line_mapping.source_byte_at_visual_col(c) {
1721                            return byte_pos;
1722                        }
1723                    }
1724                    line_mapping.line_end_byte
1725                } else {
1726                    // Click is past end of visible content
1727                    // For empty lines (only a newline), return the line start position
1728                    // to keep cursor on this line rather than jumping to the next line
1729                    if line_mapping.visual_to_char.len() <= 1 {
1730                        // Empty or newline-only line - return first source byte if available
1731                        if let Some(Some(first_byte)) = line_mapping.char_source_bytes.first() {
1732                            return *first_byte;
1733                        }
1734                    }
1735                    line_mapping.line_end_byte
1736                }
1737            };
1738
1739        let position = cached_mappings
1740            .as_ref()
1741            .and_then(|mappings| {
1742                if let Some(line_mapping) = mappings.get(visual_row) {
1743                    // Click is on a visible line
1744                    Some(position_from_mapping(line_mapping, text_col))
1745                } else if !mappings.is_empty() {
1746                    // Click is below last visible line - use the last line at the clicked column
1747                    let last_mapping = mappings.last().unwrap();
1748                    Some(position_from_mapping(last_mapping, text_col))
1749                } else {
1750                    None
1751                }
1752            })
1753            .unwrap_or(fallback_position);
1754
1755        Some(position)
1756    }
1757
1758    /// Handle click in editor content area
1759    pub(super) fn handle_editor_click(
1760        &mut self,
1761        col: u16,
1762        row: u16,
1763        split_id: crate::model::event::SplitId,
1764        buffer_id: BufferId,
1765        content_rect: ratatui::layout::Rect,
1766        modifiers: crossterm::event::KeyModifiers,
1767    ) -> AnyhowResult<()> {
1768        use crate::model::event::Event;
1769        use crossterm::event::KeyModifiers;
1770
1771        // Build modifiers string for plugins
1772        let modifiers_str = if modifiers.contains(KeyModifiers::SHIFT) {
1773            "shift".to_string()
1774        } else {
1775            String::new()
1776        };
1777
1778        // Dispatch MouseClick hook to plugins
1779        // Plugins can handle clicks on their virtual buffers
1780        if self.plugin_manager.has_hook_handlers("mouse_click") {
1781            self.plugin_manager.run_hook(
1782                "mouse_click",
1783                HookArgs::MouseClick {
1784                    column: col,
1785                    row,
1786                    button: "left".to_string(),
1787                    modifiers: modifiers_str,
1788                    content_x: content_rect.x,
1789                    content_y: content_rect.y,
1790                },
1791            );
1792        }
1793
1794        // Focus this split (handles terminal mode exit, tab state, etc.)
1795        self.focus_split(split_id, buffer_id);
1796
1797        // Handle composite buffer clicks specially
1798        if self.is_composite_buffer(buffer_id) {
1799            return self.handle_composite_click(col, row, split_id, buffer_id, content_rect);
1800        }
1801
1802        // Ensure key context is Normal for non-terminal buffers
1803        // This handles the edge case where split/buffer don't change but we clicked from FileExplorer
1804        if !self.is_terminal_buffer(buffer_id) {
1805            self.key_context = crate::input::keybindings::KeyContext::Normal;
1806        }
1807
1808        // Get cached view line mappings for this split (before mutable borrow of buffers)
1809        let cached_mappings = self
1810            .cached_layout
1811            .view_line_mappings
1812            .get(&split_id)
1813            .cloned();
1814
1815        // Get fallback from SplitViewState viewport
1816        let fallback = self
1817            .split_view_states
1818            .get(&split_id)
1819            .map(|vs| vs.viewport.top_byte)
1820            .unwrap_or(0);
1821
1822        // Calculate clicked position in buffer
1823        if let Some(state) = self.buffers.get_mut(&buffer_id) {
1824            let gutter_width = state.margins.left_total_width() as u16;
1825
1826            let Some(target_position) = Self::screen_to_buffer_position(
1827                col,
1828                row,
1829                content_rect,
1830                gutter_width,
1831                &cached_mappings,
1832                fallback,
1833                true, // Allow gutter clicks - position cursor at start of line
1834            ) else {
1835                return Ok(());
1836            };
1837
1838            // Check for onClick text property at this position
1839            // This enables clickable UI elements in virtual buffers
1840            let onclick_action = state
1841                .text_properties
1842                .get_at(target_position)
1843                .iter()
1844                .find_map(|prop| {
1845                    prop.get("onClick")
1846                        .and_then(|v| v.as_str())
1847                        .map(|s| s.to_string())
1848                });
1849
1850            if let Some(action_name) = onclick_action {
1851                // Execute the action associated with this clickable element
1852                tracing::debug!(
1853                    "onClick triggered at position {}: action={}",
1854                    target_position,
1855                    action_name
1856                );
1857                let empty_args = std::collections::HashMap::new();
1858                if let Some(action) = Action::from_str(&action_name, &empty_args) {
1859                    return self.handle_action(action);
1860                }
1861                return Ok(());
1862            }
1863
1864            // Move the primary cursor to this position
1865            // If shift is held, extend selection; otherwise clear it
1866            let primary_cursor_id = state.cursors.primary_id();
1867            let primary_cursor = state.cursors.primary();
1868            let old_position = primary_cursor.position;
1869            let old_anchor = primary_cursor.anchor;
1870
1871            // For shift+click or ctrl+click: extend selection from current anchor (or position if no anchor) to click
1872            // Both modifiers supported since some terminals intercept shift+click
1873            let extend_selection = modifiers.contains(KeyModifiers::SHIFT)
1874                || modifiers.contains(KeyModifiers::CONTROL);
1875            let new_anchor = if extend_selection {
1876                // If already selecting, keep the existing anchor; otherwise anchor at current position
1877                Some(old_anchor.unwrap_or(old_position))
1878            } else {
1879                None // Clear selection on normal click
1880            };
1881
1882            let event = Event::MoveCursor {
1883                cursor_id: primary_cursor_id,
1884                old_position,
1885                new_position: target_position,
1886                old_anchor,
1887                new_anchor,
1888                old_sticky_column: 0,
1889                new_sticky_column: 0, // Reset sticky column for goto line
1890            };
1891
1892            // Apply the event
1893            if let Some(event_log) = self.event_logs.get_mut(&buffer_id) {
1894                event_log.append(event.clone());
1895            }
1896            state.apply(&event);
1897
1898            // Track position history
1899            if !self.in_navigation {
1900                self.position_history
1901                    .record_movement(buffer_id, target_position, None);
1902            }
1903
1904            // Set up drag selection state for potential text selection
1905            self.mouse_state.dragging_text_selection = true;
1906            self.mouse_state.drag_selection_split = Some(split_id);
1907            // For shift+click, anchor stays at selection start; otherwise anchor at click position
1908            self.mouse_state.drag_selection_anchor = Some(new_anchor.unwrap_or(target_position));
1909        }
1910
1911        Ok(())
1912    }
1913
1914    /// Handle click in file explorer
1915    pub(super) fn handle_file_explorer_click(
1916        &mut self,
1917        col: u16,
1918        row: u16,
1919        explorer_area: ratatui::layout::Rect,
1920    ) -> AnyhowResult<()> {
1921        // Check if click is on the title bar (first row)
1922        if row == explorer_area.y {
1923            // Check if click is on close button (× at right side of title bar)
1924            // Close button is at position: explorer_area.x + explorer_area.width - 3 to -1
1925            let close_button_x = explorer_area.x + explorer_area.width.saturating_sub(3);
1926            if col >= close_button_x && col < explorer_area.x + explorer_area.width {
1927                self.toggle_file_explorer();
1928                return Ok(());
1929            }
1930        }
1931
1932        // Focus file explorer
1933        self.key_context = crate::input::keybindings::KeyContext::FileExplorer;
1934
1935        // Calculate which item was clicked (accounting for border and title)
1936        // The file explorer has a 1-line border at top and bottom
1937        let relative_row = row.saturating_sub(explorer_area.y + 1); // +1 for top border
1938
1939        if let Some(ref mut explorer) = self.file_explorer {
1940            let display_nodes = explorer.get_display_nodes();
1941            let scroll_offset = explorer.get_scroll_offset();
1942            let clicked_index = (relative_row as usize) + scroll_offset;
1943
1944            if clicked_index < display_nodes.len() {
1945                let (node_id, _indent) = display_nodes[clicked_index];
1946
1947                // Select this node
1948                explorer.set_selected(Some(node_id));
1949
1950                // Check if it's a file or directory
1951                let node = explorer.tree().get_node(node_id);
1952                if let Some(node) = node {
1953                    if node.is_dir() {
1954                        // Toggle expand/collapse using the existing method
1955                        self.file_explorer_toggle_expand();
1956                    } else if node.is_file() {
1957                        // Open the file but keep focus on file explorer (single click)
1958                        // Double-click or Enter will focus the editor
1959                        let path = node.entry.path.clone();
1960                        let name = node.entry.name.clone();
1961                        self.open_file(&path)?;
1962                        self.set_status_message(
1963                            rust_i18n::t!("explorer.opened_file", name = &name).to_string(),
1964                        );
1965                    }
1966                }
1967            }
1968        }
1969
1970        Ok(())
1971    }
1972
1973    /// Start the line ending selection prompt
1974    fn start_set_line_ending_prompt(&mut self) {
1975        use crate::model::buffer::LineEnding;
1976
1977        let current_line_ending = self.active_state().buffer.line_ending();
1978
1979        let options = [
1980            (LineEnding::LF, "LF", "Unix/Linux/Mac"),
1981            (LineEnding::CRLF, "CRLF", "Windows"),
1982            (LineEnding::CR, "CR", "Classic Mac"),
1983        ];
1984
1985        let current_index = options
1986            .iter()
1987            .position(|(le, _, _)| *le == current_line_ending)
1988            .unwrap_or(0);
1989
1990        let suggestions: Vec<crate::input::commands::Suggestion> = options
1991            .iter()
1992            .map(|(le, name, desc)| {
1993                let is_current = *le == current_line_ending;
1994                crate::input::commands::Suggestion {
1995                    text: format!("{} ({})", name, desc),
1996                    description: if is_current {
1997                        Some("current".to_string())
1998                    } else {
1999                        None
2000                    },
2001                    value: Some(name.to_string()),
2002                    disabled: false,
2003                    keybinding: None,
2004                    source: None,
2005                }
2006            })
2007            .collect();
2008
2009        self.prompt = Some(crate::view::prompt::Prompt::with_suggestions(
2010            "Line ending: ".to_string(),
2011            PromptType::SetLineEnding,
2012            suggestions,
2013        ));
2014
2015        if let Some(prompt) = self.prompt.as_mut() {
2016            if !prompt.suggestions.is_empty() {
2017                prompt.selected_suggestion = Some(current_index);
2018                let (_, name, desc) = options[current_index];
2019                prompt.input = format!("{} ({})", name, desc);
2020                prompt.cursor_pos = prompt.input.len();
2021            }
2022        }
2023    }
2024
2025    /// Start the language selection prompt
2026    fn start_set_language_prompt(&mut self) {
2027        let current_language = self.active_state().language.clone();
2028
2029        // Build suggestions from all available syntect syntaxes + Plain Text option
2030        let mut suggestions: Vec<crate::input::commands::Suggestion> = vec![
2031            // Plain Text option (no syntax highlighting)
2032            crate::input::commands::Suggestion {
2033                text: "Plain Text".to_string(),
2034                description: if current_language == "Plain Text" || current_language == "text" {
2035                    Some("current".to_string())
2036                } else {
2037                    None
2038                },
2039                value: Some("Plain Text".to_string()),
2040                disabled: false,
2041                keybinding: None,
2042                source: None,
2043            },
2044        ];
2045
2046        // Add all available syntaxes from the grammar registry (100+ languages)
2047        let mut syntax_names: Vec<&str> = self.grammar_registry.available_syntaxes();
2048        // Sort alphabetically for easier navigation
2049        syntax_names.sort_unstable_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase()));
2050
2051        for syntax_name in syntax_names {
2052            // Skip "Plain Text" as we already added it at the top
2053            if syntax_name == "Plain Text" {
2054                continue;
2055            }
2056            let is_current = syntax_name == current_language;
2057            suggestions.push(crate::input::commands::Suggestion {
2058                text: syntax_name.to_string(),
2059                description: if is_current {
2060                    Some("current".to_string())
2061                } else {
2062                    None
2063                },
2064                value: Some(syntax_name.to_string()),
2065                disabled: false,
2066                keybinding: None,
2067                source: None,
2068            });
2069        }
2070
2071        // Find current language index
2072        let current_index = suggestions
2073            .iter()
2074            .position(|s| s.value.as_deref() == Some(&current_language))
2075            .unwrap_or(0);
2076
2077        self.prompt = Some(crate::view::prompt::Prompt::with_suggestions(
2078            "Language: ".to_string(),
2079            PromptType::SetLanguage,
2080            suggestions,
2081        ));
2082
2083        if let Some(prompt) = self.prompt.as_mut() {
2084            if !prompt.suggestions.is_empty() {
2085                prompt.selected_suggestion = Some(current_index);
2086                // Don't set input - keep it empty so typing filters the list
2087                // The selected suggestion shows the current language
2088            }
2089        }
2090    }
2091
2092    /// Start the theme selection prompt with available themes
2093    fn start_select_theme_prompt(&mut self) {
2094        let available_themes = self.theme_registry.list();
2095        let current_theme_name = &self.theme.name;
2096
2097        // Find the index of the current theme
2098        let current_index = available_themes
2099            .iter()
2100            .position(|info| info.name == *current_theme_name)
2101            .unwrap_or(0);
2102
2103        let suggestions: Vec<crate::input::commands::Suggestion> = available_themes
2104            .iter()
2105            .map(|info| {
2106                let is_current = info.name == *current_theme_name;
2107                let description = match (is_current, info.pack.is_empty()) {
2108                    (true, true) => Some("(current)".to_string()),
2109                    (true, false) => Some(format!("{} (current)", info.pack)),
2110                    (false, true) => None,
2111                    (false, false) => Some(info.pack.clone()),
2112                };
2113                crate::input::commands::Suggestion {
2114                    text: info.name.clone(),
2115                    description,
2116                    value: Some(info.name.clone()),
2117                    disabled: false,
2118                    keybinding: None,
2119                    source: None,
2120                }
2121            })
2122            .collect();
2123
2124        self.prompt = Some(crate::view::prompt::Prompt::with_suggestions(
2125            "Select theme: ".to_string(),
2126            PromptType::SelectTheme {
2127                original_theme: current_theme_name.clone(),
2128            },
2129            suggestions,
2130        ));
2131
2132        if let Some(prompt) = self.prompt.as_mut() {
2133            if !prompt.suggestions.is_empty() {
2134                prompt.selected_suggestion = Some(current_index);
2135                // Also set input to match selected theme
2136                prompt.input = current_theme_name.to_string();
2137                prompt.cursor_pos = prompt.input.len();
2138            }
2139        }
2140    }
2141
2142    /// Apply a theme by name and persist it to config
2143    pub(super) fn apply_theme(&mut self, theme_name: &str) {
2144        if !theme_name.is_empty() {
2145            if let Some(theme) = self.theme_registry.get_cloned(theme_name) {
2146                self.theme = theme;
2147
2148                // Set terminal cursor color to match theme
2149                self.theme.set_terminal_cursor_color();
2150
2151                // Update the config in memory
2152                self.config.theme = self.theme.name.clone().into();
2153
2154                // Persist to config file
2155                self.save_theme_to_config();
2156
2157                self.set_status_message(
2158                    t!("view.theme_changed", theme = self.theme.name.clone()).to_string(),
2159                );
2160            } else {
2161                self.set_status_message(format!("Theme '{}' not found", theme_name));
2162            }
2163        }
2164    }
2165
2166    /// Preview a theme by name (without persisting to config)
2167    /// Used for live preview when navigating theme selection
2168    pub(super) fn preview_theme(&mut self, theme_name: &str) {
2169        if !theme_name.is_empty() && theme_name != self.theme.name {
2170            if let Some(theme) = self.theme_registry.get_cloned(theme_name) {
2171                self.theme = theme;
2172                self.theme.set_terminal_cursor_color();
2173            }
2174        }
2175    }
2176
2177    /// Save the current theme setting to the user's config file
2178    fn save_theme_to_config(&mut self) {
2179        // Create the directory if it doesn't exist
2180        if let Err(e) = self.filesystem.create_dir_all(&self.dir_context.config_dir) {
2181            tracing::warn!("Failed to create config directory: {}", e);
2182            return;
2183        }
2184
2185        // Save the config using the resolver
2186        let resolver = ConfigResolver::new(self.dir_context.clone(), self.working_dir.clone());
2187        if let Err(e) = resolver.save_to_layer(&self.config, ConfigLayer::User) {
2188            tracing::warn!("Failed to save theme to config: {}", e);
2189        }
2190    }
2191
2192    /// Start the keybinding map selection prompt with available maps
2193    fn start_select_keybinding_map_prompt(&mut self) {
2194        // Built-in keybinding maps
2195        let builtin_maps = vec!["default", "emacs", "vscode", "macos"];
2196
2197        // Collect user-defined keybinding maps from config
2198        let user_maps: Vec<&str> = self
2199            .config
2200            .keybinding_maps
2201            .keys()
2202            .map(|s| s.as_str())
2203            .collect();
2204
2205        // Combine built-in and user maps
2206        let mut all_maps: Vec<&str> = builtin_maps;
2207        for map in &user_maps {
2208            if !all_maps.contains(map) {
2209                all_maps.push(map);
2210            }
2211        }
2212
2213        let current_map = &self.config.active_keybinding_map;
2214
2215        // Find the index of the current keybinding map
2216        let current_index = all_maps
2217            .iter()
2218            .position(|name| *name == current_map)
2219            .unwrap_or(0);
2220
2221        let suggestions: Vec<crate::input::commands::Suggestion> = all_maps
2222            .iter()
2223            .map(|map_name| {
2224                let is_current = *map_name == current_map;
2225                crate::input::commands::Suggestion {
2226                    text: map_name.to_string(),
2227                    description: if is_current {
2228                        Some("(current)".to_string())
2229                    } else {
2230                        None
2231                    },
2232                    value: Some(map_name.to_string()),
2233                    disabled: false,
2234                    keybinding: None,
2235                    source: None,
2236                }
2237            })
2238            .collect();
2239
2240        self.prompt = Some(crate::view::prompt::Prompt::with_suggestions(
2241            "Select keybinding map: ".to_string(),
2242            PromptType::SelectKeybindingMap,
2243            suggestions,
2244        ));
2245
2246        if let Some(prompt) = self.prompt.as_mut() {
2247            if !prompt.suggestions.is_empty() {
2248                prompt.selected_suggestion = Some(current_index);
2249                // Also set input to match selected map
2250                prompt.input = current_map.to_string();
2251                prompt.cursor_pos = prompt.input.len();
2252            }
2253        }
2254    }
2255
2256    /// Apply a keybinding map by name and persist it to config
2257    pub(super) fn apply_keybinding_map(&mut self, map_name: &str) {
2258        if map_name.is_empty() {
2259            return;
2260        }
2261
2262        // Check if the map exists (either built-in or user-defined)
2263        let is_builtin = matches!(map_name, "default" | "emacs" | "vscode" | "macos");
2264        let is_user_defined = self.config.keybinding_maps.contains_key(map_name);
2265
2266        if is_builtin || is_user_defined {
2267            // Update the active keybinding map in config
2268            self.config.active_keybinding_map = map_name.to_string().into();
2269
2270            // Reload the keybinding resolver with the new map
2271            self.keybindings = crate::input::keybindings::KeybindingResolver::new(&self.config);
2272
2273            // Persist to config file
2274            self.save_keybinding_map_to_config();
2275
2276            self.set_status_message(t!("view.keybindings_switched", map = map_name).to_string());
2277        } else {
2278            self.set_status_message(t!("view.keybindings_unknown", map = map_name).to_string());
2279        }
2280    }
2281
2282    /// Save the current keybinding map setting to the user's config file
2283    fn save_keybinding_map_to_config(&mut self) {
2284        // Create the directory if it doesn't exist
2285        if let Err(e) = self.filesystem.create_dir_all(&self.dir_context.config_dir) {
2286            tracing::warn!("Failed to create config directory: {}", e);
2287            return;
2288        }
2289
2290        // Save the config using the resolver
2291        let resolver = ConfigResolver::new(self.dir_context.clone(), self.working_dir.clone());
2292        if let Err(e) = resolver.save_to_layer(&self.config, ConfigLayer::User) {
2293            tracing::warn!("Failed to save keybinding map to config: {}", e);
2294        }
2295    }
2296
2297    /// Start the cursor style selection prompt
2298    fn start_select_cursor_style_prompt(&mut self) {
2299        use crate::config::CursorStyle;
2300
2301        let current_style = self.config.editor.cursor_style;
2302
2303        // Build suggestions from available cursor styles
2304        let suggestions: Vec<crate::input::commands::Suggestion> = CursorStyle::OPTIONS
2305            .iter()
2306            .zip(CursorStyle::DESCRIPTIONS.iter())
2307            .map(|(style_name, description)| {
2308                let is_current = *style_name == current_style.as_str();
2309                crate::input::commands::Suggestion {
2310                    text: description.to_string(),
2311                    description: if is_current {
2312                        Some("(current)".to_string())
2313                    } else {
2314                        None
2315                    },
2316                    value: Some(style_name.to_string()),
2317                    disabled: false,
2318                    keybinding: None,
2319                    source: None,
2320                }
2321            })
2322            .collect();
2323
2324        // Find the index of the current cursor style
2325        let current_index = CursorStyle::OPTIONS
2326            .iter()
2327            .position(|s| *s == current_style.as_str())
2328            .unwrap_or(0);
2329
2330        self.prompt = Some(crate::view::prompt::Prompt::with_suggestions(
2331            "Select cursor style: ".to_string(),
2332            PromptType::SelectCursorStyle,
2333            suggestions,
2334        ));
2335
2336        if let Some(prompt) = self.prompt.as_mut() {
2337            if !prompt.suggestions.is_empty() {
2338                prompt.selected_suggestion = Some(current_index);
2339                prompt.input = CursorStyle::DESCRIPTIONS[current_index].to_string();
2340                prompt.cursor_pos = prompt.input.len();
2341            }
2342        }
2343    }
2344
2345    /// Apply a cursor style and persist it to config
2346    pub(super) fn apply_cursor_style(&mut self, style_name: &str) {
2347        use crate::config::CursorStyle;
2348
2349        if let Some(style) = CursorStyle::parse(style_name) {
2350            // Update the config in memory
2351            self.config.editor.cursor_style = style;
2352
2353            // Apply the cursor style to the terminal
2354            use std::io::stdout;
2355            let _ = crossterm::execute!(stdout(), style.to_crossterm_style());
2356
2357            // Persist to config file
2358            self.save_cursor_style_to_config();
2359
2360            // Find the description for the status message
2361            let description = CursorStyle::OPTIONS
2362                .iter()
2363                .zip(CursorStyle::DESCRIPTIONS.iter())
2364                .find(|(name, _)| **name == style_name)
2365                .map(|(_, desc)| *desc)
2366                .unwrap_or(style_name);
2367
2368            self.set_status_message(
2369                t!("view.cursor_style_changed", style = description).to_string(),
2370            );
2371        }
2372    }
2373
2374    /// Save the current cursor style setting to the user's config file
2375    fn save_cursor_style_to_config(&mut self) {
2376        // Create the directory if it doesn't exist
2377        if let Err(e) = self.filesystem.create_dir_all(&self.dir_context.config_dir) {
2378            tracing::warn!("Failed to create config directory: {}", e);
2379            return;
2380        }
2381
2382        // Save the config using the resolver
2383        let resolver = ConfigResolver::new(self.dir_context.clone(), self.working_dir.clone());
2384        if let Err(e) = resolver.save_to_layer(&self.config, ConfigLayer::User) {
2385            tracing::warn!("Failed to save cursor style to config: {}", e);
2386        }
2387    }
2388
2389    /// Start the locale selection prompt with available locales
2390    fn start_select_locale_prompt(&mut self) {
2391        let available_locales = crate::i18n::available_locales();
2392        let current_locale = crate::i18n::current_locale();
2393
2394        // Find the index of the current locale
2395        let current_index = available_locales
2396            .iter()
2397            .position(|name| *name == current_locale)
2398            .unwrap_or(0);
2399
2400        let suggestions: Vec<crate::input::commands::Suggestion> = available_locales
2401            .iter()
2402            .map(|locale_name| {
2403                let is_current = *locale_name == current_locale;
2404                let description = if let Some((english_name, native_name)) =
2405                    crate::i18n::locale_display_name(locale_name)
2406                {
2407                    if english_name == native_name {
2408                        // Same name (e.g., English/English)
2409                        if is_current {
2410                            format!("{} (current)", english_name)
2411                        } else {
2412                            english_name.to_string()
2413                        }
2414                    } else {
2415                        // Different names (e.g., German/Deutsch)
2416                        if is_current {
2417                            format!("{} / {} (current)", english_name, native_name)
2418                        } else {
2419                            format!("{} / {}", english_name, native_name)
2420                        }
2421                    }
2422                } else {
2423                    // Unknown locale
2424                    if is_current {
2425                        "(current)".to_string()
2426                    } else {
2427                        String::new()
2428                    }
2429                };
2430                crate::input::commands::Suggestion {
2431                    text: locale_name.to_string(),
2432                    description: if description.is_empty() {
2433                        None
2434                    } else {
2435                        Some(description)
2436                    },
2437                    value: Some(locale_name.to_string()),
2438                    disabled: false,
2439                    keybinding: None,
2440                    source: None,
2441                }
2442            })
2443            .collect();
2444
2445        self.prompt = Some(crate::view::prompt::Prompt::with_suggestions(
2446            t!("locale.select_prompt").to_string(),
2447            PromptType::SelectLocale,
2448            suggestions,
2449        ));
2450
2451        if let Some(prompt) = self.prompt.as_mut() {
2452            if !prompt.suggestions.is_empty() {
2453                prompt.selected_suggestion = Some(current_index);
2454                // Start with empty input to show all options initially
2455                prompt.input = String::new();
2456                prompt.cursor_pos = 0;
2457            }
2458        }
2459    }
2460
2461    /// Apply a locale and persist it to config
2462    pub(super) fn apply_locale(&mut self, locale_name: &str) {
2463        if !locale_name.is_empty() {
2464            // Update the locale at runtime
2465            crate::i18n::set_locale(locale_name);
2466
2467            // Update the config in memory
2468            self.config.locale = crate::config::LocaleName(Some(locale_name.to_string()));
2469
2470            // Regenerate menus with the new locale
2471            self.menus = crate::config::MenuConfig::translated();
2472
2473            // Refresh command palette commands with new locale
2474            if let Ok(mut registry) = self.command_registry.write() {
2475                registry.refresh_builtin_commands();
2476            }
2477
2478            // Persist to config file
2479            self.save_locale_to_config();
2480
2481            self.set_status_message(t!("locale.changed", locale_name = locale_name).to_string());
2482        }
2483    }
2484
2485    /// Save the current locale setting to the user's config file
2486    fn save_locale_to_config(&mut self) {
2487        // Create the directory if it doesn't exist
2488        if let Err(e) = self.filesystem.create_dir_all(&self.dir_context.config_dir) {
2489            tracing::warn!("Failed to create config directory: {}", e);
2490            return;
2491        }
2492
2493        // Save the config using the resolver
2494        let resolver = ConfigResolver::new(self.dir_context.clone(), self.working_dir.clone());
2495        if let Err(e) = resolver.save_to_layer(&self.config, ConfigLayer::User) {
2496            tracing::warn!("Failed to save locale to config: {}", e);
2497        }
2498    }
2499
2500    /// Switch to the previously active tab in the current split
2501    fn switch_to_previous_tab(&mut self) {
2502        let active_split = self.split_manager.active_split();
2503        let previous_buffer = self
2504            .split_view_states
2505            .get(&active_split)
2506            .and_then(|vs| vs.previous_buffer());
2507
2508        if let Some(prev_id) = previous_buffer {
2509            // Verify the buffer is still open in this split
2510            let is_valid = self
2511                .split_view_states
2512                .get(&active_split)
2513                .is_some_and(|vs| vs.open_buffers.contains(&prev_id));
2514
2515            if is_valid && prev_id != self.active_buffer() {
2516                // Save current position before switching
2517                self.position_history.commit_pending_movement();
2518
2519                let current_state = self.active_state();
2520                let position = current_state.cursors.primary().position;
2521                let anchor = current_state.cursors.primary().anchor;
2522                self.position_history
2523                    .record_movement(self.active_buffer(), position, anchor);
2524                self.position_history.commit_pending_movement();
2525
2526                self.set_active_buffer(prev_id);
2527            } else if !is_valid {
2528                self.set_status_message(t!("status.previous_tab_closed").to_string());
2529            }
2530        } else {
2531            self.set_status_message(t!("status.no_previous_tab").to_string());
2532        }
2533    }
2534
2535    /// Start the switch-to-tab-by-name prompt with suggestions from open buffers
2536    fn start_switch_to_tab_prompt(&mut self) {
2537        let active_split = self.split_manager.active_split();
2538        let open_buffers = if let Some(view_state) = self.split_view_states.get(&active_split) {
2539            view_state.open_buffers.clone()
2540        } else {
2541            return;
2542        };
2543
2544        if open_buffers.is_empty() {
2545            self.set_status_message(t!("status.no_tabs_in_split").to_string());
2546            return;
2547        }
2548
2549        // Find the current buffer's index
2550        let current_index = open_buffers
2551            .iter()
2552            .position(|&id| id == self.active_buffer())
2553            .unwrap_or(0);
2554
2555        let suggestions: Vec<crate::input::commands::Suggestion> = open_buffers
2556            .iter()
2557            .map(|&buffer_id| {
2558                let display_name = self
2559                    .buffer_metadata
2560                    .get(&buffer_id)
2561                    .map(|m| m.display_name.clone())
2562                    .unwrap_or_else(|| format!("Buffer {:?}", buffer_id));
2563
2564                let is_current = buffer_id == self.active_buffer();
2565                let is_modified = self
2566                    .buffers
2567                    .get(&buffer_id)
2568                    .is_some_and(|b| b.buffer.is_modified());
2569
2570                let description = match (is_current, is_modified) {
2571                    (true, true) => Some("(current, modified)".to_string()),
2572                    (true, false) => Some("(current)".to_string()),
2573                    (false, true) => Some("(modified)".to_string()),
2574                    (false, false) => None,
2575                };
2576
2577                crate::input::commands::Suggestion {
2578                    text: display_name,
2579                    description,
2580                    value: Some(buffer_id.0.to_string()),
2581                    disabled: false,
2582                    keybinding: None,
2583                    source: None,
2584                }
2585            })
2586            .collect();
2587
2588        self.prompt = Some(crate::view::prompt::Prompt::with_suggestions(
2589            "Switch to tab: ".to_string(),
2590            PromptType::SwitchToTab,
2591            suggestions,
2592        ));
2593
2594        if let Some(prompt) = self.prompt.as_mut() {
2595            if !prompt.suggestions.is_empty() {
2596                prompt.selected_suggestion = Some(current_index);
2597            }
2598        }
2599    }
2600
2601    /// Switch to a tab by its BufferId
2602    pub(crate) fn switch_to_tab(&mut self, buffer_id: BufferId) {
2603        // Verify the buffer exists and is open in the current split
2604        let active_split = self.split_manager.active_split();
2605        let is_valid = self
2606            .split_view_states
2607            .get(&active_split)
2608            .is_some_and(|vs| vs.open_buffers.contains(&buffer_id));
2609
2610        if !is_valid {
2611            self.set_status_message(t!("status.tab_not_found").to_string());
2612            return;
2613        }
2614
2615        if buffer_id != self.active_buffer() {
2616            // Save current position before switching
2617            self.position_history.commit_pending_movement();
2618
2619            let current_state = self.active_state();
2620            let position = current_state.cursors.primary().position;
2621            let anchor = current_state.cursors.primary().anchor;
2622            self.position_history
2623                .record_movement(self.active_buffer(), position, anchor);
2624            self.position_history.commit_pending_movement();
2625
2626            self.set_active_buffer(buffer_id);
2627        }
2628    }
2629
2630    /// Handle character insertion in prompt mode.
2631    fn handle_insert_char_prompt(&mut self, c: char) -> AnyhowResult<()> {
2632        // Check if this is the query-replace confirmation prompt
2633        if let Some(ref prompt) = self.prompt {
2634            if prompt.prompt_type == PromptType::QueryReplaceConfirm {
2635                return self.handle_interactive_replace_key(c);
2636            }
2637        }
2638
2639        // Reset history navigation when user starts typing
2640        // This allows them to press Up to get back to history items
2641        // Reset history navigation when typing in a prompt
2642        if let Some(ref prompt) = self.prompt {
2643            if let Some(key) = Self::prompt_type_to_history_key(&prompt.prompt_type) {
2644                if let Some(history) = self.prompt_histories.get_mut(&key) {
2645                    history.reset_navigation();
2646                }
2647            }
2648        }
2649
2650        if let Some(prompt) = self.prompt_mut() {
2651            // Use insert_str to properly handle selection deletion
2652            let s = c.to_string();
2653            prompt.insert_str(&s);
2654        }
2655        self.update_prompt_suggestions();
2656        Ok(())
2657    }
2658
2659    /// Handle character insertion in normal editor mode.
2660    fn handle_insert_char_editor(&mut self, c: char) -> AnyhowResult<()> {
2661        // Check if editing is disabled (show_cursors = false)
2662        if self.is_editing_disabled() {
2663            self.set_status_message(t!("buffer.editing_disabled").to_string());
2664            return Ok(());
2665        }
2666
2667        // Cancel any pending LSP requests since the text is changing
2668        self.cancel_pending_lsp_requests();
2669
2670        if let Some(events) = self.action_to_events(Action::InsertChar(c)) {
2671            if events.len() > 1 {
2672                // Multi-cursor: use optimized bulk edit (O(n) instead of O(n²))
2673                let description = format!("Insert '{}'", c);
2674                if let Some(bulk_edit) = self.apply_events_as_bulk_edit(events, description.clone())
2675                {
2676                    self.active_event_log_mut().append(bulk_edit);
2677                }
2678            } else {
2679                // Single cursor - apply normally
2680                for event in events {
2681                    self.active_event_log_mut().append(event.clone());
2682                    self.apply_event_to_active_buffer(&event);
2683                }
2684            }
2685        }
2686
2687        // Auto-trigger signature help on '(' and ','
2688        if c == '(' || c == ',' {
2689            let _ = self.request_signature_help();
2690        }
2691
2692        // Auto-trigger completion on trigger characters
2693        self.maybe_trigger_completion(c);
2694
2695        Ok(())
2696    }
2697
2698    /// Apply an action by converting it to events.
2699    ///
2700    /// This is the catch-all handler for actions that can be converted to buffer events
2701    /// (cursor movements, text edits, etc.). It handles batching for multi-cursor,
2702    /// position history tracking, and editing permission checks.
2703    fn apply_action_as_events(&mut self, action: Action) -> AnyhowResult<()> {
2704        // Check if active buffer is a composite buffer - handle scroll/movement specially
2705        let buffer_id = self.active_buffer();
2706        if self.is_composite_buffer(buffer_id) {
2707            if let Some(_handled) = self.handle_composite_action(buffer_id, &action) {
2708                return Ok(());
2709            }
2710        }
2711
2712        // Get description before moving action
2713        let action_description = format!("{:?}", action);
2714
2715        // Check if this is an editing action and editing is disabled
2716        let is_editing_action = matches!(
2717            action,
2718            Action::InsertNewline
2719                | Action::InsertTab
2720                | Action::DeleteForward
2721                | Action::DeleteWordBackward
2722                | Action::DeleteWordForward
2723                | Action::DeleteLine
2724                | Action::DedentSelection
2725                | Action::ToggleComment
2726        );
2727
2728        if is_editing_action && self.is_editing_disabled() {
2729            self.set_status_message(t!("buffer.editing_disabled").to_string());
2730            return Ok(());
2731        }
2732
2733        if let Some(events) = self.action_to_events(action) {
2734            if events.len() > 1 {
2735                // Check if this batch contains buffer modifications
2736                let has_buffer_mods = events
2737                    .iter()
2738                    .any(|e| matches!(e, Event::Insert { .. } | Event::Delete { .. }));
2739
2740                if has_buffer_mods {
2741                    // Multi-cursor buffer edit: use optimized bulk edit (O(n) instead of O(n²))
2742                    if let Some(bulk_edit) =
2743                        self.apply_events_as_bulk_edit(events.clone(), action_description)
2744                    {
2745                        self.active_event_log_mut().append(bulk_edit);
2746                    }
2747                } else {
2748                    // Multi-cursor non-buffer operation: use Batch for atomic undo
2749                    let batch = Event::Batch {
2750                        events: events.clone(),
2751                        description: action_description,
2752                    };
2753                    self.active_event_log_mut().append(batch.clone());
2754                    self.apply_event_to_active_buffer(&batch);
2755                }
2756
2757                // Track position history for all events
2758                for event in &events {
2759                    self.track_cursor_movement(event);
2760                }
2761            } else {
2762                // Single cursor - apply normally
2763                for event in events {
2764                    self.active_event_log_mut().append(event.clone());
2765                    self.apply_event_to_active_buffer(&event);
2766                    self.track_cursor_movement(&event);
2767                }
2768            }
2769        }
2770
2771        Ok(())
2772    }
2773
2774    /// Track cursor movement in position history if applicable.
2775    fn track_cursor_movement(&mut self, event: &Event) {
2776        if self.in_navigation {
2777            return;
2778        }
2779
2780        if let Event::MoveCursor {
2781            new_position,
2782            new_anchor,
2783            ..
2784        } = event
2785        {
2786            self.position_history
2787                .record_movement(self.active_buffer(), *new_position, *new_anchor);
2788        }
2789    }
2790}