Skip to main content

fresh/app/
input.rs

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