Skip to main content

fresh/app/
mouse_input.rs

1//! Mouse input handling.
2//!
3//! This module contains all mouse event handling logic including:
4//! - Click, double-click, and drag handling
5//! - Scrollbar interaction
6//! - Hover target computation
7//! - Split separator dragging
8//! - Text selection via mouse
9
10use super::*;
11use crate::input::keybindings::Action;
12use crate::model::event::{ContainerId, CursorId, LeafId, SplitDirection};
13use crate::services::plugins::hooks::HookArgs;
14use crate::view::popup_mouse::{popup_areas_to_layout_info, PopupHitTester};
15use crate::view::prompt::PromptType;
16use crate::view::ui::tabs::TabHit;
17use anyhow::Result as AnyhowResult;
18use rust_i18n::t;
19
20impl Editor {
21    /// Handle a mouse event.
22    /// Returns true if a re-render is needed.
23    pub fn handle_mouse(
24        &mut self,
25        mouse_event: crossterm::event::MouseEvent,
26    ) -> AnyhowResult<bool> {
27        use crossterm::event::{MouseButton, MouseEventKind};
28
29        let col = mouse_event.column;
30        let row = mouse_event.row;
31
32        let (is_double_click, is_triple_click) = self.detect_multi_click(&mouse_event, col, row);
33
34        // When keybinding editor is open, capture all mouse events
35        if self.keybinding_editor.is_some() {
36            return self.handle_keybinding_editor_mouse(mouse_event);
37        }
38
39        // When settings modal is open, capture all mouse events
40        if self.settings_state.as_ref().is_some_and(|s| s.visible) {
41            return self.handle_settings_mouse(mouse_event, is_double_click);
42        }
43
44        // When calibration wizard is active, ignore all mouse events
45        if self.calibration_wizard.is_some() {
46            return Ok(false);
47        }
48
49        // Cancel LSP rename prompt on any mouse interaction
50        let mut needs_render = false;
51        if let Some(ref prompt) = self.prompt {
52            if matches!(prompt.prompt_type, PromptType::LspRename { .. }) {
53                self.cancel_prompt();
54                needs_render = true;
55            }
56        }
57
58        // Update mouse cursor position for software cursor rendering (used by GPM)
59        // When GPM is active, we always need to re-render to update the cursor position
60        let cursor_moved = self.mouse_cursor_position != Some((col, row));
61        self.mouse_cursor_position = Some((col, row));
62        if self.gpm_active && cursor_moved {
63            needs_render = true;
64        }
65
66        tracing::trace!(
67            "handle_mouse: kind={:?}, col={}, row={}",
68            mouse_event.kind,
69            col,
70            row
71        );
72
73        // Check if we should forward mouse events to the terminal
74        // Forward if: in terminal mode, mouse is over terminal buffer, and terminal is in alternate screen mode
75        if let Some(result) = self.try_forward_mouse_to_terminal(col, row, mouse_event) {
76            return result;
77        }
78
79        // Dismiss theme info popup on any left-click; check if click is on the button first
80        if self.theme_info_popup.is_some() {
81            if let MouseEventKind::Down(MouseButton::Left) = mouse_event.kind {
82                if let Some((popup_rect, button_row_offset)) = self.theme_info_popup_rect() {
83                    let popup_x = popup_rect.x;
84                    let popup_y = popup_rect.y;
85                    let popup_w = popup_rect.width;
86                    let popup_h = popup_rect.height;
87                    let in_popup = col >= popup_x
88                        && col < popup_x + popup_w
89                        && row >= popup_y
90                        && row < popup_y + popup_h;
91
92                    if in_popup {
93                        // Check if click is on the button row (last content row before border)
94                        let actual_button_row = popup_y + button_row_offset;
95                        if row == actual_button_row {
96                            let fg_key = self
97                                .theme_info_popup
98                                .as_ref()
99                                .and_then(|p| p.info.fg_key.clone());
100                            self.theme_info_popup = None;
101                            if let Some(key) = fg_key {
102                                self.fire_theme_inspect_hook(key);
103                            }
104                            return Ok(true);
105                        }
106                        // Click inside popup but not button - ignore
107                        return Ok(true);
108                    }
109                }
110                // Click outside popup - dismiss
111                self.theme_info_popup = None;
112                needs_render = true;
113            }
114        }
115
116        match mouse_event.kind {
117            MouseEventKind::Down(MouseButton::Left) => {
118                if is_double_click || is_triple_click {
119                    if let Some((buffer_id, byte_pos)) =
120                        self.fold_toggle_line_at_screen_position(col, row)
121                    {
122                        self.toggle_fold_at_byte(buffer_id, byte_pos);
123                        needs_render = true;
124                        return Ok(needs_render);
125                    }
126                }
127                if is_triple_click {
128                    // Triple click detected - select entire line
129                    self.handle_mouse_triple_click(col, row)?;
130                    needs_render = true;
131                    return Ok(needs_render);
132                }
133                if is_double_click {
134                    // Double click detected - both clicks within time threshold AND at same position
135                    self.handle_mouse_double_click(col, row)?;
136                    needs_render = true;
137                    return Ok(needs_render);
138                }
139                self.handle_mouse_click(col, row, mouse_event.modifiers)?;
140                needs_render = true;
141            }
142            MouseEventKind::Drag(MouseButton::Left) => {
143                self.handle_mouse_drag(col, row)?;
144                needs_render = true;
145            }
146            MouseEventKind::Up(MouseButton::Left) => {
147                // Check if we were dragging a separator to trigger terminal resize
148                let was_dragging_separator = self.mouse_state.dragging_separator.is_some();
149
150                // Check if we were dragging a tab and complete the drop
151                if let Some(drag_state) = self.mouse_state.dragging_tab.take() {
152                    if drag_state.is_dragging() {
153                        if let Some(drop_zone) = drag_state.drop_zone {
154                            self.execute_tab_drop(
155                                drag_state.buffer_id,
156                                drag_state.source_split_id,
157                                drop_zone,
158                            );
159                        }
160                    }
161                }
162
163                // Stop dragging and clear drag state
164                self.mouse_state.dragging_scrollbar = None;
165                self.mouse_state.drag_start_row = None;
166                self.mouse_state.drag_start_top_byte = None;
167                self.mouse_state.dragging_horizontal_scrollbar = None;
168                self.mouse_state.drag_start_hcol = None;
169                self.mouse_state.drag_start_left_column = None;
170                self.mouse_state.dragging_separator = None;
171                self.mouse_state.drag_start_position = None;
172                self.mouse_state.drag_start_ratio = None;
173                self.mouse_state.dragging_file_explorer = false;
174                self.mouse_state.drag_start_explorer_width = None;
175                // Clear text selection drag state (selection remains in cursor)
176                self.mouse_state.dragging_text_selection = false;
177                self.mouse_state.drag_selection_split = None;
178                self.mouse_state.drag_selection_anchor = None;
179                self.mouse_state.drag_selection_by_words = false;
180                self.mouse_state.drag_selection_word_end = None;
181                // Clear popup scrollbar drag state
182                self.mouse_state.dragging_popup_scrollbar = None;
183                self.mouse_state.drag_start_popup_scroll = None;
184                // Clear popup text selection drag state (selection remains in popup)
185                self.mouse_state.selecting_in_popup = None;
186
187                // If we finished dragging a separator, resize visible terminals
188                if was_dragging_separator {
189                    self.resize_visible_terminals();
190                }
191
192                needs_render = true;
193            }
194            MouseEventKind::Moved => {
195                // Dispatch MouseMove hook to plugins (fire-and-forget, no blocking check)
196                {
197                    // Find content rect for the split under the mouse
198                    let content_rect = self
199                        .cached_layout
200                        .split_areas
201                        .iter()
202                        .find(|(_, _, content_rect, _, _, _)| {
203                            col >= content_rect.x
204                                && col < content_rect.x + content_rect.width
205                                && row >= content_rect.y
206                                && row < content_rect.y + content_rect.height
207                        })
208                        .map(|(_, _, rect, _, _, _)| *rect);
209
210                    let (content_x, content_y) = content_rect.map(|r| (r.x, r.y)).unwrap_or((0, 0));
211
212                    self.plugin_manager.run_hook(
213                        "mouse_move",
214                        HookArgs::MouseMove {
215                            column: col,
216                            row,
217                            content_x,
218                            content_y,
219                        },
220                    );
221                }
222
223                // Only re-render if hover target actually changed
224                // (preserve needs_render if already set, e.g., for GPM cursor updates)
225                let hover_changed = self.update_hover_target(col, row);
226                needs_render = needs_render || hover_changed;
227
228                // Update theme info popup button highlight on hover
229                if let Some((popup_rect, button_row_offset)) = self.theme_info_popup_rect() {
230                    let button_row = popup_rect.y + button_row_offset;
231                    let new_highlighted = row == button_row
232                        && col >= popup_rect.x
233                        && col < popup_rect.x + popup_rect.width;
234                    if let Some(ref mut popup) = self.theme_info_popup {
235                        if popup.button_highlighted != new_highlighted {
236                            popup.button_highlighted = new_highlighted;
237                            needs_render = true;
238                        }
239                    }
240                }
241
242                // Track LSP hover state for mouse-triggered hover popups
243                self.update_lsp_hover_state(col, row);
244            }
245            MouseEventKind::ScrollUp => {
246                self.handle_vertical_scroll(col, row, mouse_event.modifiers, -3)?;
247                needs_render = true;
248            }
249            MouseEventKind::ScrollDown => {
250                self.handle_vertical_scroll(col, row, mouse_event.modifiers, 3)?;
251                needs_render = true;
252            }
253            MouseEventKind::ScrollLeft => {
254                // Native horizontal scroll left
255                self.handle_horizontal_scroll(col, row, -3)?;
256                needs_render = true;
257            }
258            MouseEventKind::ScrollRight => {
259                // Native horizontal scroll right
260                self.handle_horizontal_scroll(col, row, 3)?;
261                needs_render = true;
262            }
263            MouseEventKind::Down(MouseButton::Right) => {
264                if mouse_event
265                    .modifiers
266                    .contains(crossterm::event::KeyModifiers::CONTROL)
267                {
268                    // Ctrl+Right-Click → theme info popup
269                    self.show_theme_info_popup(col, row)?;
270                } else {
271                    // Normal right-click → tab context menu
272                    self.handle_right_click(col, row)?;
273                }
274                needs_render = true;
275            }
276            _ => {
277                // Ignore other mouse events for now
278            }
279        }
280
281        self.mouse_state.last_position = Some((col, row));
282        Ok(needs_render)
283    }
284
285    /// Detect double/triple clicks and update click-tracking state.
286    fn detect_multi_click(
287        &mut self,
288        mouse_event: &crossterm::event::MouseEvent,
289        col: u16,
290        row: u16,
291    ) -> (bool, bool) {
292        use crossterm::event::{MouseButton, MouseEventKind};
293        if !matches!(mouse_event.kind, MouseEventKind::Down(MouseButton::Left)) {
294            return (false, false);
295        }
296        let now = self.time_source.now();
297        let threshold = std::time::Duration::from_millis(self.config.editor.double_click_time_ms);
298        let is_consecutive = if let (Some(prev_time), Some(prev_pos)) =
299            (self.previous_click_time, self.previous_click_position)
300        {
301            now.duration_since(prev_time) < threshold && prev_pos == (col, row)
302        } else {
303            false
304        };
305        if is_consecutive {
306            self.click_count += 1;
307        } else {
308            self.click_count = 1;
309        }
310        self.previous_click_time = Some(now);
311        self.previous_click_position = Some((col, row));
312        let is_triple = self.click_count >= 3;
313        let is_double = self.click_count == 2;
314        if is_triple {
315            self.click_count = 0;
316            self.previous_click_time = None;
317            self.previous_click_position = None;
318        }
319        (is_double, is_triple)
320    }
321
322    /// Dispatch a vertical scroll event (ScrollUp/ScrollDown) through the priority chain:
323    /// Shift → horizontal scroll, prompt, file browser, popup, editor/terminal.
324    fn handle_vertical_scroll(
325        &mut self,
326        col: u16,
327        row: u16,
328        modifiers: crossterm::event::KeyModifiers,
329        delta: i32,
330    ) -> AnyhowResult<()> {
331        if modifiers.contains(crossterm::event::KeyModifiers::SHIFT) {
332            self.handle_horizontal_scroll(col, row, delta)?;
333        } else if self.handle_prompt_scroll(delta) {
334            // prompt consumed the scroll
335        } else if self.is_file_open_active()
336            && self.is_mouse_over_file_browser(col, row)
337            && self.handle_file_open_scroll(delta)
338        {
339            // file browser consumed the scroll
340        } else if self.is_mouse_over_any_popup(col, row) {
341            self.scroll_popup(delta);
342        } else {
343            if self.terminal_mode && self.is_terminal_buffer(self.active_buffer()) {
344                self.sync_terminal_to_buffer(self.active_buffer());
345                self.terminal_mode = false;
346                self.key_context = crate::input::keybindings::KeyContext::Normal;
347            }
348            self.dismiss_transient_popups();
349            self.handle_mouse_scroll(col, row, delta)?;
350        }
351        Ok(())
352    }
353
354    /// Update the current hover target based on mouse position
355    /// Returns true if the hover target changed (requiring a re-render)
356    pub(super) fn update_hover_target(&mut self, col: u16, row: u16) -> bool {
357        let old_target = self.mouse_state.hover_target.clone();
358        let new_target = self.compute_hover_target(col, row);
359        let changed = old_target != new_target;
360        self.mouse_state.hover_target = new_target.clone();
361
362        // If a menu is currently open and we're hovering over a different menu bar item,
363        // switch to that menu automatically
364        if let Some(active_menu_idx) = self.menu_state.active_menu {
365            if let Some(HoverTarget::MenuBarItem(hovered_menu_idx)) = new_target.clone() {
366                if hovered_menu_idx != active_menu_idx {
367                    self.menu_state.open_menu(hovered_menu_idx);
368                    return true; // Force re-render since menu changed
369                }
370            }
371
372            // If hovering over a menu dropdown item, check if it's a submenu and open it
373            if let Some(HoverTarget::MenuDropdownItem(_, item_idx)) = new_target.clone() {
374                let all_menus: Vec<crate::config::Menu> = self
375                    .menus
376                    .menus
377                    .iter()
378                    .chain(self.menu_state.plugin_menus.iter())
379                    .cloned()
380                    .collect();
381
382                // If this item is the parent of the currently open submenu, keep it open.
383                // This prevents blinking when hovering over the parent item of an open submenu.
384                if self.menu_state.submenu_path.first() == Some(&item_idx) {
385                    tracing::trace!(
386                        "menu hover: staying on submenu parent item_idx={}, submenu_path={:?}",
387                        item_idx,
388                        self.menu_state.submenu_path
389                    );
390                    return changed;
391                }
392
393                // Clear any open submenus since we're at a different item in the main dropdown
394                if !self.menu_state.submenu_path.is_empty() {
395                    tracing::trace!(
396                        "menu hover: clearing submenu_path={:?} for different item_idx={}",
397                        self.menu_state.submenu_path,
398                        item_idx
399                    );
400                    self.menu_state.submenu_path.clear();
401                    self.menu_state.highlighted_item = Some(item_idx);
402                    return true;
403                }
404
405                // Check if the hovered item is a submenu
406                if let Some(menu) = all_menus.get(active_menu_idx) {
407                    if let Some(crate::config::MenuItem::Submenu { items, .. }) =
408                        menu.items.get(item_idx)
409                    {
410                        if !items.is_empty() {
411                            tracing::trace!("menu hover: opening submenu at item_idx={}", item_idx);
412                            self.menu_state.submenu_path.push(item_idx);
413                            self.menu_state.highlighted_item = Some(0);
414                            return true;
415                        }
416                    }
417                }
418                // Update highlighted item for non-submenu items too
419                if self.menu_state.highlighted_item != Some(item_idx) {
420                    self.menu_state.highlighted_item = Some(item_idx);
421                    return true;
422                }
423            }
424
425            // If hovering over a submenu item, handle submenu navigation
426            if let Some(HoverTarget::SubmenuItem(depth, item_idx)) = new_target {
427                // If this item is the parent of a currently open nested submenu, keep it open.
428                // This prevents blinking when hovering over the parent item of an open nested submenu.
429                // submenu_path[depth] stores the index of the nested submenu opened from this level.
430                if self.menu_state.submenu_path.len() > depth
431                    && self.menu_state.submenu_path.get(depth) == Some(&item_idx)
432                {
433                    tracing::trace!(
434                        "menu hover: staying on nested submenu parent depth={}, item_idx={}, submenu_path={:?}",
435                        depth,
436                        item_idx,
437                        self.menu_state.submenu_path
438                    );
439                    return changed;
440                }
441
442                // Truncate submenu path to this depth (close any deeper submenus)
443                if self.menu_state.submenu_path.len() > depth {
444                    tracing::trace!(
445                        "menu hover: truncating submenu_path={:?} to depth={} for item_idx={}",
446                        self.menu_state.submenu_path,
447                        depth,
448                        item_idx
449                    );
450                    self.menu_state.submenu_path.truncate(depth);
451                }
452
453                let all_menus: Vec<crate::config::Menu> = self
454                    .menus
455                    .menus
456                    .iter()
457                    .chain(self.menu_state.plugin_menus.iter())
458                    .cloned()
459                    .collect();
460
461                // Get the items at this depth
462                if let Some(items) = self
463                    .menu_state
464                    .get_current_items(&all_menus, active_menu_idx)
465                {
466                    // Check if hovered item is a submenu - if so, open it
467                    if let Some(crate::config::MenuItem::Submenu {
468                        items: sub_items, ..
469                    }) = items.get(item_idx)
470                    {
471                        if !sub_items.is_empty()
472                            && !self.menu_state.submenu_path.contains(&item_idx)
473                        {
474                            tracing::trace!(
475                                "menu hover: opening nested submenu at depth={}, item_idx={}",
476                                depth,
477                                item_idx
478                            );
479                            self.menu_state.submenu_path.push(item_idx);
480                            self.menu_state.highlighted_item = Some(0);
481                            return true;
482                        }
483                    }
484                    // Update highlighted item
485                    if self.menu_state.highlighted_item != Some(item_idx) {
486                        self.menu_state.highlighted_item = Some(item_idx);
487                        return true;
488                    }
489                }
490            }
491        }
492
493        // Handle tab context menu hover - update highlighted item
494        if let Some(HoverTarget::TabContextMenuItem(item_idx)) = new_target.clone() {
495            if let Some(ref mut menu) = self.tab_context_menu {
496                if menu.highlighted != item_idx {
497                    menu.highlighted = item_idx;
498                    return true;
499                }
500            }
501        }
502
503        if let Some(&HoverTarget::FileExplorerContextMenuItem(item_idx)) = new_target.as_ref() {
504            if let Some(ref mut menu) = self.file_explorer_context_menu {
505                if menu.highlighted != item_idx {
506                    menu.highlighted = item_idx;
507                    return true;
508                }
509            }
510        }
511
512        // Handle file explorer status indicator hover - show tooltip
513        // Always dismiss existing tooltip first when target changes
514        if old_target != new_target
515            && matches!(
516                old_target,
517                Some(HoverTarget::FileExplorerStatusIndicator(_))
518            )
519        {
520            self.dismiss_file_explorer_status_tooltip();
521        }
522
523        if let Some(HoverTarget::FileExplorerStatusIndicator(ref path)) = new_target {
524            // Only show tooltip if this is a new hover (not already showing for this path)
525            if old_target != new_target {
526                self.show_file_explorer_status_tooltip(path.clone(), col, row);
527                return true;
528            }
529        }
530
531        changed
532    }
533
534    /// Update LSP hover state based on mouse position
535    /// Tracks position for debounced hover requests
536    ///
537    /// Hover popup stays visible when:
538    /// - Mouse is over the hover popup itself
539    /// - Mouse is within the hovered symbol range
540    ///
541    /// Hover is dismissed when mouse leaves the editor area entirely.
542    fn update_lsp_hover_state(&mut self, col: u16, row: u16) {
543        tracing::trace!(col, row, "update_lsp_hover_state: raw mouse position");
544
545        // Suppress LSP hover when a popup is already visible (e.g. theme info popup,
546        // tab context menu) to avoid hover tooltips overlapping other popups.
547        if self.theme_info_popup.is_some()
548            || self.tab_context_menu.is_some()
549            || self.file_explorer_context_menu.is_some()
550        {
551            if self.mouse_state.lsp_hover_state.is_some() {
552                self.mouse_state.lsp_hover_state = None;
553                self.mouse_state.lsp_hover_request_sent = false;
554                self.dismiss_transient_popups();
555            }
556            return;
557        }
558
559        // Check if mouse is over a transient popup - if so, keep hover active
560        if self.is_mouse_over_transient_popup(col, row) {
561            return;
562        }
563
564        // Find which split the mouse is over
565        let split_info = self
566            .cached_layout
567            .split_areas
568            .iter()
569            .find(|(_, _, content_rect, _, _, _)| {
570                col >= content_rect.x
571                    && col < content_rect.x + content_rect.width
572                    && row >= content_rect.y
573                    && row < content_rect.y + content_rect.height
574            })
575            .map(|(split_id, buffer_id, content_rect, _, _, _)| {
576                (*split_id, *buffer_id, *content_rect)
577            });
578
579        let Some((split_id, buffer_id, content_rect)) = split_info else {
580            // Mouse is not over editor content - clear hover state and dismiss popup
581            if self.mouse_state.lsp_hover_state.is_some() {
582                self.mouse_state.lsp_hover_state = None;
583                self.mouse_state.lsp_hover_request_sent = false;
584                self.dismiss_transient_popups();
585            }
586            return;
587        };
588
589        // Get cached mappings and gutter width for this split
590        let cached_mappings = self
591            .cached_layout
592            .view_line_mappings
593            .get(&split_id)
594            .cloned();
595        let gutter_width = self
596            .buffers
597            .get(&buffer_id)
598            .map(|s| s.margins.left_total_width() as u16)
599            .unwrap_or(0);
600        let fallback = self
601            .buffers
602            .get(&buffer_id)
603            .map(|s| s.buffer.len())
604            .unwrap_or(0);
605
606        // Get compose width for this split
607        let compose_width = self
608            .split_view_states
609            .get(&split_id)
610            .and_then(|vs| vs.compose_width);
611
612        // Convert screen position to buffer byte position
613        let Some(byte_pos) = super::click_geometry::screen_to_buffer_position(
614            col,
615            row,
616            content_rect,
617            gutter_width,
618            &cached_mappings,
619            fallback,
620            false, // Don't include gutter
621            compose_width,
622        ) else {
623            // Mouse is in gutter - clear hover state
624            if self.mouse_state.lsp_hover_state.is_some() {
625                self.mouse_state.lsp_hover_state = None;
626                self.mouse_state.lsp_hover_request_sent = false;
627                self.dismiss_transient_popups();
628            }
629            return;
630        };
631
632        // Check if mouse is past the end of line content - don't trigger hover for empty space
633        let content_col = col.saturating_sub(content_rect.x);
634        let text_col = content_col.saturating_sub(gutter_width) as usize;
635        let visual_row = row.saturating_sub(content_rect.y) as usize;
636
637        let line_info = cached_mappings
638            .as_ref()
639            .and_then(|mappings| mappings.get(visual_row))
640            .map(|line_mapping| {
641                (
642                    line_mapping.visual_to_char.len(),
643                    line_mapping.line_end_byte,
644                )
645            });
646
647        let is_past_line_end_or_empty = line_info
648            .map(|(line_len, _)| {
649                // Empty lines (just newline) should not trigger hover
650                if line_len <= 1 {
651                    return true;
652                }
653                text_col >= line_len
654            })
655            // If mouse is below all mapped lines (no mapping), don't trigger hover
656            .unwrap_or(true);
657
658        tracing::trace!(
659            col,
660            row,
661            content_col,
662            text_col,
663            visual_row,
664            gutter_width,
665            byte_pos,
666            ?line_info,
667            is_past_line_end_or_empty,
668            "update_lsp_hover_state: position check"
669        );
670
671        if is_past_line_end_or_empty {
672            tracing::trace!(
673                "update_lsp_hover_state: mouse past line end or empty line, clearing hover"
674            );
675            // Mouse is past end of line content - clear hover state and don't trigger new hover
676            if self.mouse_state.lsp_hover_state.is_some() {
677                self.mouse_state.lsp_hover_state = None;
678                self.mouse_state.lsp_hover_request_sent = false;
679                self.dismiss_transient_popups();
680            }
681            return;
682        }
683
684        // Check if mouse is within the hovered symbol range - if so, keep hover active
685        if let Some((start, end)) = self.hover.symbol_range() {
686            if byte_pos >= start && byte_pos < end {
687                // Mouse is still over the hovered symbol - keep hover state
688                return;
689            }
690        }
691
692        // Check if we're still hovering the same position
693        if let Some((old_pos, _, _, _)) = self.mouse_state.lsp_hover_state {
694            if old_pos == byte_pos {
695                // Same position - keep existing state
696                return;
697            }
698            // Position changed outside symbol range - reset state and dismiss popup
699            self.dismiss_transient_popups();
700        }
701
702        // Start tracking new hover position
703        self.mouse_state.lsp_hover_state = Some((byte_pos, std::time::Instant::now(), col, row));
704        self.mouse_state.lsp_hover_request_sent = false;
705    }
706
707    /// Check if mouse position is over a transient popup (hover, signature help)
708    fn is_mouse_over_transient_popup(&self, col: u16, row: u16) -> bool {
709        let layouts = popup_areas_to_layout_info(&self.cached_layout.popup_areas);
710        let hit_tester = PopupHitTester::new(&layouts, &self.active_state().popups);
711        hit_tester.is_over_transient_popup(col, row)
712    }
713
714    /// Check if mouse position is over any popup (including non-transient ones like completion)
715    fn is_mouse_over_any_popup(&self, col: u16, row: u16) -> bool {
716        // Editor-level popup overlays absorb every click within their outer
717        // rect so the buffer below doesn't receive a stray cursor placement.
718        for (_, popup_area, _, _, _) in &self.cached_layout.global_popup_areas {
719            if col >= popup_area.x
720                && col < popup_area.x + popup_area.width
721                && row >= popup_area.y
722                && row < popup_area.y + popup_area.height
723            {
724                return true;
725            }
726        }
727        let layouts = popup_areas_to_layout_info(&self.cached_layout.popup_areas);
728        let hit_tester = PopupHitTester::new(&layouts, &self.active_state().popups);
729        hit_tester.is_over_popup(col, row)
730    }
731
732    /// Check if mouse position is over the file browser popup
733    fn is_mouse_over_file_browser(&self, col: u16, row: u16) -> bool {
734        self.file_browser_layout
735            .as_ref()
736            .is_some_and(|layout| layout.contains(col, row))
737    }
738
739    /// Find the split whose content or scrollbar area contains (col, row).
740    /// Returns the split id and its buffer id, or None if not over any split.
741    pub(super) fn split_at_position(&self, col: u16, row: u16) -> Option<(LeafId, BufferId)> {
742        for &(split_id, buffer_id, content_rect, scrollbar_rect, _, _) in
743            &self.cached_layout.split_areas
744        {
745            let in_content = col >= content_rect.x
746                && col < content_rect.x + content_rect.width
747                && row >= content_rect.y
748                && row < content_rect.y + content_rect.height;
749            let in_scrollbar = scrollbar_rect.width > 0
750                && scrollbar_rect.height > 0
751                && col >= scrollbar_rect.x
752                && col < scrollbar_rect.x + scrollbar_rect.width
753                && row >= scrollbar_rect.y
754                && row < scrollbar_rect.y + scrollbar_rect.height;
755            if in_content || in_scrollbar {
756                return Some((split_id, buffer_id));
757            }
758        }
759        None
760    }
761
762    /// Compute what hover target is at the given position
763    fn compute_hover_target(&self, col: u16, row: u16) -> Option<HoverTarget> {
764        if let Some(ref menu) = self.file_explorer_context_menu {
765            let (menu_x, menu_y) = menu.clamped_position(
766                self.cached_layout.last_frame_width,
767                self.cached_layout.last_frame_height,
768            );
769            let menu_width = super::types::FILE_EXPLORER_CONTEXT_MENU_WIDTH;
770            let menu_height = menu.height();
771
772            if col >= menu_x
773                && col < menu_x + menu_width
774                && row > menu_y
775                && row < menu_y + menu_height - 1
776            {
777                let item_idx = (row - menu_y - 1) as usize;
778                if item_idx < menu.items().len() {
779                    return Some(HoverTarget::FileExplorerContextMenuItem(item_idx));
780                }
781            }
782        }
783
784        // Check tab context menu first (it's rendered on top)
785        if let Some(ref menu) = self.tab_context_menu {
786            let menu_x = menu.position.0;
787            let menu_y = menu.position.1;
788            let menu_width = 22u16;
789            let items = super::types::TabContextMenuItem::all();
790            let menu_height = items.len() as u16 + 2;
791
792            if col >= menu_x
793                && col < menu_x + menu_width
794                && row > menu_y
795                && row < menu_y + menu_height - 1
796            {
797                let item_idx = (row - menu_y - 1) as usize;
798                if item_idx < items.len() {
799                    return Some(HoverTarget::TabContextMenuItem(item_idx));
800                }
801            }
802        }
803
804        // Check suggestions area first (command palette, autocomplete)
805        if let Some((inner_rect, start_idx, _visible_count, total_count)) =
806            &self.cached_layout.suggestions_area
807        {
808            if col >= inner_rect.x
809                && col < inner_rect.x + inner_rect.width
810                && row >= inner_rect.y
811                && row < inner_rect.y + inner_rect.height
812            {
813                let relative_row = (row - inner_rect.y) as usize;
814                let item_idx = start_idx + relative_row;
815
816                if item_idx < *total_count {
817                    return Some(HoverTarget::SuggestionItem(item_idx));
818                }
819            }
820        }
821
822        // Check popups (they're rendered on top)
823        // Check from top to bottom (reverse order since last popup is on top)
824        for (popup_idx, _popup_rect, inner_rect, scroll_offset, num_items, _, _) in
825            self.cached_layout.popup_areas.iter().rev()
826        {
827            if col >= inner_rect.x
828                && col < inner_rect.x + inner_rect.width
829                && row >= inner_rect.y
830                && row < inner_rect.y + inner_rect.height
831                && *num_items > 0
832            {
833                // Calculate which item is being hovered
834                let relative_row = (row - inner_rect.y) as usize;
835                let item_idx = scroll_offset + relative_row;
836
837                if item_idx < *num_items {
838                    return Some(HoverTarget::PopupListItem(*popup_idx, item_idx));
839                }
840            }
841        }
842
843        // Check file browser popup
844        if self.is_file_open_active() {
845            if let Some(hover) = self.compute_file_browser_hover(col, row) {
846                return Some(hover);
847            }
848        }
849
850        // Check menu bar (row 0, only when visible)
851        // Check menu bar using cached layout from previous render
852        if self.menu_bar_visible {
853            if let Some(ref menu_layout) = self.cached_layout.menu_layout {
854                if let Some(menu_idx) = menu_layout.menu_at(col, row) {
855                    return Some(HoverTarget::MenuBarItem(menu_idx));
856                }
857            }
858        }
859
860        // Check menu dropdown items if a menu is open (including submenus)
861        if let Some(active_idx) = self.menu_state.active_menu {
862            if let Some(hover) = self.compute_menu_dropdown_hover(col, row, active_idx) {
863                return Some(hover);
864            }
865        }
866
867        // Check file explorer close button and border (for resize)
868        if let Some(explorer_area) = self.cached_layout.file_explorer_area {
869            // Close button is at position: explorer_area.x + explorer_area.width - 3 to -1
870            let close_button_x = explorer_area.x + explorer_area.width.saturating_sub(3);
871            if row == explorer_area.y
872                && col >= close_button_x
873                && col < explorer_area.x + explorer_area.width
874            {
875                return Some(HoverTarget::FileExplorerCloseButton);
876            }
877
878            // Check if hovering over a status indicator in the file explorer content area
879            // Status indicators are in the rightmost 2 characters of each row (before border)
880            let content_start_y = explorer_area.y + 1; // +1 for title bar
881            let content_end_y = explorer_area.y + explorer_area.height.saturating_sub(1); // -1 for bottom border
882            let status_indicator_x = explorer_area.x + explorer_area.width.saturating_sub(3); // 2 chars + 1 border
883
884            if row >= content_start_y
885                && row < content_end_y
886                && col >= status_indicator_x
887                && col < explorer_area.x + explorer_area.width.saturating_sub(1)
888            {
889                // Determine which item is at this row
890                if let Some(ref explorer) = self.file_explorer {
891                    let relative_row = row.saturating_sub(content_start_y) as usize;
892                    let scroll_offset = explorer.get_scroll_offset();
893                    let item_index = relative_row + scroll_offset;
894                    let display_nodes = explorer.get_display_nodes();
895
896                    if item_index < display_nodes.len() {
897                        let (node_id, _indent) = display_nodes[item_index];
898                        if let Some(node) = explorer.tree().get_node(node_id) {
899                            return Some(HoverTarget::FileExplorerStatusIndicator(
900                                node.entry.path.clone(),
901                            ));
902                        }
903                    }
904                }
905            }
906
907            // The border is at the rightmost column of the file explorer area
908            // (the drawn border character), not one past it.
909            let border_x = explorer_area.x + explorer_area.width.saturating_sub(1);
910            if col == border_x
911                && row >= explorer_area.y
912                && row < explorer_area.y + explorer_area.height
913            {
914                return Some(HoverTarget::FileExplorerBorder);
915            }
916        }
917
918        // Check split separators
919        for (split_id, direction, sep_x, sep_y, sep_length) in &self.cached_layout.separator_areas {
920            let is_on_separator = match direction {
921                SplitDirection::Horizontal => {
922                    row == *sep_y && col >= *sep_x && col < sep_x + sep_length
923                }
924                SplitDirection::Vertical => {
925                    col == *sep_x && row >= *sep_y && row < sep_y + sep_length
926                }
927            };
928
929            if is_on_separator {
930                return Some(HoverTarget::SplitSeparator(*split_id, *direction));
931            }
932        }
933
934        // Check tab areas using cached hit regions (computed during rendering)
935        // Check split control buttons first (they're on top of the tab row)
936        for (split_id, btn_row, start_col, end_col) in &self.cached_layout.close_split_areas {
937            if row == *btn_row && col >= *start_col && col < *end_col {
938                return Some(HoverTarget::CloseSplitButton(*split_id));
939            }
940        }
941
942        for (split_id, btn_row, start_col, end_col) in &self.cached_layout.maximize_split_areas {
943            if row == *btn_row && col >= *start_col && col < *end_col {
944                return Some(HoverTarget::MaximizeSplitButton(*split_id));
945            }
946        }
947
948        for (split_id, tab_layout) in &self.cached_layout.tab_layouts {
949            match tab_layout.hit_test(col, row) {
950                Some(TabHit::CloseButton(target)) => {
951                    return Some(HoverTarget::TabCloseButton(target, *split_id));
952                }
953                Some(TabHit::TabName(target)) => {
954                    return Some(HoverTarget::TabName(target, *split_id));
955                }
956                Some(TabHit::ScrollLeft)
957                | Some(TabHit::ScrollRight)
958                | Some(TabHit::BarBackground)
959                | None => {}
960            }
961        }
962
963        // Check scrollbars
964        for (split_id, _buffer_id, _content_rect, scrollbar_rect, thumb_start, thumb_end) in
965            &self.cached_layout.split_areas
966        {
967            if col >= scrollbar_rect.x
968                && col < scrollbar_rect.x + scrollbar_rect.width
969                && row >= scrollbar_rect.y
970                && row < scrollbar_rect.y + scrollbar_rect.height
971            {
972                let relative_row = row.saturating_sub(scrollbar_rect.y) as usize;
973                let is_on_thumb = relative_row >= *thumb_start && relative_row < *thumb_end;
974
975                if is_on_thumb {
976                    return Some(HoverTarget::ScrollbarThumb(*split_id));
977                } else {
978                    return Some(HoverTarget::ScrollbarTrack(*split_id, relative_row as u16));
979                }
980            }
981        }
982
983        // Check status bar indicators
984        if let Some((status_row, _status_x, _status_width)) = self.cached_layout.status_bar_area {
985            if row == status_row {
986                // Check line ending indicator area
987                if let Some((le_row, le_start, le_end)) =
988                    self.cached_layout.status_bar_line_ending_area
989                {
990                    if row == le_row && col >= le_start && col < le_end {
991                        return Some(HoverTarget::StatusBarLineEndingIndicator);
992                    }
993                }
994
995                // Check encoding indicator area
996                if let Some((enc_row, enc_start, enc_end)) =
997                    self.cached_layout.status_bar_encoding_area
998                {
999                    if row == enc_row && col >= enc_start && col < enc_end {
1000                        return Some(HoverTarget::StatusBarEncodingIndicator);
1001                    }
1002                }
1003
1004                // Check language indicator area
1005                if let Some((lang_row, lang_start, lang_end)) =
1006                    self.cached_layout.status_bar_language_area
1007                {
1008                    if row == lang_row && col >= lang_start && col < lang_end {
1009                        return Some(HoverTarget::StatusBarLanguageIndicator);
1010                    }
1011                }
1012
1013                // Check LSP indicator area
1014                if let Some((lsp_row, lsp_start, lsp_end)) = self.cached_layout.status_bar_lsp_area
1015                {
1016                    if row == lsp_row && col >= lsp_start && col < lsp_end {
1017                        return Some(HoverTarget::StatusBarLspIndicator);
1018                    }
1019                }
1020
1021                // Check remote indicator area
1022                if let Some((rem_row, rem_start, rem_end)) =
1023                    self.cached_layout.status_bar_remote_area
1024                {
1025                    if row == rem_row && col >= rem_start && col < rem_end {
1026                        return Some(HoverTarget::StatusBarRemoteIndicator);
1027                    }
1028                }
1029
1030                // Check warning badge area
1031                if let Some((warn_row, warn_start, warn_end)) =
1032                    self.cached_layout.status_bar_warning_area
1033                {
1034                    if row == warn_row && col >= warn_start && col < warn_end {
1035                        return Some(HoverTarget::StatusBarWarningBadge);
1036                    }
1037                }
1038            }
1039        }
1040
1041        // Check search options bar checkboxes
1042        if let Some(ref layout) = self.cached_layout.search_options_layout {
1043            use crate::view::ui::status_bar::SearchOptionsHover;
1044            if let Some(hover) = layout.checkbox_at(col, row) {
1045                return Some(match hover {
1046                    SearchOptionsHover::CaseSensitive => HoverTarget::SearchOptionCaseSensitive,
1047                    SearchOptionsHover::WholeWord => HoverTarget::SearchOptionWholeWord,
1048                    SearchOptionsHover::Regex => HoverTarget::SearchOptionRegex,
1049                    SearchOptionsHover::ConfirmEach => HoverTarget::SearchOptionConfirmEach,
1050                    SearchOptionsHover::None => return None,
1051                });
1052            }
1053        }
1054
1055        // No hover target
1056        None
1057    }
1058
1059    /// Handle mouse double click (down event)
1060    /// Double-click in editor area selects the word under the cursor.
1061    pub(super) fn handle_mouse_double_click(&mut self, col: u16, row: u16) -> AnyhowResult<()> {
1062        tracing::debug!("handle_mouse_double_click at col={}, row={}", col, row);
1063
1064        // Handle popups: dismiss if clicking outside, block if clicking inside
1065        if self.is_mouse_over_any_popup(col, row) {
1066            // Double-click inside popup - block from reaching editor
1067            return Ok(());
1068        } else {
1069            // Double-click outside popup - dismiss transient popups
1070            self.dismiss_transient_popups();
1071        }
1072
1073        // Is it in the file open dialog?
1074        if self.handle_file_open_double_click(col, row) {
1075            return Ok(());
1076        }
1077
1078        // Is it in the file explorer? Double-click opens file AND focuses editor
1079        if let Some(explorer_area) = self.cached_layout.file_explorer_area {
1080            if col >= explorer_area.x
1081                && col < explorer_area.x + explorer_area.width
1082                && row > explorer_area.y // Skip title bar
1083                && row < explorer_area.y + explorer_area.height
1084            {
1085                // Open file and focus editor (via file_explorer_open_file which calls focus_editor)
1086                self.file_explorer_open_file()?;
1087                return Ok(());
1088            }
1089        }
1090
1091        // Find which split/buffer was clicked and handle double-click
1092        let split_areas = self.cached_layout.split_areas.clone();
1093        for (split_id, buffer_id, content_rect, _scrollbar_rect, _thumb_start, _thumb_end) in
1094            &split_areas
1095        {
1096            if col >= content_rect.x
1097                && col < content_rect.x + content_rect.width
1098                && row >= content_rect.y
1099                && row < content_rect.y + content_rect.height
1100            {
1101                // Double-clicked on an editor split
1102                if self.is_terminal_buffer(*buffer_id) {
1103                    self.key_context = crate::input::keybindings::KeyContext::Terminal;
1104                    // Don't select word in terminal buffers
1105                    return Ok(());
1106                }
1107
1108                self.key_context = crate::input::keybindings::KeyContext::Normal;
1109
1110                // Position cursor at click location and select word
1111                self.handle_editor_double_click(col, row, *split_id, *buffer_id, *content_rect)?;
1112                return Ok(());
1113            }
1114        }
1115
1116        Ok(())
1117    }
1118
1119    /// Handle double-click in editor content area - selects the word under cursor
1120    fn handle_editor_double_click(
1121        &mut self,
1122        col: u16,
1123        row: u16,
1124        split_id: LeafId,
1125        buffer_id: BufferId,
1126        content_rect: ratatui::layout::Rect,
1127    ) -> AnyhowResult<()> {
1128        use crate::model::event::Event;
1129
1130        // Fixed panels (toolbars, headers) are inert — no click focus,
1131        // no selection. Scrollable group panels still accept clicks even
1132        // when their cursor is hidden.
1133        if self.is_non_scrollable_buffer(buffer_id) {
1134            return Ok(());
1135        }
1136
1137        // Focus this split
1138        self.focus_split(split_id, buffer_id);
1139
1140        // Get cached view line mappings for this split
1141        let cached_mappings = self
1142            .cached_layout
1143            .view_line_mappings
1144            .get(&split_id)
1145            .cloned();
1146
1147        // Get fallback from SplitViewState viewport
1148        let leaf_id = split_id;
1149        let fallback = self
1150            .split_view_states
1151            .get(&leaf_id)
1152            .map(|vs| vs.viewport.top_byte)
1153            .unwrap_or(0);
1154
1155        // Get compose width for this split
1156        let compose_width = self
1157            .split_view_states
1158            .get(&leaf_id)
1159            .and_then(|vs| vs.compose_width);
1160
1161        // Calculate clicked position in buffer
1162        if let Some(state) = self.buffers.get_mut(&buffer_id) {
1163            let gutter_width = state.margins.left_total_width() as u16;
1164
1165            let Some(target_position) = super::click_geometry::screen_to_buffer_position(
1166                col,
1167                row,
1168                content_rect,
1169                gutter_width,
1170                &cached_mappings,
1171                fallback,
1172                true, // Allow gutter clicks
1173                compose_width,
1174            ) else {
1175                return Ok(());
1176            };
1177
1178            // Move cursor to clicked position first
1179            let primary_cursor_id = self
1180                .split_view_states
1181                .get(&leaf_id)
1182                .map(|vs| vs.cursors.primary_id())
1183                .unwrap_or(CursorId(0));
1184            let event = Event::MoveCursor {
1185                cursor_id: primary_cursor_id,
1186                old_position: 0,
1187                new_position: target_position,
1188                old_anchor: None,
1189                new_anchor: None,
1190                old_sticky_column: 0,
1191                new_sticky_column: 0,
1192            };
1193
1194            if let Some(event_log) = self.event_logs.get_mut(&buffer_id) {
1195                event_log.append(event.clone());
1196            }
1197            if let Some(cursors) = self
1198                .split_view_states
1199                .get_mut(&leaf_id)
1200                .map(|vs| &mut vs.cursors)
1201            {
1202                state.apply(cursors, &event);
1203            }
1204        }
1205
1206        // Now select the word under cursor
1207        self.handle_action(Action::SelectWord)?;
1208
1209        // Set up drag state so subsequent drag events extend selection word-by-word
1210        if let Some(cursor) = self
1211            .split_view_states
1212            .get(&leaf_id)
1213            .map(|vs| vs.cursors.primary())
1214        {
1215            // Store both edges of the selected word so we can use the appropriate
1216            // anchor when dragging forward (use word start) vs backward (use word end).
1217            let sel_start = cursor.selection_start();
1218            let sel_end = cursor.selection_end();
1219            self.mouse_state.dragging_text_selection = true;
1220            self.mouse_state.drag_selection_split = Some(split_id);
1221            self.mouse_state.drag_selection_anchor = Some(sel_start);
1222            self.mouse_state.drag_selection_by_words = true;
1223            self.mouse_state.drag_selection_word_end = Some(sel_end);
1224        }
1225
1226        Ok(())
1227    }
1228    /// Handle mouse triple click (down event)
1229    /// Triple-click in editor area selects the entire line under the cursor.
1230    pub(super) fn handle_mouse_triple_click(&mut self, col: u16, row: u16) -> AnyhowResult<()> {
1231        tracing::debug!("handle_mouse_triple_click at col={}, row={}", col, row);
1232
1233        // Handle popups: dismiss if clicking outside, block if clicking inside
1234        if self.is_mouse_over_any_popup(col, row) {
1235            return Ok(());
1236        } else {
1237            self.dismiss_transient_popups();
1238        }
1239
1240        // Find which split/buffer was clicked
1241        let split_areas = self.cached_layout.split_areas.clone();
1242        for (split_id, buffer_id, content_rect, _scrollbar_rect, _thumb_start, _thumb_end) in
1243            &split_areas
1244        {
1245            if col >= content_rect.x
1246                && col < content_rect.x + content_rect.width
1247                && row >= content_rect.y
1248                && row < content_rect.y + content_rect.height
1249            {
1250                if self.is_terminal_buffer(*buffer_id) {
1251                    return Ok(());
1252                }
1253
1254                self.key_context = crate::input::keybindings::KeyContext::Normal;
1255
1256                // Use the same pattern as handle_editor_double_click:
1257                // first focus and position cursor, then select line
1258                self.handle_editor_triple_click(col, row, *split_id, *buffer_id, *content_rect)?;
1259                return Ok(());
1260            }
1261        }
1262
1263        Ok(())
1264    }
1265
1266    /// Handle triple-click in editor content area - selects the entire line under cursor
1267    fn handle_editor_triple_click(
1268        &mut self,
1269        col: u16,
1270        row: u16,
1271        split_id: LeafId,
1272        buffer_id: BufferId,
1273        content_rect: ratatui::layout::Rect,
1274    ) -> AnyhowResult<()> {
1275        use crate::model::event::Event;
1276
1277        if self.is_non_scrollable_buffer(buffer_id) {
1278            return Ok(());
1279        }
1280
1281        // Focus this split
1282        self.focus_split(split_id, buffer_id);
1283
1284        // Get cached view line mappings for this split
1285        let cached_mappings = self
1286            .cached_layout
1287            .view_line_mappings
1288            .get(&split_id)
1289            .cloned();
1290
1291        let leaf_id = split_id;
1292        let fallback = self
1293            .split_view_states
1294            .get(&leaf_id)
1295            .map(|vs| vs.viewport.top_byte)
1296            .unwrap_or(0);
1297
1298        // Get compose width for this split
1299        let compose_width = self
1300            .split_view_states
1301            .get(&leaf_id)
1302            .and_then(|vs| vs.compose_width);
1303
1304        // Calculate clicked position in buffer
1305        if let Some(state) = self.buffers.get_mut(&buffer_id) {
1306            let gutter_width = state.margins.left_total_width() as u16;
1307
1308            let Some(target_position) = super::click_geometry::screen_to_buffer_position(
1309                col,
1310                row,
1311                content_rect,
1312                gutter_width,
1313                &cached_mappings,
1314                fallback,
1315                true,
1316                compose_width,
1317            ) else {
1318                return Ok(());
1319            };
1320
1321            // Move cursor to clicked position first
1322            let primary_cursor_id = self
1323                .split_view_states
1324                .get(&leaf_id)
1325                .map(|vs| vs.cursors.primary_id())
1326                .unwrap_or(CursorId(0));
1327            let event = Event::MoveCursor {
1328                cursor_id: primary_cursor_id,
1329                old_position: 0,
1330                new_position: target_position,
1331                old_anchor: None,
1332                new_anchor: None,
1333                old_sticky_column: 0,
1334                new_sticky_column: 0,
1335            };
1336
1337            if let Some(event_log) = self.event_logs.get_mut(&buffer_id) {
1338                event_log.append(event.clone());
1339            }
1340            if let Some(cursors) = self
1341                .split_view_states
1342                .get_mut(&leaf_id)
1343                .map(|vs| &mut vs.cursors)
1344            {
1345                state.apply(cursors, &event);
1346            }
1347        }
1348
1349        // Now select the entire line
1350        self.handle_action(Action::SelectLine)?;
1351
1352        Ok(())
1353    }
1354
1355    /// Handle mouse click (down event)
1356    pub(super) fn handle_mouse_click(
1357        &mut self,
1358        col: u16,
1359        row: u16,
1360        modifiers: crossterm::event::KeyModifiers,
1361    ) -> AnyhowResult<()> {
1362        if let Some(r) = self.handle_click_context_menus(col, row) {
1363            return r;
1364        }
1365        if !self.is_mouse_over_any_popup(col, row) {
1366            self.dismiss_transient_popups();
1367        }
1368        if let Some(r) = self.handle_click_suggestions(col, row) {
1369            return r;
1370        }
1371        if let Some(r) = self.handle_click_popup_scrollbar(col, row) {
1372            return r;
1373        }
1374        if let Some(r) = self.handle_click_global_popups(col, row) {
1375            return r;
1376        }
1377        if let Some(r) = self.handle_click_buffer_popups(col, row) {
1378            return r;
1379        }
1380        if self.is_mouse_over_any_popup(col, row) {
1381            return Ok(());
1382        }
1383        if self.is_file_open_active() && self.handle_file_open_click(col, row) {
1384            return Ok(());
1385        }
1386        if let Some(r) = self.handle_click_menu_bar(col, row) {
1387            return r;
1388        }
1389        if let Some(r) = self.handle_click_file_explorer_area(col, row) {
1390            return r;
1391        }
1392        if let Some(r) = self.handle_click_scrollbar(col, row) {
1393            return r;
1394        }
1395        if let Some(r) = self.handle_click_horizontal_scrollbar(col, row) {
1396            return r;
1397        }
1398        if let Some(r) = self.handle_click_status_bar(col, row) {
1399            return r;
1400        }
1401        if let Some(r) = self.handle_click_search_options(col, row) {
1402            return r;
1403        }
1404        if let Some(r) = self.handle_click_split_separator(col, row) {
1405            return r;
1406        }
1407        if let Some(r) = self.handle_click_split_controls(col, row) {
1408            return r;
1409        }
1410        if let Some(r) = self.handle_click_tab_bar(col, row) {
1411            return r;
1412        }
1413
1414        // Check if click is in editor content area
1415        tracing::debug!(
1416            "handle_mouse_click: checking {} split_areas for click at ({}, {})",
1417            self.cached_layout.split_areas.len(),
1418            col,
1419            row
1420        );
1421        for (split_id, buffer_id, content_rect, _scrollbar_rect, _thumb_start, _thumb_end) in
1422            &self.cached_layout.split_areas
1423        {
1424            tracing::debug!(
1425                "  split_id={:?}, content_rect=({}, {}, {}x{})",
1426                split_id,
1427                content_rect.x,
1428                content_rect.y,
1429                content_rect.width,
1430                content_rect.height
1431            );
1432            if col >= content_rect.x
1433                && col < content_rect.x + content_rect.width
1434                && row >= content_rect.y
1435                && row < content_rect.y + content_rect.height
1436            {
1437                // Click in editor - focus split and position cursor
1438                tracing::debug!("  -> HIT! calling handle_editor_click");
1439                self.handle_editor_click(
1440                    col,
1441                    row,
1442                    *split_id,
1443                    *buffer_id,
1444                    *content_rect,
1445                    modifiers,
1446                )?;
1447                return Ok(());
1448            }
1449        }
1450        tracing::debug!("  -> No split area hit");
1451
1452        Ok(())
1453    }
1454
1455    // ── handle_mouse_click helpers ──────────────────────────────────────────
1456    // Each returns Some(result) if the click was consumed, None to fall through.
1457
1458    fn handle_click_context_menus(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
1459        if self.file_explorer_context_menu.is_some() {
1460            if let Some(result) = self.handle_file_explorer_context_menu_click(col, row) {
1461                return Some(result);
1462            }
1463        }
1464        if self.tab_context_menu.is_some() {
1465            if let Some(result) = self.handle_tab_context_menu_click(col, row) {
1466                return Some(result);
1467            }
1468        }
1469        None
1470    }
1471
1472    fn handle_click_suggestions(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
1473        let (inner_rect, start_idx, _visible_count, total_count) =
1474            self.cached_layout.suggestions_area?;
1475        if col >= inner_rect.x
1476            && col < inner_rect.x + inner_rect.width
1477            && row >= inner_rect.y
1478            && row < inner_rect.y + inner_rect.height
1479        {
1480            let relative_row = (row - inner_rect.y) as usize;
1481            let item_idx = start_idx + relative_row;
1482            if item_idx < total_count {
1483                if let Some(prompt) = &mut self.prompt {
1484                    prompt.selected_suggestion = Some(item_idx);
1485                }
1486                return Some(self.handle_action(Action::PromptConfirm));
1487            }
1488        }
1489        None
1490    }
1491
1492    fn handle_click_popup_scrollbar(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
1493        // Collect all needed data before mutating self.
1494        let scrollbar_info: Option<(usize, i32)> =
1495            self.cached_layout.popup_areas.iter().rev().find_map(
1496                |(popup_idx, _popup_rect, inner_rect, _scroll, _n, scrollbar_rect, total_lines)| {
1497                    let sb_rect = scrollbar_rect.as_ref()?;
1498                    if col >= sb_rect.x
1499                        && col < sb_rect.x + sb_rect.width
1500                        && row >= sb_rect.y
1501                        && row < sb_rect.y + sb_rect.height
1502                    {
1503                        let relative_row = (row - sb_rect.y) as usize;
1504                        let track_height = sb_rect.height as usize;
1505                        let visible_lines = inner_rect.height as usize;
1506                        if track_height > 0 && *total_lines > visible_lines {
1507                            let max_scroll = total_lines.saturating_sub(visible_lines);
1508                            let target = if track_height > 1 {
1509                                (relative_row * max_scroll) / (track_height.saturating_sub(1))
1510                            } else {
1511                                0
1512                            };
1513                            Some((*popup_idx, target as i32))
1514                        } else {
1515                            Some((*popup_idx, 0))
1516                        }
1517                    } else {
1518                        None
1519                    }
1520                },
1521            );
1522        let (popup_idx, target_scroll) = scrollbar_info?;
1523        self.mouse_state.dragging_popup_scrollbar = Some(popup_idx);
1524        self.mouse_state.drag_start_row = Some(row);
1525        let current_scroll = self
1526            .active_state()
1527            .popups
1528            .get(popup_idx)
1529            .map(|p| p.scroll_offset)
1530            .unwrap_or(0);
1531        self.mouse_state.drag_start_popup_scroll = Some(current_scroll);
1532        let state = self.active_state_mut();
1533        if let Some(popup) = state.popups.get_mut(popup_idx) {
1534            popup.scroll_by(target_scroll - current_scroll as i32);
1535        }
1536        Some(Ok(()))
1537    }
1538
1539    fn handle_click_global_popups(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
1540        for (popup_idx, popup_rect, inner_rect, scroll_offset, num_items) in self
1541            .cached_layout
1542            .global_popup_areas
1543            .clone()
1544            .into_iter()
1545            .rev()
1546        {
1547            if popup_rect.width >= 5 {
1548                let cb_x = popup_rect.x + popup_rect.width - 4;
1549                if row == popup_rect.y && col >= cb_x && col < cb_x + 3 {
1550                    return Some(self.handle_action(Action::PopupCancel));
1551                }
1552            }
1553            if col >= inner_rect.x
1554                && col < inner_rect.x + inner_rect.width
1555                && row >= inner_rect.y
1556                && row < inner_rect.y + inner_rect.height
1557                && num_items > 0
1558            {
1559                let relative_row = (row - inner_rect.y) as usize;
1560                let item_idx = scroll_offset + relative_row;
1561                if item_idx < num_items {
1562                    if let Some(popup) = self.global_popups.get_mut(popup_idx) {
1563                        if let crate::view::popup::PopupContent::List { items: _, selected } =
1564                            &mut popup.content
1565                        {
1566                            *selected = item_idx;
1567                        }
1568                    }
1569                    return Some(self.handle_action(Action::PopupConfirm));
1570                }
1571            }
1572        }
1573        None
1574    }
1575
1576    fn handle_click_buffer_popups(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
1577        // Check close-button overlay ("[×]") on each popup.
1578        let close_hit = self.cached_layout.popup_areas.iter().rev().find_map(
1579            |(_idx, popup_rect, _inner, _scroll, _n, _sb, _tl)| {
1580                if popup_rect.width < 5 {
1581                    return None;
1582                }
1583                let cb_x = popup_rect.x + popup_rect.width - 4;
1584                if row == popup_rect.y && col >= cb_x && col < cb_x + 3 {
1585                    Some(())
1586                } else {
1587                    None
1588                }
1589            },
1590        );
1591        if close_hit.is_some() {
1592            return Some(self.handle_action(Action::PopupCancel));
1593        }
1594
1595        // Content area clicks — clone to allow &mut self calls inside the loop.
1596        let popup_areas = self.cached_layout.popup_areas.clone();
1597        for (popup_idx, _popup_rect, inner_rect, scroll_offset, num_items, _, _) in
1598            popup_areas.iter().rev()
1599        {
1600            if !(col >= inner_rect.x
1601                && col < inner_rect.x + inner_rect.width
1602                && row >= inner_rect.y
1603                && row < inner_rect.y + inner_rect.height)
1604            {
1605                continue;
1606            }
1607            let relative_col = (col - inner_rect.x) as usize;
1608            let relative_row = (row - inner_rect.y) as usize;
1609
1610            let link_url = {
1611                let state = self.active_state();
1612                state
1613                    .popups
1614                    .top()
1615                    .and_then(|p| p.link_at_position(relative_col, relative_row))
1616            };
1617            if let Some(url) = link_url {
1618                #[cfg(feature = "runtime")]
1619                if let Err(e) = open::that(&url) {
1620                    self.set_status_message(format!("Failed to open URL: {}", e));
1621                } else {
1622                    self.set_status_message(format!("Opening: {}", url));
1623                }
1624                return Some(Ok(()));
1625            }
1626
1627            if *num_items > 0 {
1628                let item_idx = scroll_offset + relative_row;
1629                if item_idx < *num_items {
1630                    let state = self.active_state_mut();
1631                    if let Some(popup) = state.popups.top_mut() {
1632                        if let crate::view::popup::PopupContent::List { items: _, selected } =
1633                            &mut popup.content
1634                        {
1635                            *selected = item_idx;
1636                        }
1637                    }
1638                    return Some(self.handle_action(Action::PopupConfirm));
1639                }
1640            }
1641
1642            let is_text_popup = {
1643                let state = self.active_state();
1644                state.popups.top().is_some_and(|p| {
1645                    matches!(
1646                        p.content,
1647                        crate::view::popup::PopupContent::Text(_)
1648                            | crate::view::popup::PopupContent::Markdown(_)
1649                    )
1650                })
1651            };
1652            if is_text_popup {
1653                let line = scroll_offset + relative_row;
1654                let popup_idx_copy = *popup_idx;
1655                let state = self.active_state_mut();
1656                if let Some(popup) = state.popups.top_mut() {
1657                    popup.start_selection(line, relative_col);
1658                }
1659                self.mouse_state.selecting_in_popup = Some(popup_idx_copy);
1660                return Some(Ok(()));
1661            }
1662        }
1663        None
1664    }
1665
1666    fn handle_click_menu_bar(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
1667        if self.menu_bar_visible {
1668            // Resolve the hit before any &mut operations to avoid borrow conflicts.
1669            let hit = self
1670                .cached_layout
1671                .menu_layout
1672                .as_ref()
1673                .and_then(|ml| ml.menu_at(col, row));
1674            let layout_exists = self.cached_layout.menu_layout.is_some();
1675            if layout_exists {
1676                if let Some(menu_idx) = hit {
1677                    if self.menu_state.active_menu == Some(menu_idx) {
1678                        self.close_menu_with_auto_hide();
1679                    } else {
1680                        self.on_editor_focus_lost();
1681                        self.menu_state.open_menu(menu_idx);
1682                    }
1683                    return Some(Ok(()));
1684                } else if row == 0 {
1685                    self.close_menu_with_auto_hide();
1686                    return Some(Ok(()));
1687                }
1688            }
1689        }
1690
1691        if let Some(active_idx) = self.menu_state.active_menu {
1692            let all_menus: Vec<crate::config::Menu> = self
1693                .menus
1694                .menus
1695                .iter()
1696                .chain(self.menu_state.plugin_menus.iter())
1697                .cloned()
1698                .collect();
1699            if let Some(menu) = all_menus.get(active_idx) {
1700                match self.handle_menu_dropdown_click(col, row, menu) {
1701                    Ok(Some(click_result)) => return Some(click_result),
1702                    Ok(None) => {}
1703                    Err(e) => return Some(Err(e)),
1704                }
1705            }
1706            self.close_menu_with_auto_hide();
1707            return Some(Ok(()));
1708        }
1709
1710        None
1711    }
1712
1713    fn handle_click_file_explorer_area(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
1714        let explorer_area = self.cached_layout.file_explorer_area?;
1715        let border_x = explorer_area.x + explorer_area.width.saturating_sub(1);
1716        if col == border_x && row >= explorer_area.y && row < explorer_area.y + explorer_area.height
1717        {
1718            self.mouse_state.dragging_file_explorer = true;
1719            self.mouse_state.drag_start_position = Some((col, row));
1720            self.mouse_state.drag_start_explorer_width = Some(self.file_explorer_width);
1721            return Some(Ok(()));
1722        }
1723        if col >= explorer_area.x
1724            && col < explorer_area.x + explorer_area.width
1725            && row >= explorer_area.y
1726            && row < explorer_area.y + explorer_area.height
1727        {
1728            return Some(self.handle_file_explorer_click(col, row, explorer_area));
1729        }
1730        None
1731    }
1732
1733    fn handle_click_scrollbar(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
1734        let (split_id, buffer_id, scrollbar_rect, is_on_thumb) =
1735            self.cached_layout.split_areas.iter().find_map(
1736                |(split_id, buffer_id, _content, scrollbar_rect, thumb_start, thumb_end)| {
1737                    if col >= scrollbar_rect.x
1738                        && col < scrollbar_rect.x + scrollbar_rect.width
1739                        && row >= scrollbar_rect.y
1740                        && row < scrollbar_rect.y + scrollbar_rect.height
1741                    {
1742                        let relative_row = row.saturating_sub(scrollbar_rect.y) as usize;
1743                        let on_thumb = relative_row >= *thumb_start && relative_row < *thumb_end;
1744                        Some((*split_id, *buffer_id, *scrollbar_rect, on_thumb))
1745                    } else {
1746                        None
1747                    }
1748                },
1749            )?;
1750
1751        self.focus_split(split_id, buffer_id);
1752        if is_on_thumb {
1753            self.mouse_state.dragging_scrollbar = Some(split_id);
1754            self.mouse_state.drag_start_row = Some(row);
1755            if self.is_composite_buffer(buffer_id) {
1756                if let Some(vs) = self.composite_view_states.get(&(split_id, buffer_id)) {
1757                    self.mouse_state.drag_start_composite_scroll_row = Some(vs.scroll_row);
1758                }
1759            } else if let Some(vs) = self.split_view_states.get(&split_id) {
1760                self.mouse_state.drag_start_top_byte = Some(vs.viewport.top_byte);
1761                self.mouse_state.drag_start_view_line_offset =
1762                    Some(vs.viewport.top_view_line_offset);
1763            }
1764        } else {
1765            self.mouse_state.dragging_scrollbar = Some(split_id);
1766            if let Err(e) =
1767                self.handle_scrollbar_jump(col, row, split_id, buffer_id, scrollbar_rect)
1768            {
1769                return Some(Err(e));
1770            }
1771            self.mouse_state.hover_target = Some(HoverTarget::ScrollbarThumb(split_id));
1772        }
1773        Some(Ok(()))
1774    }
1775
1776    fn handle_click_horizontal_scrollbar(
1777        &mut self,
1778        col: u16,
1779        row: u16,
1780    ) -> Option<AnyhowResult<()>> {
1781        let (split_id, buffer_id, hscrollbar_rect, max_content_width, is_on_thumb) = self
1782            .cached_layout
1783            .horizontal_scrollbar_areas
1784            .iter()
1785            .find_map(
1786                |(
1787                    split_id,
1788                    buffer_id,
1789                    hscrollbar_rect,
1790                    max_content_width,
1791                    thumb_start,
1792                    thumb_end,
1793                )| {
1794                    if col >= hscrollbar_rect.x
1795                        && col < hscrollbar_rect.x + hscrollbar_rect.width
1796                        && row >= hscrollbar_rect.y
1797                        && row < hscrollbar_rect.y + hscrollbar_rect.height
1798                    {
1799                        let relative_col = col.saturating_sub(hscrollbar_rect.x) as usize;
1800                        let on_thumb = relative_col >= *thumb_start && relative_col < *thumb_end;
1801                        Some((
1802                            *split_id,
1803                            *buffer_id,
1804                            *hscrollbar_rect,
1805                            *max_content_width,
1806                            on_thumb,
1807                        ))
1808                    } else {
1809                        None
1810                    }
1811                },
1812            )?;
1813
1814        self.focus_split(split_id, buffer_id);
1815        self.mouse_state.dragging_horizontal_scrollbar = Some(split_id);
1816        if is_on_thumb {
1817            self.mouse_state.drag_start_hcol = Some(col);
1818            if let Some(vs) = self.split_view_states.get(&split_id) {
1819                self.mouse_state.drag_start_left_column = Some(vs.viewport.left_column);
1820            }
1821        } else {
1822            self.mouse_state.drag_start_hcol = None;
1823            self.mouse_state.drag_start_left_column = None;
1824            let relative_col = col.saturating_sub(hscrollbar_rect.x) as f64;
1825            let track_width = hscrollbar_rect.width as f64;
1826            let ratio = if track_width > 1.0 {
1827                (relative_col / (track_width - 1.0)).clamp(0.0, 1.0)
1828            } else {
1829                0.0
1830            };
1831            if let Some(vs) = self.split_view_states.get_mut(&split_id) {
1832                let visible_width = vs.viewport.width as usize;
1833                let max_scroll = max_content_width.saturating_sub(visible_width);
1834                let target_col = (ratio * max_scroll as f64).round() as usize;
1835                vs.viewport.left_column = target_col.min(max_scroll);
1836                vs.viewport.set_skip_ensure_visible();
1837            }
1838        }
1839        Some(Ok(()))
1840    }
1841
1842    fn handle_click_status_bar(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
1843        let (status_row, _status_x, _status_width) = self.cached_layout.status_bar_area?;
1844        if row != status_row {
1845            return None;
1846        }
1847        if let Some((r, s, e)) = self.cached_layout.status_bar_line_ending_area {
1848            if row == r && col >= s && col < e {
1849                return Some(self.handle_action(Action::SetLineEnding));
1850            }
1851        }
1852        if let Some((r, s, e)) = self.cached_layout.status_bar_encoding_area {
1853            if row == r && col >= s && col < e {
1854                return Some(self.handle_action(Action::SetEncoding));
1855            }
1856        }
1857        if let Some((r, s, e)) = self.cached_layout.status_bar_language_area {
1858            if row == r && col >= s && col < e {
1859                return Some(self.handle_action(Action::SetLanguage));
1860            }
1861        }
1862        if let Some((r, s, e)) = self.cached_layout.status_bar_lsp_area {
1863            if row == r && col >= s && col < e {
1864                return Some(self.handle_action(Action::ShowLspStatus));
1865            }
1866        }
1867        if let Some((r, s, e)) = self.cached_layout.status_bar_remote_area {
1868            if row == r && col >= s && col < e {
1869                return Some(self.handle_action(Action::ShowRemoteIndicatorMenu));
1870            }
1871        }
1872        if let Some((r, s, e)) = self.cached_layout.status_bar_warning_area {
1873            if row == r && col >= s && col < e {
1874                return Some(self.handle_action(Action::ShowWarnings));
1875            }
1876        }
1877        if let Some((r, s, e)) = self.cached_layout.status_bar_message_area {
1878            if row == r && col >= s && col < e {
1879                return Some(self.handle_action(Action::ShowStatusLog));
1880            }
1881        }
1882        None
1883    }
1884
1885    fn handle_click_search_options(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
1886        use crate::view::ui::status_bar::SearchOptionsHover;
1887        let layout = self.cached_layout.search_options_layout.clone()?;
1888        match layout.checkbox_at(col, row)? {
1889            SearchOptionsHover::CaseSensitive => {
1890                Some(self.handle_action(Action::ToggleSearchCaseSensitive))
1891            }
1892            SearchOptionsHover::WholeWord => {
1893                Some(self.handle_action(Action::ToggleSearchWholeWord))
1894            }
1895            SearchOptionsHover::Regex => Some(self.handle_action(Action::ToggleSearchRegex)),
1896            SearchOptionsHover::ConfirmEach => {
1897                Some(self.handle_action(Action::ToggleSearchConfirmEach))
1898            }
1899            SearchOptionsHover::None => None,
1900        }
1901    }
1902
1903    fn handle_click_split_separator(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
1904        let separator_areas = self.cached_layout.separator_areas.clone();
1905        for (split_id, direction, sep_x, sep_y, sep_length) in &separator_areas {
1906            let is_on_separator = match direction {
1907                SplitDirection::Horizontal => {
1908                    row == *sep_y && col >= *sep_x && col < sep_x + sep_length
1909                }
1910                SplitDirection::Vertical => {
1911                    col == *sep_x && row >= *sep_y && row < sep_y + sep_length
1912                }
1913            };
1914            if is_on_separator {
1915                self.mouse_state.dragging_separator = Some((*split_id, *direction));
1916                self.mouse_state.drag_start_position = Some((col, row));
1917                let ratio = self
1918                    .split_manager
1919                    .get_ratio((*split_id).into())
1920                    .or_else(|| self.grouped_split_ratio(*split_id));
1921                if let Some(ratio) = ratio {
1922                    self.mouse_state.drag_start_ratio = Some(ratio);
1923                }
1924                return Some(Ok(()));
1925            }
1926        }
1927        None
1928    }
1929
1930    fn handle_click_split_controls(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
1931        let close_split_id = self
1932            .cached_layout
1933            .close_split_areas
1934            .iter()
1935            .find(|(_, btn_row, start_col, end_col)| {
1936                row == *btn_row && col >= *start_col && col < *end_col
1937            })
1938            .map(|(split_id, _, _, _)| *split_id);
1939        if let Some(split_id) = close_split_id {
1940            if let Err(e) = self.split_manager.close_split(split_id) {
1941                self.set_status_message(
1942                    t!("error.cannot_close_split", error = e.to_string()).to_string(),
1943                );
1944            } else {
1945                let new_active = self.split_manager.active_split();
1946                if let Some(buffer_id) = self.split_manager.buffer_for_split(new_active) {
1947                    self.set_active_buffer(buffer_id);
1948                }
1949                self.set_status_message(t!("split.closed").to_string());
1950            }
1951            return Some(Ok(()));
1952        }
1953
1954        let maximize_hit = self.cached_layout.maximize_split_areas.iter().any(
1955            |(_, btn_row, start_col, end_col)| {
1956                row == *btn_row && col >= *start_col && col < *end_col
1957            },
1958        );
1959        if maximize_hit {
1960            match self.split_manager.toggle_maximize() {
1961                Ok(maximized) => {
1962                    let msg = if maximized {
1963                        t!("split.maximized").to_string()
1964                    } else {
1965                        t!("split.restored").to_string()
1966                    };
1967                    self.set_status_message(msg);
1968                }
1969                Err(e) => self.set_status_message(e),
1970            }
1971            return Some(Ok(()));
1972        }
1973
1974        None
1975    }
1976
1977    fn handle_click_tab_bar(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
1978        for (split_id, tab_layout) in &self.cached_layout.tab_layouts {
1979            tracing::debug!(
1980                "Tab layout for split {:?}: bar_area={:?}, left_scroll={:?}, right_scroll={:?}",
1981                split_id,
1982                tab_layout.bar_area,
1983                tab_layout.left_scroll_area,
1984                tab_layout.right_scroll_area
1985            );
1986        }
1987        let tab_hit = self
1988            .cached_layout
1989            .tab_layouts
1990            .iter()
1991            .find_map(|(split_id, tab_layout)| {
1992                let hit = tab_layout.hit_test(col, row);
1993                tracing::debug!(
1994                    "Tab hit_test at ({}, {}) for split {:?} returned {:?}",
1995                    col,
1996                    row,
1997                    split_id,
1998                    hit
1999                );
2000                hit.map(|h| (*split_id, h))
2001            });
2002        let (split_id, hit) = tab_hit?;
2003        match hit {
2004            TabHit::CloseButton(target) => {
2005                match target {
2006                    crate::view::split::TabTarget::Buffer(buffer_id) => {
2007                        self.focus_split(split_id, buffer_id);
2008                        self.close_tab_in_split(buffer_id, split_id);
2009                    }
2010                    crate::view::split::TabTarget::Group(group_leaf) => {
2011                        self.close_buffer_group_by_leaf(group_leaf);
2012                    }
2013                }
2014                Some(Ok(()))
2015            }
2016            TabHit::TabName(target) => {
2017                let direction = self
2018                    .split_view_states
2019                    .get(&split_id)
2020                    .map(|vs| {
2021                        let open = &vs.open_buffers;
2022                        let cur = vs.active_target();
2023                        let cur_idx = open.iter().position(|t| *t == cur);
2024                        let new_idx = open.iter().position(|t| *t == target);
2025                        match (cur_idx, new_idx) {
2026                            (Some(c), Some(n)) if n > c => 1,
2027                            (Some(c), Some(n)) if n < c => -1,
2028                            _ => 0,
2029                        }
2030                    })
2031                    .unwrap_or(0);
2032                self.animate_tab_switch(split_id, direction);
2033                match target {
2034                    crate::view::split::TabTarget::Buffer(buffer_id) => {
2035                        self.focus_split(split_id, buffer_id);
2036                        self.promote_buffer_from_preview(buffer_id);
2037                        self.mouse_state.dragging_tab = Some(super::types::TabDragState::new(
2038                            buffer_id,
2039                            split_id,
2040                            (col, row),
2041                        ));
2042                    }
2043                    crate::view::split::TabTarget::Group(group_leaf) => {
2044                        self.activate_group_tab(split_id, group_leaf);
2045                    }
2046                }
2047                Some(Ok(()))
2048            }
2049            TabHit::ScrollLeft => {
2050                self.set_status_message("ScrollLeft clicked!".to_string());
2051                if let Some(vs) = self.split_view_states.get_mut(&split_id) {
2052                    vs.tab_scroll_offset = vs.tab_scroll_offset.saturating_sub(10);
2053                }
2054                Some(Ok(()))
2055            }
2056            TabHit::ScrollRight => {
2057                self.set_status_message("ScrollRight clicked!".to_string());
2058                if let Some(vs) = self.split_view_states.get_mut(&split_id) {
2059                    vs.tab_scroll_offset = vs.tab_scroll_offset.saturating_add(10);
2060                }
2061                Some(Ok(()))
2062            }
2063            TabHit::BarBackground => None,
2064        }
2065    }
2066
2067    /// Handle mouse drag event
2068    pub(super) fn handle_mouse_drag(&mut self, col: u16, row: u16) -> AnyhowResult<()> {
2069        // If dragging scrollbar, update scroll position
2070        if let Some(dragging_split_id) = self.mouse_state.dragging_scrollbar {
2071            // Find the buffer and scrollbar rect for this split
2072            for (split_id, buffer_id, _content_rect, scrollbar_rect, _thumb_start, _thumb_end) in
2073                &self.cached_layout.split_areas
2074            {
2075                if *split_id == dragging_split_id {
2076                    // Check if we started dragging from the thumb (have drag_start_row)
2077                    if self.mouse_state.drag_start_row.is_some() {
2078                        // Relative drag from thumb
2079                        self.handle_scrollbar_drag_relative(
2080                            row,
2081                            *split_id,
2082                            *buffer_id,
2083                            *scrollbar_rect,
2084                        )?;
2085                    } else {
2086                        // Jump drag (started from track)
2087                        self.handle_scrollbar_jump(
2088                            col,
2089                            row,
2090                            *split_id,
2091                            *buffer_id,
2092                            *scrollbar_rect,
2093                        )?;
2094                    }
2095                    return Ok(());
2096                }
2097            }
2098        }
2099
2100        // If dragging horizontal scrollbar, update horizontal scroll position
2101        if let Some(dragging_split_id) = self.mouse_state.dragging_horizontal_scrollbar {
2102            for (
2103                split_id,
2104                _buffer_id,
2105                hscrollbar_rect,
2106                max_content_width,
2107                thumb_start,
2108                thumb_end,
2109            ) in &self.cached_layout.horizontal_scrollbar_areas
2110            {
2111                if *split_id == dragging_split_id {
2112                    let track_width = hscrollbar_rect.width as f64;
2113                    if track_width <= 1.0 {
2114                        break;
2115                    }
2116
2117                    if let (Some(drag_start_hcol), Some(drag_start_left_column)) = (
2118                        self.mouse_state.drag_start_hcol,
2119                        self.mouse_state.drag_start_left_column,
2120                    ) {
2121                        // Relative drag from thumb - move proportionally to mouse offset
2122                        // Use thumb size to compute the correct ratio so thumb tracks with mouse
2123                        let col_offset = (col as i32) - (drag_start_hcol as i32);
2124                        if let Some(view_state) = self.split_view_states.get_mut(&dragging_split_id)
2125                        {
2126                            let visible_width = view_state.viewport.width as usize;
2127                            let max_scroll = max_content_width.saturating_sub(visible_width);
2128                            if max_scroll > 0 {
2129                                let thumb_size = thumb_end.saturating_sub(*thumb_start).max(1);
2130                                let track_travel = (track_width - thumb_size as f64).max(1.0);
2131                                let scroll_per_pixel = max_scroll as f64 / track_travel;
2132                                let scroll_offset =
2133                                    (col_offset as f64 * scroll_per_pixel).round() as i64;
2134                                let new_left =
2135                                    (drag_start_left_column as i64 + scroll_offset).max(0) as usize;
2136                                view_state.viewport.left_column = new_left.min(max_scroll);
2137                                view_state.viewport.set_skip_ensure_visible();
2138                            }
2139                        }
2140                    } else {
2141                        // Jump drag (started from track) - jump to absolute position
2142                        let relative_col = col.saturating_sub(hscrollbar_rect.x) as f64;
2143                        let ratio = (relative_col / (track_width - 1.0)).clamp(0.0, 1.0);
2144
2145                        if let Some(view_state) = self.split_view_states.get_mut(&dragging_split_id)
2146                        {
2147                            let visible_width = view_state.viewport.width as usize;
2148                            let max_scroll = max_content_width.saturating_sub(visible_width);
2149                            let target_col = (ratio * max_scroll as f64).round() as usize;
2150                            view_state.viewport.left_column = target_col.min(max_scroll);
2151                            view_state.viewport.set_skip_ensure_visible();
2152                        }
2153                    }
2154
2155                    return Ok(());
2156                }
2157            }
2158        }
2159
2160        // If selecting text in popup, extend selection
2161        if let Some(popup_idx) = self.mouse_state.selecting_in_popup {
2162            // Find the popup area from cached layout
2163            if let Some((_, _, inner_rect, scroll_offset, _, _, _)) = self
2164                .cached_layout
2165                .popup_areas
2166                .iter()
2167                .find(|(idx, _, _, _, _, _, _)| *idx == popup_idx)
2168            {
2169                // Check if mouse is within the popup inner area
2170                if col >= inner_rect.x
2171                    && col < inner_rect.x + inner_rect.width
2172                    && row >= inner_rect.y
2173                    && row < inner_rect.y + inner_rect.height
2174                {
2175                    let relative_col = (col - inner_rect.x) as usize;
2176                    let relative_row = (row - inner_rect.y) as usize;
2177                    let line = scroll_offset + relative_row;
2178
2179                    let state = self.active_state_mut();
2180                    if let Some(popup) = state.popups.get_mut(popup_idx) {
2181                        popup.extend_selection(line, relative_col);
2182                    }
2183                }
2184            }
2185            return Ok(());
2186        }
2187
2188        // If dragging popup scrollbar, update popup scroll position
2189        if let Some(popup_idx) = self.mouse_state.dragging_popup_scrollbar {
2190            // Find the popup's scrollbar rect from cached layout
2191            if let Some((_, _, inner_rect, _, _, Some(sb_rect), total_lines)) = self
2192                .cached_layout
2193                .popup_areas
2194                .iter()
2195                .find(|(idx, _, _, _, _, _, _)| *idx == popup_idx)
2196            {
2197                let track_height = sb_rect.height as usize;
2198                let visible_lines = inner_rect.height as usize;
2199
2200                if track_height > 0 && *total_lines > visible_lines {
2201                    let relative_row = row.saturating_sub(sb_rect.y) as usize;
2202                    let max_scroll = total_lines.saturating_sub(visible_lines);
2203                    let target_scroll = if track_height > 1 {
2204                        (relative_row * max_scroll) / (track_height.saturating_sub(1))
2205                    } else {
2206                        0
2207                    };
2208
2209                    let state = self.active_state_mut();
2210                    if let Some(popup) = state.popups.get_mut(popup_idx) {
2211                        let current_scroll = popup.scroll_offset as i32;
2212                        let delta = target_scroll as i32 - current_scroll;
2213                        popup.scroll_by(delta);
2214                    }
2215                }
2216            }
2217            return Ok(());
2218        }
2219
2220        // If dragging separator, update split ratio
2221        if let Some((split_id, direction)) = self.mouse_state.dragging_separator {
2222            self.handle_separator_drag(col, row, split_id, direction)?;
2223            return Ok(());
2224        }
2225
2226        // If dragging file explorer border, update width
2227        if self.mouse_state.dragging_file_explorer {
2228            self.handle_file_explorer_border_drag(col)?;
2229            return Ok(());
2230        }
2231
2232        // If dragging to select text
2233        if self.mouse_state.dragging_text_selection {
2234            self.handle_text_selection_drag(col, row)?;
2235            return Ok(());
2236        }
2237
2238        // If dragging a tab, update position and compute drop zone
2239        if self.mouse_state.dragging_tab.is_some() {
2240            self.handle_tab_drag(col, row)?;
2241            return Ok(());
2242        }
2243
2244        Ok(())
2245    }
2246
2247    /// Handle text selection drag - extends selection from anchor to current position
2248    fn handle_text_selection_drag(&mut self, col: u16, row: u16) -> AnyhowResult<()> {
2249        use crate::model::event::Event;
2250        use crate::primitives::word_navigation::{find_word_end, find_word_start};
2251
2252        let Some(split_id) = self.mouse_state.drag_selection_split else {
2253            return Ok(());
2254        };
2255        let Some(anchor_position) = self.mouse_state.drag_selection_anchor else {
2256            return Ok(());
2257        };
2258
2259        // Find the buffer for this split
2260        let buffer_id = self
2261            .cached_layout
2262            .split_areas
2263            .iter()
2264            .find(|(sid, _, _, _, _, _)| *sid == split_id)
2265            .map(|(_, bid, _, _, _, _)| *bid);
2266
2267        let Some(buffer_id) = buffer_id else {
2268            return Ok(());
2269        };
2270
2271        // Find the content rect for this split
2272        let content_rect = self
2273            .cached_layout
2274            .split_areas
2275            .iter()
2276            .find(|(sid, _, _, _, _, _)| *sid == split_id)
2277            .map(|(_, _, rect, _, _, _)| *rect);
2278
2279        let Some(content_rect) = content_rect else {
2280            return Ok(());
2281        };
2282
2283        // Get cached view line mappings for this split
2284        let cached_mappings = self
2285            .cached_layout
2286            .view_line_mappings
2287            .get(&split_id)
2288            .cloned();
2289
2290        let leaf_id = split_id;
2291
2292        // Get fallback from SplitViewState viewport
2293        let fallback = self
2294            .split_view_states
2295            .get(&leaf_id)
2296            .map(|vs| vs.viewport.top_byte)
2297            .unwrap_or(0);
2298
2299        // Get compose width for this split
2300        let compose_width = self
2301            .split_view_states
2302            .get(&leaf_id)
2303            .and_then(|vs| vs.compose_width);
2304
2305        // Calculate the target position from screen coordinates
2306        if let Some(state) = self.buffers.get_mut(&buffer_id) {
2307            let gutter_width = state.margins.left_total_width() as u16;
2308
2309            let Some(target_position) = super::click_geometry::screen_to_buffer_position(
2310                col,
2311                row,
2312                content_rect,
2313                gutter_width,
2314                &cached_mappings,
2315                fallback,
2316                true, // Allow gutter clicks for drag selection
2317                compose_width,
2318            ) else {
2319                return Ok(());
2320            };
2321
2322            // When drag started with double-click, snap to word boundaries.
2323            // When dragging forward, anchor at word start and extend to word end.
2324            // When dragging backward, anchor at word end and extend to word start,
2325            // so the initially double-clicked word stays selected.
2326            let (new_position, anchor_position) = if self.mouse_state.drag_selection_by_words {
2327                if target_position >= anchor_position {
2328                    (
2329                        find_word_end(&state.buffer, target_position),
2330                        anchor_position,
2331                    )
2332                } else {
2333                    let word_end = self
2334                        .mouse_state
2335                        .drag_selection_word_end
2336                        .unwrap_or(anchor_position);
2337                    (find_word_start(&state.buffer, target_position), word_end)
2338                }
2339            } else {
2340                (target_position, anchor_position)
2341            };
2342
2343            let (primary_cursor_id, old_position, old_anchor, old_sticky_column) = self
2344                .split_view_states
2345                .get(&leaf_id)
2346                .map(|vs| {
2347                    let cursor = vs.cursors.primary();
2348                    (
2349                        vs.cursors.primary_id(),
2350                        cursor.position,
2351                        cursor.anchor,
2352                        cursor.sticky_column,
2353                    )
2354                })
2355                .unwrap_or((CursorId(0), 0, None, 0));
2356
2357            let new_sticky_column = state
2358                .buffer
2359                .offset_to_position(new_position)
2360                .map(|pos| pos.column)
2361                .unwrap_or(old_sticky_column);
2362            let event = Event::MoveCursor {
2363                cursor_id: primary_cursor_id,
2364                old_position,
2365                new_position,
2366                old_anchor,
2367                new_anchor: Some(anchor_position), // Keep anchor to maintain selection
2368                old_sticky_column,
2369                new_sticky_column,
2370            };
2371
2372            if let Some(event_log) = self.event_logs.get_mut(&buffer_id) {
2373                event_log.append(event.clone());
2374            }
2375            if let Some(cursors) = self
2376                .split_view_states
2377                .get_mut(&leaf_id)
2378                .map(|vs| &mut vs.cursors)
2379            {
2380                state.apply(cursors, &event);
2381            }
2382        }
2383
2384        Ok(())
2385    }
2386
2387    /// Handle file explorer border drag for resizing
2388    pub(super) fn handle_file_explorer_border_drag(&mut self, col: u16) -> AnyhowResult<()> {
2389        let Some((start_col, _start_row)) = self.mouse_state.drag_start_position else {
2390            return Ok(());
2391        };
2392        let Some(start_width) = self.mouse_state.drag_start_explorer_width else {
2393            return Ok(());
2394        };
2395
2396        let delta = col as i32 - start_col as i32;
2397        let total_width = self.terminal_width as i32;
2398
2399        // Drag preserves the variant the user chose. A user editing
2400        // columns doesn't want their mode silently flipped to percent
2401        // just because they grabbed the divider.
2402        if total_width > 0 {
2403            use crate::config::ExplorerWidth;
2404            self.file_explorer_width = match start_width {
2405                ExplorerWidth::Percent(start_pct) => {
2406                    let percent_delta = (delta * 100) / total_width;
2407                    let new_pct = (start_pct as i32 + percent_delta).clamp(0, 100) as u8;
2408                    ExplorerWidth::Percent(new_pct)
2409                }
2410                ExplorerWidth::Columns(start_cols) => {
2411                    let new_cols = (start_cols as i32 + delta).clamp(0, total_width) as u16;
2412                    ExplorerWidth::Columns(new_cols)
2413                }
2414            };
2415        }
2416
2417        Ok(())
2418    }
2419
2420    /// Handle separator drag for split resizing
2421    pub(super) fn handle_separator_drag(
2422        &mut self,
2423        col: u16,
2424        row: u16,
2425        split_id: ContainerId,
2426        direction: SplitDirection,
2427    ) -> AnyhowResult<()> {
2428        let Some((start_col, start_row)) = self.mouse_state.drag_start_position else {
2429            return Ok(());
2430        };
2431        let Some(start_ratio) = self.mouse_state.drag_start_ratio else {
2432            return Ok(());
2433        };
2434        let Some(editor_area) = self.cached_layout.editor_content_area else {
2435            return Ok(());
2436        };
2437
2438        // Calculate the delta in screen space
2439        let (delta, total_size) = match direction {
2440            SplitDirection::Horizontal => {
2441                // For horizontal splits, we move the separator up/down (row changes)
2442                let delta = row as i32 - start_row as i32;
2443                let total = editor_area.height as i32;
2444                (delta, total)
2445            }
2446            SplitDirection::Vertical => {
2447                // For vertical splits, we move the separator left/right (col changes)
2448                let delta = col as i32 - start_col as i32;
2449                let total = editor_area.width as i32;
2450                (delta, total)
2451            }
2452        };
2453
2454        // Convert screen delta to ratio delta
2455        // The ratio represents the fraction of space the first split gets
2456        if total_size > 0 {
2457            let ratio_delta = delta as f32 / total_size as f32;
2458            let new_ratio = (start_ratio + ratio_delta).clamp(0.1, 0.9);
2459
2460            // Update the split ratio. The container may live in the main
2461            // split tree or inside a stashed Grouped subtree (buffer group
2462            // panels like the theme editor); try the main tree first and
2463            // fall back to the grouped subtrees.
2464            if self.split_manager.get_ratio(split_id.into()).is_some() {
2465                self.split_manager.set_ratio(split_id, new_ratio);
2466            } else {
2467                self.set_grouped_split_ratio(split_id, new_ratio);
2468            }
2469        }
2470
2471        Ok(())
2472    }
2473
2474    /// Handle right-click event
2475    pub(super) fn handle_right_click(&mut self, col: u16, row: u16) -> AnyhowResult<()> {
2476        if let Some(ref menu) = self.file_explorer_context_menu {
2477            let (menu_x, menu_y) = menu.clamped_position(
2478                self.cached_layout.last_frame_width,
2479                self.cached_layout.last_frame_height,
2480            );
2481            let menu_width = super::types::FILE_EXPLORER_CONTEXT_MENU_WIDTH;
2482            let menu_height = menu.height();
2483            if col >= menu_x
2484                && col < menu_x + menu_width
2485                && row >= menu_y
2486                && row < menu_y + menu_height
2487            {
2488                return Ok(());
2489            }
2490        }
2491
2492        // First check if a tab context menu is open and the click is on a menu item
2493        if let Some(ref menu) = self.tab_context_menu {
2494            let menu_x = menu.position.0;
2495            let menu_y = menu.position.1;
2496            let menu_width = 22u16; // "Close to the Right" + padding
2497            let menu_height = super::types::TabContextMenuItem::all().len() as u16 + 2; // items + borders
2498
2499            // Check if click is inside the menu
2500            if col >= menu_x
2501                && col < menu_x + menu_width
2502                && row >= menu_y
2503                && row < menu_y + menu_height
2504            {
2505                // Click inside menu - let left-click handler deal with it
2506                return Ok(());
2507            }
2508        }
2509
2510        if let Some(explorer_area) = self.cached_layout.file_explorer_area {
2511            if col >= explorer_area.x
2512                && col < explorer_area.x + explorer_area.width
2513                && row < explorer_area.y + explorer_area.height
2514                && row > explorer_area.y
2515            // skip title row
2516            {
2517                let relative_row = row.saturating_sub(explorer_area.y + 1);
2518                let (is_multi, is_root_selected) =
2519                    if let Some(ref mut explorer) = self.file_explorer {
2520                        let display_nodes = explorer.get_display_nodes();
2521                        let scroll_offset = explorer.get_scroll_offset();
2522                        let clicked_index = (relative_row as usize) + scroll_offset;
2523                        let mut clicked_is_root = false;
2524                        if clicked_index < display_nodes.len() {
2525                            let (node_id, _) = display_nodes[clicked_index];
2526                            explorer.set_selected(Some(node_id));
2527                            clicked_is_root = node_id == explorer.tree().root_id();
2528                        }
2529                        (explorer.has_multi_selection(), clicked_is_root)
2530                    } else {
2531                        (false, false)
2532                    };
2533                self.key_context = crate::input::keybindings::KeyContext::FileExplorer;
2534                self.tab_context_menu = None;
2535                self.file_explorer_context_menu = Some(super::types::FileExplorerContextMenu::new(
2536                    col,
2537                    row + 1,
2538                    is_multi,
2539                    is_root_selected,
2540                ));
2541                return Ok(());
2542            }
2543        }
2544
2545        self.file_explorer_context_menu = None;
2546
2547        // Check if right-click is on a tab
2548        let tab_hit =
2549            self.cached_layout.tab_layouts.iter().find_map(
2550                |(split_id, tab_layout)| match tab_layout.hit_test(col, row) {
2551                    Some(TabHit::TabName(target) | TabHit::CloseButton(target)) => {
2552                        // Context menu only makes sense for buffer tabs; groups are
2553                        // plugin-managed and closed via the close button.
2554                        target.as_buffer().map(|bid| (*split_id, bid))
2555                    }
2556                    _ => None,
2557                },
2558            );
2559
2560        if let Some((split_id, buffer_id)) = tab_hit {
2561            // Open tab context menu
2562            self.tab_context_menu = Some(TabContextMenu::new(buffer_id, split_id, col, row + 1));
2563        } else {
2564            // Click outside tab - close context menu if open
2565            self.tab_context_menu = None;
2566        }
2567
2568        Ok(())
2569    }
2570
2571    /// Handle left-click on tab context menu
2572    pub(super) fn handle_tab_context_menu_click(
2573        &mut self,
2574        col: u16,
2575        row: u16,
2576    ) -> Option<AnyhowResult<()>> {
2577        let menu = self.tab_context_menu.as_ref()?;
2578        let menu_x = menu.position.0;
2579        let menu_y = menu.position.1;
2580        let menu_width = 22u16;
2581        let items = super::types::TabContextMenuItem::all();
2582        let menu_height = items.len() as u16 + 2; // items + borders
2583
2584        // Check if click is inside the menu area
2585        if col < menu_x || col >= menu_x + menu_width || row < menu_y || row >= menu_y + menu_height
2586        {
2587            // Click outside menu - close it
2588            self.tab_context_menu = None;
2589            return Some(Ok(()));
2590        }
2591
2592        // Check if click is on the border (first or last row)
2593        if row == menu_y || row == menu_y + menu_height - 1 {
2594            return Some(Ok(()));
2595        }
2596
2597        // Calculate which item was clicked (accounting for border)
2598        let item_idx = (row - menu_y - 1) as usize;
2599        if item_idx >= items.len() {
2600            return Some(Ok(()));
2601        }
2602
2603        // Get the menu state before closing it
2604        let buffer_id = menu.buffer_id;
2605        let split_id = menu.split_id;
2606        let item = items[item_idx];
2607
2608        // Close the menu
2609        self.tab_context_menu = None;
2610
2611        // Execute the action
2612        Some(self.execute_tab_context_menu_action(item, buffer_id, split_id))
2613    }
2614
2615    /// Execute a tab context menu action
2616    fn execute_tab_context_menu_action(
2617        &mut self,
2618        item: super::types::TabContextMenuItem,
2619        buffer_id: BufferId,
2620        leaf_id: LeafId,
2621    ) -> AnyhowResult<()> {
2622        use super::types::TabContextMenuItem;
2623        match item {
2624            TabContextMenuItem::Close => {
2625                self.close_tab_in_split(buffer_id, leaf_id);
2626            }
2627            TabContextMenuItem::CloseOthers => {
2628                self.close_other_tabs_in_split(buffer_id, leaf_id);
2629            }
2630            TabContextMenuItem::CloseToRight => {
2631                self.close_tabs_to_right_in_split(buffer_id, leaf_id);
2632            }
2633            TabContextMenuItem::CloseToLeft => {
2634                self.close_tabs_to_left_in_split(buffer_id, leaf_id);
2635            }
2636            TabContextMenuItem::CloseAll => {
2637                self.close_all_tabs_in_split(leaf_id);
2638            }
2639        }
2640
2641        Ok(())
2642    }
2643
2644    /// Handle keyboard navigation for the file explorer context menu.
2645    /// Returns `Some` if the key was consumed, `None` to let normal dispatch continue.
2646    pub(super) fn handle_file_explorer_context_menu_key(
2647        &mut self,
2648        code: crossterm::event::KeyCode,
2649        modifiers: crossterm::event::KeyModifiers,
2650    ) -> Option<AnyhowResult<()>> {
2651        use crossterm::event::KeyCode;
2652        use crossterm::event::KeyModifiers;
2653
2654        if modifiers != KeyModifiers::NONE {
2655            return None;
2656        }
2657
2658        match code {
2659            KeyCode::Up => {
2660                if let Some(ref mut menu) = self.file_explorer_context_menu {
2661                    menu.prev_item();
2662                }
2663                Some(Ok(()))
2664            }
2665            KeyCode::Down => {
2666                if let Some(ref mut menu) = self.file_explorer_context_menu {
2667                    menu.next_item();
2668                }
2669                Some(Ok(()))
2670            }
2671            KeyCode::Enter => {
2672                let item = {
2673                    let menu = self.file_explorer_context_menu.as_ref()?;
2674                    menu.items()[menu.highlighted]
2675                };
2676                self.file_explorer_context_menu = None;
2677                self.execute_file_explorer_context_menu_action(item);
2678                Some(Ok(()))
2679            }
2680            KeyCode::Esc => {
2681                self.file_explorer_context_menu = None;
2682                Some(Ok(()))
2683            }
2684            _ => None,
2685        }
2686    }
2687
2688    /// Handle left-click on the file explorer context menu
2689    pub(super) fn handle_file_explorer_context_menu_click(
2690        &mut self,
2691        col: u16,
2692        row: u16,
2693    ) -> Option<AnyhowResult<()>> {
2694        // Extract all needed values while the immutable borrow is live, then mutate.
2695        let clicked_item: Option<super::types::FileExplorerContextMenuItem> = {
2696            let menu = self.file_explorer_context_menu.as_ref()?;
2697            let (menu_x, menu_y) = menu.clamped_position(
2698                self.cached_layout.last_frame_width,
2699                self.cached_layout.last_frame_height,
2700            );
2701            let menu_width = super::types::FILE_EXPLORER_CONTEXT_MENU_WIDTH;
2702            let menu_height = menu.height();
2703
2704            if col < menu_x
2705                || col >= menu_x + menu_width
2706                || row < menu_y
2707                || row >= menu_y + menu_height
2708            {
2709                self.file_explorer_context_menu = None;
2710                return Some(Ok(()));
2711            }
2712
2713            if row == menu_y || row == menu_y + menu_height - 1 {
2714                return Some(Ok(()));
2715            }
2716
2717            let item_idx = (row - menu_y - 1) as usize;
2718            menu.items().get(item_idx).copied()
2719        };
2720
2721        self.file_explorer_context_menu = None;
2722        if let Some(item) = clicked_item {
2723            self.execute_file_explorer_context_menu_action(item);
2724        }
2725        Some(Ok(()))
2726    }
2727
2728    fn execute_file_explorer_context_menu_action(
2729        &mut self,
2730        item: super::types::FileExplorerContextMenuItem,
2731    ) {
2732        use super::types::FileExplorerContextMenuItem;
2733        match item {
2734            FileExplorerContextMenuItem::NewFile => self.file_explorer_new_file(),
2735            FileExplorerContextMenuItem::NewDirectory => self.file_explorer_new_directory(),
2736            FileExplorerContextMenuItem::Rename => self.file_explorer_rename(),
2737            FileExplorerContextMenuItem::Cut => self.file_explorer_cut(),
2738            FileExplorerContextMenuItem::Copy => self.file_explorer_copy(),
2739            FileExplorerContextMenuItem::Paste => self.file_explorer_paste(),
2740            FileExplorerContextMenuItem::Delete => self.file_explorer_delete(),
2741        }
2742    }
2743
2744    /// Show a tooltip for a file explorer status indicator
2745    fn show_file_explorer_status_tooltip(&mut self, path: std::path::PathBuf, col: u16, row: u16) {
2746        use crate::view::popup::{Popup, PopupPosition};
2747        use ratatui::style::Style;
2748
2749        let is_directory = path.is_dir();
2750
2751        // Get the decoration for this file to determine the status
2752        let decoration = self
2753            .file_explorer_decoration_cache
2754            .direct_for_path(&path)
2755            .cloned();
2756
2757        // For directories, also check bubbled decoration
2758        let bubbled_decoration = if is_directory && decoration.is_none() {
2759            self.file_explorer_decoration_cache
2760                .bubbled_for_path(&path)
2761                .cloned()
2762        } else {
2763            None
2764        };
2765
2766        // Check if file/folder has unsaved changes in editor
2767        let has_unsaved_changes = if is_directory {
2768            // Check if any buffer under this directory has unsaved changes
2769            self.buffers.iter().any(|(buffer_id, state)| {
2770                if state.buffer.is_modified() {
2771                    if let Some(metadata) = self.buffer_metadata.get(buffer_id) {
2772                        if let Some(file_path) = metadata.file_path() {
2773                            return file_path.starts_with(&path);
2774                        }
2775                    }
2776                }
2777                false
2778            })
2779        } else {
2780            self.buffers.iter().any(|(buffer_id, state)| {
2781                if state.buffer.is_modified() {
2782                    if let Some(metadata) = self.buffer_metadata.get(buffer_id) {
2783                        return metadata.file_path() == Some(&path);
2784                    }
2785                }
2786                false
2787            })
2788        };
2789
2790        // Build tooltip content
2791        let mut lines: Vec<String> = Vec::new();
2792
2793        if let Some(decoration) = &decoration {
2794            let symbol = &decoration.symbol;
2795            let explanation = match symbol.as_str() {
2796                "U" => "Untracked - File is not tracked by git",
2797                "M" => "Modified - File has unstaged changes",
2798                "A" => "Added - File is staged for commit",
2799                "D" => "Deleted - File is staged for deletion",
2800                "R" => "Renamed - File has been renamed",
2801                "C" => "Copied - File has been copied",
2802                "!" => "Conflicted - File has merge conflicts",
2803                "●" => "Has changes - Contains modified files",
2804                _ => "Unknown status",
2805            };
2806            lines.push(format!("{} - {}", symbol, explanation));
2807        } else if bubbled_decoration.is_some() {
2808            lines.push("● - Contains modified files".to_string());
2809        } else if has_unsaved_changes {
2810            if is_directory {
2811                lines.push("● - Contains unsaved changes".to_string());
2812            } else {
2813                lines.push("● - Unsaved changes in editor".to_string());
2814            }
2815        } else {
2816            return; // No status to show
2817        }
2818
2819        // For directories, show list of modified files
2820        if is_directory {
2821            // get_modified_files_in_directory returns None if no files, so no need to check is_empty()
2822            if let Some(modified_files) = self.get_modified_files_in_directory(&path) {
2823                lines.push(String::new()); // Empty line separator
2824                lines.push("Modified files:".to_string());
2825                // Resolve symlinks for proper prefix stripping
2826                let resolved_path = path.canonicalize().unwrap_or_else(|_| path.clone());
2827                const MAX_FILES: usize = 8;
2828                for (i, file) in modified_files.iter().take(MAX_FILES).enumerate() {
2829                    // Show relative path from the directory
2830                    let display_name = file
2831                        .strip_prefix(&resolved_path)
2832                        .unwrap_or(file)
2833                        .to_string_lossy()
2834                        .to_string();
2835                    lines.push(format!("  {}", display_name));
2836                    if i == MAX_FILES - 1 && modified_files.len() > MAX_FILES {
2837                        lines.push(format!(
2838                            "  ... and {} more",
2839                            modified_files.len() - MAX_FILES
2840                        ));
2841                        break;
2842                    }
2843                }
2844            }
2845        } else {
2846            // For files, try to get git diff stats
2847            if let Some(stats) = self.get_git_diff_stats(&path) {
2848                lines.push(String::new()); // Empty line separator
2849                lines.push(stats);
2850            }
2851        }
2852
2853        if lines.is_empty() {
2854            return;
2855        }
2856
2857        // Create popup
2858        let mut popup = Popup::text(lines, &self.theme);
2859        popup.title = Some("Git Status".to_string());
2860        popup.transient = true;
2861        popup.position = PopupPosition::Fixed { x: col, y: row + 1 };
2862        popup.width = 50;
2863        popup.max_height = 15;
2864        popup.border_style = Style::default().fg(self.theme.popup_border_fg);
2865        popup.background_style = Style::default().bg(self.theme.popup_bg);
2866
2867        // Show the popup
2868        if let Some(state) = self.buffers.get_mut(&self.active_buffer()) {
2869            state.popups.show(popup);
2870        }
2871    }
2872
2873    /// Dismiss the file explorer status tooltip
2874    fn dismiss_file_explorer_status_tooltip(&mut self) {
2875        // Dismiss any transient popups
2876        if let Some(state) = self.buffers.get_mut(&self.active_buffer()) {
2877            state.popups.dismiss_transient();
2878        }
2879    }
2880
2881    /// Get git diff stats for a file (insertions/deletions)
2882    fn get_git_diff_stats(&self, path: &std::path::Path) -> Option<String> {
2883        use std::process::Command;
2884
2885        // Run git diff --numstat for the file
2886        let output = Command::new("git")
2887            .args(["diff", "--numstat", "--"])
2888            .arg(path)
2889            .current_dir(&self.working_dir)
2890            .output()
2891            .ok()?;
2892
2893        if !output.status.success() {
2894            return None;
2895        }
2896
2897        let stdout = String::from_utf8_lossy(&output.stdout);
2898        let line = stdout.lines().next()?;
2899        let parts: Vec<&str> = line.split('\t').collect();
2900
2901        if parts.len() >= 2 {
2902            let insertions = parts[0];
2903            let deletions = parts[1];
2904
2905            // Handle binary files (shows as -)
2906            if insertions == "-" && deletions == "-" {
2907                return Some("Binary file changed".to_string());
2908            }
2909
2910            let ins: i32 = insertions.parse().unwrap_or(0);
2911            let del: i32 = deletions.parse().unwrap_or(0);
2912
2913            if ins > 0 || del > 0 {
2914                return Some(format!("+{} -{} lines", ins, del));
2915            }
2916        }
2917
2918        // Also check staged changes
2919        let staged_output = Command::new("git")
2920            .args(["diff", "--numstat", "--cached", "--"])
2921            .arg(path)
2922            .current_dir(&self.working_dir)
2923            .output()
2924            .ok()?;
2925
2926        if staged_output.status.success() {
2927            let staged_stdout = String::from_utf8_lossy(&staged_output.stdout);
2928            if let Some(line) = staged_stdout.lines().next() {
2929                let parts: Vec<&str> = line.split('\t').collect();
2930                if parts.len() >= 2 {
2931                    let insertions = parts[0];
2932                    let deletions = parts[1];
2933
2934                    if insertions == "-" && deletions == "-" {
2935                        return Some("Binary file staged".to_string());
2936                    }
2937
2938                    let ins: i32 = insertions.parse().unwrap_or(0);
2939                    let del: i32 = deletions.parse().unwrap_or(0);
2940
2941                    if ins > 0 || del > 0 {
2942                        return Some(format!("+{} -{} lines (staged)", ins, del));
2943                    }
2944                }
2945            }
2946        }
2947
2948        None
2949    }
2950
2951    /// Get list of modified files in a directory
2952    fn get_modified_files_in_directory(
2953        &self,
2954        dir_path: &std::path::Path,
2955    ) -> Option<Vec<std::path::PathBuf>> {
2956        use std::process::Command;
2957
2958        // Resolve symlinks to get the actual directory path
2959        let resolved_path = dir_path
2960            .canonicalize()
2961            .unwrap_or_else(|_| dir_path.to_path_buf());
2962
2963        // Run git status --porcelain to get list of modified files
2964        let output = Command::new("git")
2965            .args(["status", "--porcelain", "--"])
2966            .arg(&resolved_path)
2967            .current_dir(&self.working_dir)
2968            .output()
2969            .ok()?;
2970
2971        if !output.status.success() {
2972            return None;
2973        }
2974
2975        let stdout = String::from_utf8_lossy(&output.stdout);
2976        let modified_files: Vec<std::path::PathBuf> = stdout
2977            .lines()
2978            .filter_map(|line| {
2979                // Git porcelain format: XY filename
2980                // where XY is the status (M, A, D, ??, etc.)
2981                if line.len() > 3 {
2982                    let file_part = &line[3..];
2983                    // Handle renamed files (old -> new format)
2984                    let file_name = if file_part.contains(" -> ") {
2985                        file_part.split(" -> ").last().unwrap_or(file_part)
2986                    } else {
2987                        file_part
2988                    };
2989                    Some(self.working_dir.join(file_name))
2990                } else {
2991                    None
2992                }
2993            })
2994            .collect();
2995
2996        if modified_files.is_empty() {
2997            None
2998        } else {
2999            Some(modified_files)
3000        }
3001    }
3002}