Skip to main content

fresh/app/
mouse_input.rs

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