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