Skip to main content

fresh/app/
mouse_input.rs

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