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