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