Skip to main content

fresh/app/
mouse_input.rs

1//! Mouse input handling.
2//!
3//! This module contains all mouse event handling logic including:
4//! - Click, double-click, and drag handling
5//! - Scrollbar interaction
6//! - Hover target computation
7//! - Split separator dragging
8//! - Text selection via mouse
9
10use super::*;
11use crate::input::keybindings::Action;
12use crate::model::event::{ContainerId, CursorId, LeafId, SplitDirection};
13use crate::services::plugins::hooks::HookArgs;
14use crate::view::popup_mouse::{popup_areas_to_layout_info, PopupHitTester};
15use crate::view::prompt::PromptType;
16use crate::view::ui::tabs::TabHit;
17use anyhow::Result as AnyhowResult;
18use ratatui::layout::Rect;
19use rust_i18n::t;
20
21/// Returns true if (col, row) falls inside `rect`.
22fn in_rect(col: u16, row: u16, rect: Rect) -> bool {
23    col >= rect.x && col < rect.x + rect.width && row >= rect.y && row < rect.y + rect.height
24}
25
26impl Editor {
27    /// Handle a mouse event.
28    /// Returns true if a re-render is needed.
29    pub fn handle_mouse(
30        &mut self,
31        mouse_event: crossterm::event::MouseEvent,
32    ) -> AnyhowResult<bool> {
33        use crossterm::event::{MouseButton, MouseEventKind};
34
35        let col = mouse_event.column;
36        let row = mouse_event.row;
37
38        let (is_double_click, is_triple_click) = self.detect_multi_click(&mouse_event, col, row);
39
40        // When keybinding editor is open, capture all mouse events
41        if self.keybinding_editor.is_some() {
42            return self.handle_keybinding_editor_mouse(mouse_event);
43        }
44
45        // When settings modal is open, capture all mouse events
46        if self.settings_state.as_ref().is_some_and(|s| s.visible) {
47            return self.handle_settings_mouse(mouse_event, is_double_click);
48        }
49
50        // When calibration wizard is active, ignore all mouse events
51        if self.calibration_wizard.is_some() {
52            return Ok(false);
53        }
54
55        // The workspace-trust modal captures every mouse event: clicks act on
56        // its controls (and are otherwise absorbed), the wheel scrolls it. None
57        // of it may leak to the buffer behind (which would move the cursor).
58        if self.global_popups.top().is_some_and(|p| {
59            matches!(
60                p.resolver,
61                crate::view::popup::PopupResolver::WorkspaceTrust
62            )
63        }) {
64            return self.handle_workspace_trust_mouse(mouse_event);
65        }
66
67        // Cancel LSP rename prompt on any mouse interaction
68        let mut needs_render = false;
69        if let Some(ref prompt) = self.active_window_mut().prompt {
70            if matches!(prompt.prompt_type, PromptType::LspRename { .. }) {
71                self.cancel_prompt();
72                needs_render = true;
73            }
74        }
75
76        // Update mouse cursor position for software cursor rendering (used by GPM)
77        // When GPM is active, we always need to re-render to update the cursor position
78        let cursor_moved = self.active_window_mut().mouse_cursor_position != Some((col, row));
79        self.active_window_mut().mouse_cursor_position = Some((col, row));
80        if self.active_window_mut().gpm_active && cursor_moved {
81            needs_render = true;
82        }
83
84        tracing::trace!(
85            "handle_mouse: kind={:?}, col={}, row={}",
86            mouse_event.kind,
87            col,
88            row
89        );
90
91        // Check if we should forward mouse events to the terminal
92        // Forward if: in terminal mode, mouse is over terminal buffer, and terminal is in alternate screen mode
93        if let Some(result) =
94            self.active_window_mut()
95                .try_forward_mouse_to_terminal(col, row, mouse_event)
96        {
97            return result;
98        }
99
100        // Dismiss theme info popup on any left-click; check if click is on the button first
101        if self.active_window_mut().theme_info_popup.is_some() {
102            if let MouseEventKind::Down(MouseButton::Left) = mouse_event.kind {
103                if let Some((popup_rect, button_row_offset)) = self.theme_info_popup_rect() {
104                    if in_rect(col, row, popup_rect) {
105                        // Check if click is on the button row (last content row before border)
106                        let actual_button_row = popup_rect.y + button_row_offset;
107                        if row == actual_button_row {
108                            let fg_key = self
109                                .active_window_mut()
110                                .theme_info_popup
111                                .as_ref()
112                                .and_then(|p| p.info.fg_key.clone());
113                            self.active_window_mut().theme_info_popup = None;
114                            if let Some(key) = fg_key {
115                                self.fire_theme_inspect_hook(key);
116                            }
117                            return Ok(true);
118                        }
119                        // Click inside popup but not button - ignore
120                        return Ok(true);
121                    }
122                }
123                // Click outside popup - dismiss
124                self.active_window_mut().theme_info_popup = None;
125                needs_render = true;
126            }
127        }
128
129        match mouse_event.kind {
130            MouseEventKind::Down(MouseButton::Left) => {
131                if is_double_click || is_triple_click {
132                    if let Some((buffer_id, byte_pos)) =
133                        self.fold_toggle_line_at_screen_position(col, row)
134                    {
135                        self.active_window_mut()
136                            .toggle_fold_at_byte(buffer_id, byte_pos);
137                        needs_render = true;
138                        return Ok(needs_render);
139                    }
140                }
141                if is_triple_click {
142                    // Triple click detected - select entire line
143                    self.handle_mouse_triple_click(col, row)?;
144                    needs_render = true;
145                    return Ok(needs_render);
146                }
147                if is_double_click {
148                    // Double click detected - both clicks within time threshold AND at same position
149                    self.handle_mouse_double_click(col, row)?;
150                    needs_render = true;
151                    return Ok(needs_render);
152                }
153                self.handle_mouse_click(col, row, mouse_event.modifiers)?;
154                needs_render = true;
155            }
156            MouseEventKind::Drag(MouseButton::Left) => {
157                self.handle_mouse_drag(col, row)?;
158                needs_render = true;
159            }
160            MouseEventKind::Up(MouseButton::Left) => {
161                // Check if we were dragging a separator to trigger terminal resize
162                let was_dragging_separator = self
163                    .active_window_mut()
164                    .mouse_state
165                    .dragging_separator
166                    .is_some();
167
168                // Check if we were dragging a tab and complete the drop
169                if let Some(drag_state) = self.active_window_mut().mouse_state.dragging_tab.take() {
170                    if drag_state.is_dragging() {
171                        if let Some(drop_zone) = drag_state.drop_zone {
172                            self.execute_tab_drop(
173                                drag_state.buffer_id,
174                                drag_state.source_split_id,
175                                drop_zone,
176                            );
177                        }
178                    }
179                }
180
181                // Stop dragging and clear drag state
182                self.release_widget_scrollbar();
183                self.active_window_mut().mouse_state.dragging_scrollbar = None;
184                self.active_window_mut().mouse_state.drag_start_row = None;
185                self.active_window_mut().mouse_state.drag_start_top_byte = None;
186                self.active_window_mut()
187                    .mouse_state
188                    .dragging_horizontal_scrollbar = None;
189                self.active_window_mut().mouse_state.drag_start_hcol = None;
190                self.active_window_mut().mouse_state.drag_start_left_column = None;
191                self.active_window_mut().mouse_state.dragging_separator = None;
192                self.active_window_mut().mouse_state.drag_start_position = None;
193                self.active_window_mut().mouse_state.drag_start_ratio = None;
194                self.active_window_mut().mouse_state.dragging_file_explorer = false;
195                self.active_window_mut()
196                    .mouse_state
197                    .drag_start_explorer_width = None;
198                // Clear text selection drag state (selection remains in cursor)
199                self.active_window_mut().mouse_state.dragging_text_selection = false;
200                self.active_window_mut().mouse_state.drag_selection_split = None;
201                self.active_window_mut().mouse_state.drag_selection_anchor = None;
202                self.active_window_mut().mouse_state.drag_selection_by_words = false;
203                self.active_window_mut().mouse_state.drag_selection_word_end = None;
204                // Clear popup scrollbar drag state
205                self.active_window_mut()
206                    .mouse_state
207                    .dragging_popup_scrollbar = None;
208                self.active_window_mut().mouse_state.drag_start_popup_scroll = None;
209                // Clear prompt scrollbar drag state (issue #1796)
210                self.active_window_mut()
211                    .mouse_state
212                    .dragging_prompt_scrollbar = false;
213                // Clear popup text selection drag state (selection remains in popup)
214                self.active_window_mut().mouse_state.selecting_in_popup = None;
215
216                // If we finished dragging a separator, resize visible terminals
217                if was_dragging_separator {
218                    self.active_window_mut().resize_visible_terminals();
219                }
220
221                needs_render = true;
222            }
223            MouseEventKind::Moved => {
224                // Dispatch MouseMove hook to plugins (fire-and-forget, no blocking check)
225                {
226                    // Find content rect for the split under the mouse
227                    let content_rect = self
228                        .active_layout()
229                        .split_areas
230                        .iter()
231                        .find(|(_, _, content_rect, _, _, _)| in_rect(col, row, *content_rect))
232                        .map(|(_, _, rect, _, _, _)| *rect);
233
234                    let (content_x, content_y) = content_rect.map(|r| (r.x, r.y)).unwrap_or((0, 0));
235
236                    self.plugin_manager.read().unwrap().run_hook(
237                        "mouse_move",
238                        HookArgs::MouseMove {
239                            column: col,
240                            row,
241                            content_x,
242                            content_y,
243                        },
244                    );
245                }
246
247                // Only re-render if hover target actually changed
248                // (preserve needs_render if already set, e.g., for GPM cursor updates)
249                let hover_changed = self.update_hover_target(col, row);
250                needs_render = needs_render || hover_changed;
251
252                // Update theme info popup button highlight on hover
253                if let Some((popup_rect, button_row_offset)) = self.theme_info_popup_rect() {
254                    let button_row = popup_rect.y + button_row_offset;
255                    let new_highlighted = row == button_row
256                        && col >= popup_rect.x
257                        && col < popup_rect.x + popup_rect.width;
258                    if let Some(ref mut popup) = self.active_window_mut().theme_info_popup {
259                        if popup.button_highlighted != new_highlighted {
260                            popup.button_highlighted = new_highlighted;
261                            needs_render = true;
262                        }
263                    }
264                }
265
266                // Track LSP hover state for mouse-triggered hover popups
267                self.update_lsp_hover_state(col, row);
268            }
269            MouseEventKind::ScrollUp => {
270                self.handle_vertical_scroll(col, row, mouse_event.modifiers, -3)?;
271                needs_render = true;
272            }
273            MouseEventKind::ScrollDown => {
274                self.handle_vertical_scroll(col, row, mouse_event.modifiers, 3)?;
275                needs_render = true;
276            }
277            MouseEventKind::ScrollLeft => {
278                // Native horizontal scroll left
279                self.active_window_mut()
280                    .handle_horizontal_scroll(col, row, -3)?;
281                needs_render = true;
282            }
283            MouseEventKind::ScrollRight => {
284                // Native horizontal scroll right
285                self.active_window_mut()
286                    .handle_horizontal_scroll(col, row, 3)?;
287                needs_render = true;
288            }
289            MouseEventKind::Down(MouseButton::Right) => {
290                // Mouse-modal overlay: swallow right-click / Ctrl+right-click
291                // so neither the tab context menu nor the theme-info popup
292                // fires, and the buffer below is untouched.
293                if self.overlay_prompt_active() {
294                    needs_render = true;
295                } else if mouse_event
296                    .modifiers
297                    .contains(crossterm::event::KeyModifiers::CONTROL)
298                {
299                    // Ctrl+Right-Click → theme info popup
300                    self.show_theme_info_popup(col, row)?;
301                    needs_render = true;
302                } else {
303                    // Normal right-click → tab context menu
304                    self.handle_right_click(col, row)?;
305                    needs_render = true;
306                }
307            }
308            _ => {
309                // Ignore other mouse events for now
310            }
311        }
312
313        self.active_window_mut().mouse_state.last_position = Some((col, row));
314        Ok(needs_render)
315    }
316
317    /// Detect double/triple clicks and update click-tracking state.
318    fn detect_multi_click(
319        &mut self,
320        mouse_event: &crossterm::event::MouseEvent,
321        col: u16,
322        row: u16,
323    ) -> (bool, bool) {
324        use crossterm::event::{MouseButton, MouseEventKind};
325        if !matches!(mouse_event.kind, MouseEventKind::Down(MouseButton::Left)) {
326            return (false, false);
327        }
328        let now = self.time_source.now();
329        let threshold = std::time::Duration::from_millis(self.config.editor.double_click_time_ms);
330        let is_consecutive = if let (Some(prev_time), Some(prev_pos)) = (
331            self.active_window_mut().previous_click_time,
332            self.active_window_mut().previous_click_position,
333        ) {
334            now.duration_since(prev_time) < threshold && prev_pos == (col, row)
335        } else {
336            false
337        };
338        if is_consecutive {
339            self.active_window_mut().click_count += 1;
340        } else {
341            self.active_window_mut().click_count = 1;
342        }
343        self.active_window_mut().previous_click_time = Some(now);
344        self.active_window_mut().previous_click_position = Some((col, row));
345        let is_triple = self.active_window_mut().click_count >= 3;
346        let is_double = self.active_window_mut().click_count == 2;
347        if is_triple {
348            self.active_window_mut().click_count = 0;
349            self.active_window_mut().previous_click_time = None;
350            self.active_window_mut().previous_click_position = None;
351        }
352        (is_double, is_triple)
353    }
354
355    /// Dispatch a vertical scroll event (ScrollUp/ScrollDown) through the priority chain:
356    /// Shift → horizontal scroll, prompt, file browser, popup, editor/terminal.
357    fn handle_vertical_scroll(
358        &mut self,
359        col: u16,
360        row: u16,
361        modifiers: crossterm::event::KeyModifiers,
362        delta: i32,
363    ) -> AnyhowResult<()> {
364        if modifiers.contains(crossterm::event::KeyModifiers::SHIFT) {
365            self.active_window_mut()
366                .handle_horizontal_scroll(col, row, delta)?;
367        } else if self.handle_overlay_prompt_scroll(col, row, delta) {
368            // Floating-overlay prompt (Live Grep): the wheel scrolls the pane
369            // under the pointer — the preview when over it, otherwise the
370            // result list (without moving the selection). See issue #2119.
371        } else if self.handle_prompt_scroll(delta) {
372            // bottom-anchored prompt consumed the scroll (moves selection)
373        } else if self.is_file_open_active()
374            && self.is_mouse_over_file_browser(col, row)
375            && self.handle_file_open_scroll(delta)
376        {
377            // file browser consumed the scroll
378        } else if self.is_mouse_over_any_popup(col, row) {
379            self.scroll_popup(delta);
380        } else if self.floating_widget_panel.is_some() {
381            // The floating widget panel (orchestrator picker, New
382            // Session form, ...) is modal: scroll it when the pointer is
383            // over it (e.g. a Text-widget completion popup scrolls its
384            // candidate list), and otherwise swallow the wheel so it
385            // can't leak through to the buffer behind the modal. Either
386            // way the event is consumed here — never falls through.
387            self.handle_floating_widget_panel_wheel(col, row, delta);
388        } else if self
389            .active_window()
390            .split_at_position(col, row)
391            .map(|(_, buffer_id)| self.handle_widget_panel_wheel(buffer_id, delta))
392            .unwrap_or(false)
393        {
394            // a mounted widget panel consumed the scroll
395        } else {
396            if self.active_window().terminal_mode
397                && self
398                    .active_window()
399                    .is_terminal_buffer(self.active_buffer())
400            {
401                {
402                    let __b = self.active_buffer();
403                    self.active_window_mut().sync_terminal_to_buffer(__b);
404                };
405                self.active_window_mut().terminal_mode = false;
406                self.active_window_mut().key_context =
407                    crate::input::keybindings::KeyContext::Normal;
408            }
409            self.dismiss_transient_popups();
410            self.active_window_mut()
411                .handle_mouse_scroll(col, row, delta)?;
412        }
413        Ok(())
414    }
415
416    /// Route a wheel event inside the floating-overlay prompt (Live Grep).
417    ///
418    /// The overlay is mouse-modal, so it always consumes the wheel (returns
419    /// true) when active — the event must never leak to the buffer below.
420    /// * Over the preview pane → scroll the preview.
421    /// * Anywhere else (result list, input, toolbar, frame) → scroll the
422    ///   result list *without* moving the selection.
423    ///
424    /// Bottom-anchored prompts (command palette, file finder) are left to
425    /// `handle_prompt_scroll`, which keeps their wheel-moves-selection UX.
426    fn handle_overlay_prompt_scroll(&mut self, col: u16, row: u16, delta: i32) -> bool {
427        if !self.overlay_prompt_active() {
428            return false;
429        }
430        let preview_area = self.active_chrome().prompt_preview_area;
431        let results_visible = self
432            .active_chrome()
433            .prompt_results_area
434            .map(|r| r.height as usize)
435            .unwrap_or(0);
436        if let Some(preview) = preview_area {
437            if in_rect(col, row, preview) {
438                self.active_window_mut()
439                    .scroll_overlay_preview_by_lines(delta);
440                return true;
441            }
442        }
443        if let Some(prompt) = self.active_window_mut().prompt.as_mut() {
444            prompt.scroll_results(delta, results_visible);
445        }
446        true
447    }
448
449    /// Update the current hover target based on mouse position
450    /// Returns true if the hover target changed (requiring a re-render)
451    pub(super) fn update_hover_target(&mut self, col: u16, row: u16) -> bool {
452        let old_target = self.active_window_mut().mouse_state.hover_target.clone();
453        let new_target = self.compute_hover_target(col, row);
454        let changed = old_target != new_target;
455        self.active_window_mut().mouse_state.hover_target = new_target.clone();
456
457        // If a menu is currently open and we're hovering over a different menu bar item,
458        // switch to that menu automatically
459        if let Some(active_menu_idx) = self.menu_state.active_menu {
460            let all_menus: Vec<crate::config::Menu> = self
461                .menus
462                .menus
463                .iter()
464                .chain(self.menu_state.plugin_menus.iter())
465                .cloned()
466                .collect();
467            if let Some(HoverTarget::MenuBarItem(hovered_menu_idx)) = new_target.clone() {
468                if hovered_menu_idx != active_menu_idx {
469                    self.menu_state.open_menu(hovered_menu_idx);
470                    return true; // Force re-render since menu changed
471                }
472            }
473
474            // If hovering over a menu dropdown item, check if it's a submenu and open it
475            if let Some(HoverTarget::MenuDropdownItem(_, item_idx)) = new_target.clone() {
476                // If this item is the parent of the currently open submenu, keep it open.
477                // This prevents blinking when hovering over the parent item of an open submenu.
478                if self.menu_state.submenu_path.first() == Some(&item_idx) {
479                    tracing::trace!(
480                        "menu hover: staying on submenu parent item_idx={}, submenu_path={:?}",
481                        item_idx,
482                        self.menu_state.submenu_path
483                    );
484                    return changed;
485                }
486
487                // Clear any open submenus since we're at a different item in the main dropdown
488                if !self.menu_state.submenu_path.is_empty() {
489                    tracing::trace!(
490                        "menu hover: clearing submenu_path={:?} for different item_idx={}",
491                        self.menu_state.submenu_path,
492                        item_idx
493                    );
494                    self.menu_state.submenu_path.clear();
495                    self.menu_state.highlighted_item = Some(item_idx);
496                    return true;
497                }
498
499                // Check if the hovered item is a submenu
500                if let Some(menu) = all_menus.get(active_menu_idx) {
501                    if let Some(crate::config::MenuItem::Submenu { items, .. }) =
502                        menu.items.get(item_idx)
503                    {
504                        if !items.is_empty() {
505                            tracing::trace!("menu hover: opening submenu at item_idx={}", item_idx);
506                            self.menu_state.submenu_path.push(item_idx);
507                            self.menu_state.highlighted_item = Some(0);
508                            return true;
509                        }
510                    }
511                }
512                // Update highlighted item for non-submenu items too
513                if self.menu_state.highlighted_item != Some(item_idx) {
514                    self.menu_state.highlighted_item = Some(item_idx);
515                    return true;
516                }
517            }
518
519            // If hovering over a submenu item, handle submenu navigation
520            if let Some(HoverTarget::SubmenuItem(depth, item_idx)) = new_target {
521                // If this item is the parent of a currently open nested submenu, keep it open.
522                // This prevents blinking when hovering over the parent item of an open nested submenu.
523                // submenu_path[depth] stores the index of the nested submenu opened from this level.
524                if self.menu_state.submenu_path.len() > depth
525                    && self.menu_state.submenu_path.get(depth) == Some(&item_idx)
526                {
527                    tracing::trace!(
528                        "menu hover: staying on nested submenu parent depth={}, item_idx={}, submenu_path={:?}",
529                        depth,
530                        item_idx,
531                        self.menu_state.submenu_path
532                    );
533                    return changed;
534                }
535
536                // Truncate submenu path to this depth (close any deeper submenus)
537                if self.menu_state.submenu_path.len() > depth {
538                    tracing::trace!(
539                        "menu hover: truncating submenu_path={:?} to depth={} for item_idx={}",
540                        self.menu_state.submenu_path,
541                        depth,
542                        item_idx
543                    );
544                    self.menu_state.submenu_path.truncate(depth);
545                }
546
547                // Get the items at this depth
548                if let Some(items) = self
549                    .menu_state
550                    .get_current_items(&all_menus, active_menu_idx)
551                {
552                    // Check if hovered item is a submenu - if so, open it
553                    if let Some(crate::config::MenuItem::Submenu {
554                        items: sub_items, ..
555                    }) = items.get(item_idx)
556                    {
557                        if !sub_items.is_empty()
558                            && !self.menu_state.submenu_path.contains(&item_idx)
559                        {
560                            tracing::trace!(
561                                "menu hover: opening nested submenu at depth={}, item_idx={}",
562                                depth,
563                                item_idx
564                            );
565                            self.menu_state.submenu_path.push(item_idx);
566                            self.menu_state.highlighted_item = Some(0);
567                            return true;
568                        }
569                    }
570                    // Update highlighted item
571                    if self.menu_state.highlighted_item != Some(item_idx) {
572                        self.menu_state.highlighted_item = Some(item_idx);
573                        return true;
574                    }
575                }
576            }
577        }
578
579        // Handle tab context menu hover - update highlighted item
580        if let Some(HoverTarget::TabContextMenuItem(item_idx)) = new_target.clone() {
581            if let Some(ref mut menu) = self.active_window_mut().tab_context_menu {
582                if menu.highlighted != item_idx {
583                    menu.highlighted = item_idx;
584                    return true;
585                }
586            }
587        }
588
589        if let Some(&HoverTarget::FileExplorerContextMenuItem(item_idx)) = new_target.as_ref() {
590            if let Some(ref mut menu) = self.active_window_mut().file_explorer_context_menu {
591                if menu.highlighted != item_idx {
592                    menu.highlighted = item_idx;
593                    return true;
594                }
595            }
596        }
597
598        // Handle file explorer status indicator hover - show tooltip
599        // Always dismiss existing tooltip first when target changes
600        if old_target != new_target
601            && matches!(
602                old_target,
603                Some(HoverTarget::FileExplorerStatusIndicator(_))
604            )
605        {
606            self.dismiss_file_explorer_status_tooltip();
607        }
608
609        if let Some(HoverTarget::FileExplorerStatusIndicator(ref path)) = new_target {
610            // Only show tooltip if this is a new hover (not already showing for this path)
611            if old_target != new_target {
612                self.show_file_explorer_status_tooltip(path.clone(), col, row);
613                return true;
614            }
615        }
616
617        changed
618    }
619
620    /// Update LSP hover state based on mouse position
621    /// Tracks position for debounced hover requests
622    ///
623    /// Hover popup stays visible when:
624    /// - Mouse is over the hover popup itself
625    /// - Mouse is within the hovered symbol range
626    ///
627    /// Hover is dismissed when mouse leaves the editor area entirely.
628    fn update_lsp_hover_state(&mut self, col: u16, row: u16) {
629        tracing::trace!(col, row, "update_lsp_hover_state: raw mouse position");
630
631        // Suppress LSP hover when a popup is already visible (e.g. theme info popup,
632        // tab context menu) to avoid hover tooltips overlapping other popups.
633        if self.active_window_mut().theme_info_popup.is_some()
634            || self.active_window_mut().tab_context_menu.is_some()
635            || self
636                .active_window_mut()
637                .file_explorer_context_menu
638                .is_some()
639        {
640            if self
641                .active_window_mut()
642                .mouse_state
643                .lsp_hover_state
644                .is_some()
645            {
646                self.active_window_mut().mouse_state.lsp_hover_state = None;
647                self.active_window_mut().mouse_state.lsp_hover_request_sent = false;
648                self.dismiss_transient_popups();
649            }
650            return;
651        }
652
653        // Check if mouse is over a transient popup - if so, keep hover active
654        if self.is_mouse_over_transient_popup(col, row) {
655            return;
656        }
657
658        // Find which split the mouse is over
659        let split_info = self
660            .active_layout()
661            .split_areas
662            .iter()
663            .find(|(_, _, content_rect, _, _, _)| in_rect(col, row, *content_rect))
664            .map(|(split_id, buffer_id, content_rect, _, _, _)| {
665                (*split_id, *buffer_id, *content_rect)
666            });
667
668        let Some((split_id, buffer_id, content_rect)) = split_info else {
669            // Mouse is not over editor content - clear hover state and dismiss popup
670            if self
671                .active_window_mut()
672                .mouse_state
673                .lsp_hover_state
674                .is_some()
675            {
676                self.active_window_mut().mouse_state.lsp_hover_state = None;
677                self.active_window_mut().mouse_state.lsp_hover_request_sent = false;
678                self.dismiss_transient_popups();
679            }
680            return;
681        };
682
683        // Get cached mappings and gutter width for this split
684        let cached_mappings = self
685            .active_layout()
686            .view_line_mappings
687            .get(&split_id)
688            .cloned();
689        let gutter_width = self
690            .buffers()
691            .get(&buffer_id)
692            .map(|s| s.margins.left_total_width() as u16)
693            .unwrap_or(0);
694        let fallback = self
695            .buffers()
696            .get(&buffer_id)
697            .map(|s| s.buffer.len())
698            .unwrap_or(0);
699
700        // Get compose width for this split
701        let compose_width = self
702            .windows
703            .get(&self.active_window)
704            .and_then(|w| w.buffers.splits())
705            .map(|(_, vs)| vs)
706            .expect("active window must have a populated split layout")
707            .get(&split_id)
708            .and_then(|vs| vs.compose_width);
709
710        // Convert screen position to buffer byte position
711        let Some(byte_pos) = super::click_geometry::screen_to_buffer_position(
712            col,
713            row,
714            content_rect,
715            gutter_width,
716            &cached_mappings,
717            fallback,
718            false, // Don't include gutter
719            compose_width,
720        ) else {
721            // Mouse is in the gutter — stop tracking a pending request but keep
722            // any existing popup visible. The popup is only dismissed when the
723            // mouse leaves the editor area entirely (see docstring).
724            if self
725                .active_window_mut()
726                .mouse_state
727                .lsp_hover_state
728                .is_some()
729            {
730                self.active_window_mut().mouse_state.lsp_hover_state = None;
731                self.active_window_mut().mouse_state.lsp_hover_request_sent = false;
732            }
733            return;
734        };
735
736        // Check if mouse is past the end of line content - don't trigger hover for empty space
737        let content_col = col.saturating_sub(content_rect.x);
738        let text_col = content_col.saturating_sub(gutter_width) as usize;
739        let visual_row = row.saturating_sub(content_rect.y) as usize;
740
741        let line_info = cached_mappings
742            .as_ref()
743            .and_then(|mappings| mappings.get(visual_row))
744            .map(|line_mapping| {
745                (
746                    line_mapping.visual_to_char.len(),
747                    line_mapping.line_end_byte,
748                )
749            });
750
751        let is_past_line_end_or_empty = line_info
752            .map(|(line_len, _)| {
753                // Empty lines (just newline) should not trigger hover
754                if line_len <= 1 {
755                    return true;
756                }
757                text_col >= line_len
758            })
759            // If mouse is below all mapped lines (no mapping), don't trigger hover
760            .unwrap_or(true);
761
762        tracing::trace!(
763            col,
764            row,
765            content_col,
766            text_col,
767            visual_row,
768            gutter_width,
769            byte_pos,
770            ?line_info,
771            is_past_line_end_or_empty,
772            "update_lsp_hover_state: position check"
773        );
774
775        if is_past_line_end_or_empty {
776            tracing::trace!(
777                "update_lsp_hover_state: mouse past line end or empty line, clearing hover"
778            );
779            // Mouse is past end of line content — stop tracking a pending
780            // request but keep any existing popup visible. The popup is only
781            // dismissed when the mouse leaves the editor area entirely
782            // (see docstring).
783            if self
784                .active_window_mut()
785                .mouse_state
786                .lsp_hover_state
787                .is_some()
788            {
789                self.active_window_mut().mouse_state.lsp_hover_state = None;
790                self.active_window_mut().mouse_state.lsp_hover_request_sent = false;
791            }
792            return;
793        }
794
795        // Check if mouse is within the hovered symbol range - if so, keep hover active
796        if let Some((start, end)) = self.active_window_mut().hover.symbol_range() {
797            if byte_pos >= start && byte_pos < end {
798                // Mouse is still over the hovered symbol - keep hover state
799                return;
800            }
801        }
802
803        // Check if we're still hovering the same position
804        if let Some((old_pos, _, _, _)) = self.active_window_mut().mouse_state.lsp_hover_state {
805            if old_pos == byte_pos {
806                // Same position - keep existing state
807                return;
808            }
809            // Position changed outside the hovered symbol range. Don't dismiss
810            // the popup here: a new hover request will fire after the debounce
811            // and replace the popup naturally if the mouse settles on another
812            // symbol. Dismissing eagerly tore the popup down whenever the
813            // mouse passed through whitespace between two words (issue #692).
814        }
815
816        // Start tracking new hover position
817        self.active_window_mut().mouse_state.lsp_hover_state =
818            Some((byte_pos, std::time::Instant::now(), col, row));
819        self.active_window_mut().mouse_state.lsp_hover_request_sent = false;
820    }
821
822    /// Check if mouse position is over a transient popup (hover, signature help)
823    fn is_mouse_over_transient_popup(&self, col: u16, row: u16) -> bool {
824        let layouts = popup_areas_to_layout_info(&self.active_chrome().popup_areas);
825        let hit_tester = PopupHitTester::new(&layouts, &self.active_state().popups);
826        hit_tester.is_over_transient_popup(col, row)
827    }
828
829    /// Check if mouse position is over any popup (including non-transient ones like completion)
830    fn is_mouse_over_any_popup(&self, col: u16, row: u16) -> bool {
831        // Editor-level popup overlays absorb every click within their outer
832        // rect so the buffer below doesn't receive a stray cursor placement.
833        for (_, popup_area, _, _, _) in &self.active_chrome().global_popup_areas {
834            if in_rect(col, row, *popup_area) {
835                return true;
836            }
837        }
838        // The prompt's suggestions popup also absorbs clicks across its full
839        // outer rect (border + items): clicking the chrome must not move the
840        // buffer cursor below.
841        if let Some(outer) = self.active_chrome().suggestions_outer_area {
842            if in_rect(col, row, outer) {
843                return true;
844            }
845        }
846        let layouts = popup_areas_to_layout_info(&self.active_chrome().popup_areas);
847        let hit_tester = PopupHitTester::new(&layouts, &self.active_state().popups);
848        hit_tester.is_over_popup(col, row)
849    }
850
851    /// Check if mouse position is over the file browser popup
852    fn is_mouse_over_file_browser(&self, col: u16, row: u16) -> bool {
853        self.active_window()
854            .file_browser_layout
855            .as_ref()
856            .is_some_and(|layout| layout.contains(col, row))
857    }
858
859    // `split_at_position` lives on `impl Window` — call it via
860    // `self.active_window().split_at_position(col, row)`.
861
862    /// Compute what hover target is at the given position
863    fn compute_hover_target(&self, col: u16, row: u16) -> Option<HoverTarget> {
864        if let Some(ref menu) = self.active_window().file_explorer_context_menu {
865            let (menu_x, menu_y) = menu.clamped_position(
866                self.active_chrome().last_frame_width,
867                self.active_chrome().last_frame_height,
868            );
869            let menu_width = super::types::FILE_EXPLORER_CONTEXT_MENU_WIDTH;
870            let menu_height = menu.height();
871
872            if col >= menu_x
873                && col < menu_x + menu_width
874                && row > menu_y
875                && row < menu_y + menu_height - 1
876            {
877                let item_idx = (row - menu_y - 1) as usize;
878                if item_idx < menu.items().len() {
879                    return Some(HoverTarget::FileExplorerContextMenuItem(item_idx));
880                }
881            }
882        }
883
884        // Check tab context menu first (it's rendered on top)
885        if let Some(ref menu) = self.active_window().tab_context_menu {
886            let menu_x = menu.position.0;
887            let menu_y = menu.position.1;
888            let menu_width = 22u16;
889            let items = super::types::TabContextMenuItem::all();
890            let menu_height = items.len() as u16 + 2;
891
892            if col >= menu_x
893                && col < menu_x + menu_width
894                && row > menu_y
895                && row < menu_y + menu_height - 1
896            {
897                let item_idx = (row - menu_y - 1) as usize;
898                if item_idx < items.len() {
899                    return Some(HoverTarget::TabContextMenuItem(item_idx));
900                }
901            }
902        }
903
904        // Check suggestions area first (command palette, autocomplete)
905        if let Some((inner_rect, start_idx, _visible_count, total_count)) =
906            &self.active_chrome().suggestions_area
907        {
908            if in_rect(col, row, *inner_rect) {
909                let relative_row = (row - inner_rect.y) as usize;
910                let item_idx = start_idx + relative_row;
911
912                if item_idx < *total_count {
913                    return Some(HoverTarget::SuggestionItem(item_idx));
914                }
915            }
916        }
917
918        // Check popups (they're rendered on top)
919        // Check from top to bottom (reverse order since last popup is on top)
920        for (popup_idx, _popup_rect, inner_rect, scroll_offset, num_items, _, _) in
921            self.active_chrome().popup_areas.iter().rev()
922        {
923            if in_rect(col, row, *inner_rect) && *num_items > 0 {
924                // Calculate which item is being hovered
925                let relative_row = (row - inner_rect.y) as usize;
926                let item_idx = scroll_offset + relative_row;
927
928                if item_idx < *num_items {
929                    return Some(HoverTarget::PopupListItem(*popup_idx, item_idx));
930                }
931            }
932        }
933
934        // Check file browser popup
935        if self.is_file_open_active() {
936            if let Some(hover) = self.compute_file_browser_hover(col, row) {
937                return Some(hover);
938            }
939        }
940
941        // Check menu bar (row 0, only when visible)
942        // Check menu bar using cached layout from previous render
943        if self.active_window().menu_bar_visible {
944            if let Some(ref menu_layout) = self.active_chrome().menu_layout {
945                if let Some(menu_idx) = menu_layout.menu_at(col, row) {
946                    return Some(HoverTarget::MenuBarItem(menu_idx));
947                }
948            }
949        }
950
951        // Check menu dropdown items if a menu is open (including submenus)
952        if let Some(active_idx) = self.menu_state.active_menu {
953            if let Some(hover) = self.compute_menu_dropdown_hover(col, row, active_idx) {
954                return Some(hover);
955            }
956        }
957
958        // Check file explorer close button and border (for resize)
959        if let Some(explorer_area) = self.active_layout().file_explorer_area {
960            // Close button is at position: explorer_area.x + explorer_area.width - 3 to -1
961            let close_button_x = explorer_area.x + explorer_area.width.saturating_sub(3);
962            if row == explorer_area.y
963                && col >= close_button_x
964                && col < explorer_area.x + explorer_area.width
965            {
966                return Some(HoverTarget::FileExplorerCloseButton);
967            }
968
969            // Check if hovering over a status indicator in the file explorer content area
970            // Status indicators are in the rightmost 2 characters of each row (before border)
971            let content_start_y = explorer_area.y + 1; // +1 for title bar
972            let content_end_y = explorer_area.y + explorer_area.height.saturating_sub(1); // -1 for bottom border
973            let status_indicator_x = explorer_area.x + explorer_area.width.saturating_sub(3); // 2 chars + 1 border
974
975            if row >= content_start_y
976                && row < content_end_y
977                && col >= status_indicator_x
978                && col < explorer_area.x + explorer_area.width.saturating_sub(1)
979            {
980                // Determine which item is at this row
981                if let Some(explorer) = self.file_explorer().as_ref() {
982                    let relative_row = row.saturating_sub(content_start_y) as usize;
983                    let scroll_offset = explorer.get_scroll_offset();
984                    let item_index = relative_row + scroll_offset;
985                    let display_nodes = explorer.get_display_nodes();
986
987                    if item_index < display_nodes.len() {
988                        let (node_id, _indent) = display_nodes[item_index];
989                        if let Some(node) = explorer.tree().get_node(node_id) {
990                            return Some(HoverTarget::FileExplorerStatusIndicator(
991                                node.entry.path.clone(),
992                            ));
993                        }
994                    }
995                }
996            }
997
998            // The border is at the rightmost column of the file explorer area
999            // (the drawn border character), not one past it.
1000            let border_x = explorer_area.x + explorer_area.width.saturating_sub(1);
1001            if col == border_x
1002                && row >= explorer_area.y
1003                && row < explorer_area.y + explorer_area.height
1004            {
1005                return Some(HoverTarget::FileExplorerBorder);
1006            }
1007        }
1008
1009        // Check split separators
1010        for (split_id, direction, sep_x, sep_y, sep_length) in &self.active_layout().separator_areas
1011        {
1012            let is_on_separator = match direction {
1013                SplitDirection::Horizontal => {
1014                    row == *sep_y && col >= *sep_x && col < sep_x + sep_length
1015                }
1016                SplitDirection::Vertical => {
1017                    col == *sep_x && row >= *sep_y && row < sep_y + sep_length
1018                }
1019            };
1020
1021            if is_on_separator {
1022                return Some(HoverTarget::SplitSeparator(*split_id, *direction));
1023            }
1024        }
1025
1026        // Check tab areas using cached hit regions (computed during rendering)
1027        // Check split control buttons first (they're on top of the tab row)
1028        for (split_id, btn_row, start_col, end_col) in &self.active_layout().close_split_areas {
1029            if row == *btn_row && col >= *start_col && col < *end_col {
1030                return Some(HoverTarget::CloseSplitButton(*split_id));
1031            }
1032        }
1033
1034        for (split_id, btn_row, start_col, end_col) in &self.active_layout().maximize_split_areas {
1035            if row == *btn_row && col >= *start_col && col < *end_col {
1036                return Some(HoverTarget::MaximizeSplitButton(*split_id));
1037            }
1038        }
1039
1040        for (split_id, tab_layout) in &self.active_layout().tab_layouts {
1041            match tab_layout.hit_test(col, row) {
1042                Some(TabHit::CloseButton(target)) => {
1043                    return Some(HoverTarget::TabCloseButton(target, *split_id));
1044                }
1045                Some(TabHit::TabName(target)) => {
1046                    return Some(HoverTarget::TabName(target, *split_id));
1047                }
1048                Some(TabHit::ScrollLeft)
1049                | Some(TabHit::ScrollRight)
1050                | Some(TabHit::BarBackground)
1051                | None => {}
1052            }
1053        }
1054
1055        // Check scrollbars
1056        for (split_id, _buffer_id, _content_rect, scrollbar_rect, thumb_start, thumb_end) in
1057            &self.active_layout().split_areas
1058        {
1059            if in_rect(col, row, *scrollbar_rect) {
1060                let relative_row = row.saturating_sub(scrollbar_rect.y) as usize;
1061                let is_on_thumb = relative_row >= *thumb_start && relative_row < *thumb_end;
1062
1063                if is_on_thumb {
1064                    return Some(HoverTarget::ScrollbarThumb(*split_id));
1065                } else {
1066                    return Some(HoverTarget::ScrollbarTrack(*split_id, relative_row as u16));
1067                }
1068            }
1069        }
1070
1071        // Check status bar indicators
1072        if let Some((status_row, _status_x, _status_width)) = self.active_chrome().status_bar_area {
1073            if row == status_row {
1074                let indicators = [
1075                    (
1076                        self.active_chrome().status_bar_line_ending_area,
1077                        HoverTarget::StatusBarLineEndingIndicator,
1078                    ),
1079                    (
1080                        self.active_chrome().status_bar_encoding_area,
1081                        HoverTarget::StatusBarEncodingIndicator,
1082                    ),
1083                    (
1084                        self.active_chrome().status_bar_language_area,
1085                        HoverTarget::StatusBarLanguageIndicator,
1086                    ),
1087                    (
1088                        self.active_chrome().status_bar_lsp_area,
1089                        HoverTarget::StatusBarLspIndicator,
1090                    ),
1091                    (
1092                        self.active_chrome().status_bar_remote_area,
1093                        HoverTarget::StatusBarRemoteIndicator,
1094                    ),
1095                    (
1096                        self.active_chrome().status_bar_warning_area,
1097                        HoverTarget::StatusBarWarningBadge,
1098                    ),
1099                ];
1100                for (area, target) in indicators {
1101                    if let Some((indicator_row, start, end)) = area {
1102                        if row == indicator_row && col >= start && col < end {
1103                            return Some(target);
1104                        }
1105                    }
1106                }
1107            }
1108        }
1109
1110        // Check search options bar checkboxes
1111        if let Some(ref layout) = self.active_chrome().search_options_layout {
1112            use crate::view::ui::status_bar::SearchOptionsHover;
1113            if let Some(hover) = layout.checkbox_at(col, row) {
1114                return Some(match hover {
1115                    SearchOptionsHover::CaseSensitive => HoverTarget::SearchOptionCaseSensitive,
1116                    SearchOptionsHover::WholeWord => HoverTarget::SearchOptionWholeWord,
1117                    SearchOptionsHover::Regex => HoverTarget::SearchOptionRegex,
1118                    SearchOptionsHover::ConfirmEach => HoverTarget::SearchOptionConfirmEach,
1119                    SearchOptionsHover::None => return None,
1120                });
1121            }
1122        }
1123
1124        // No hover target
1125        None
1126    }
1127
1128    /// Handle mouse double click (down event)
1129    /// Double-click in editor area selects the word under the cursor.
1130    pub(super) fn handle_mouse_double_click(&mut self, col: u16, row: u16) -> AnyhowResult<()> {
1131        tracing::debug!("handle_mouse_double_click at col={}, row={}", col, row);
1132
1133        // Double-click on a suggestion item commits the choice — even for
1134        // prompts whose first click only previews. The first click already
1135        // selected the row; the second confirms (#1660).
1136        if let Some(r) = self.handle_click_suggestions_confirm(col, row) {
1137            return r;
1138        }
1139
1140        // Mouse-modal overlay: swallow any double-click that wasn't on a
1141        // result row so it can't reach (and word-select in) the buffer below.
1142        if self.overlay_prompt_active() {
1143            return Ok(());
1144        }
1145
1146        // Handle popups: dismiss if clicking outside, block if clicking inside
1147        if self.is_mouse_over_any_popup(col, row) {
1148            // Double-click inside popup - block from reaching editor
1149            return Ok(());
1150        } else {
1151            // Double-click outside popup - dismiss transient popups
1152            self.dismiss_transient_popups();
1153        }
1154
1155        // Is it in the file open dialog?
1156        if self.handle_file_open_double_click(col, row) {
1157            return Ok(());
1158        }
1159
1160        // Is it in the file explorer? Double-click opens file AND focuses editor
1161        if let Some(explorer_area) = self.active_layout().file_explorer_area {
1162            if col >= explorer_area.x
1163                && col < explorer_area.x + explorer_area.width
1164                && row > explorer_area.y // Skip title bar
1165                && row < explorer_area.y + explorer_area.height
1166            {
1167                // Open file and focus editor (via file_explorer_open_file which calls focus_editor)
1168                self.file_explorer_open_file()?;
1169                return Ok(());
1170            }
1171        }
1172
1173        // Find which split/buffer was clicked and handle double-click
1174        let split_areas = self.active_layout().split_areas.clone();
1175        for (split_id, buffer_id, content_rect, _scrollbar_rect, _thumb_start, _thumb_end) in
1176            &split_areas
1177        {
1178            if in_rect(col, row, *content_rect) {
1179                // Double-clicked on an editor split
1180                if self.active_window().is_terminal_buffer(*buffer_id) {
1181                    self.active_window_mut().key_context =
1182                        crate::input::keybindings::KeyContext::Terminal;
1183                    // Don't select word in terminal buffers
1184                    return Ok(());
1185                }
1186
1187                self.active_window_mut().key_context =
1188                    crate::input::keybindings::KeyContext::Normal;
1189
1190                // Position cursor at click location and select word
1191                self.handle_editor_double_click(col, row, *split_id, *buffer_id, *content_rect)?;
1192                return Ok(());
1193            }
1194        }
1195
1196        Ok(())
1197    }
1198
1199    /// Handle double-click in editor content area - selects the word under cursor
1200    fn handle_editor_double_click(
1201        &mut self,
1202        col: u16,
1203        row: u16,
1204        split_id: LeafId,
1205        buffer_id: BufferId,
1206        content_rect: ratatui::layout::Rect,
1207    ) -> AnyhowResult<()> {
1208        use crate::model::event::Event;
1209
1210        // Fixed panels (toolbars, headers) are inert — no click focus,
1211        // no selection. Scrollable group panels still accept clicks even
1212        // when their cursor is hidden.
1213        if self.active_window().is_non_scrollable_buffer(buffer_id) {
1214            return Ok(());
1215        }
1216
1217        // Focus this split
1218        self.focus_split(split_id, buffer_id);
1219
1220        // Get cached view line mappings for this split
1221        let cached_mappings = self
1222            .active_layout()
1223            .view_line_mappings
1224            .get(&split_id)
1225            .cloned();
1226
1227        // Get fallback from SplitViewState viewport
1228        let leaf_id = split_id;
1229        let fallback = self
1230            .windows
1231            .get(&self.active_window)
1232            .and_then(|w| w.buffers.splits())
1233            .map(|(_, vs)| vs)
1234            .expect("active window must have a populated split layout")
1235            .get(&leaf_id)
1236            .map(|vs| vs.viewport.top_byte)
1237            .unwrap_or(0);
1238
1239        // Get compose width for this split
1240        let compose_width = self
1241            .windows
1242            .get(&self.active_window)
1243            .and_then(|w| w.buffers.splits())
1244            .map(|(_, vs)| vs)
1245            .expect("active window must have a populated split layout")
1246            .get(&leaf_id)
1247            .and_then(|vs| vs.compose_width);
1248
1249        // Pull the bits we need out of the active window separately;
1250        // the per-step helper methods (`apply_event_to_buffer` etc.)
1251        // hide the disjoint sub-field borrowing.
1252        let gutter_width = self
1253            .active_window()
1254            .buffers
1255            .get(&buffer_id)
1256            .map(|s| s.margins.left_total_width() as u16)
1257            .unwrap_or(0);
1258
1259        let Some(target_position) = super::click_geometry::screen_to_buffer_position(
1260            col,
1261            row,
1262            content_rect,
1263            gutter_width,
1264            &cached_mappings,
1265            fallback,
1266            true, // Allow gutter clicks
1267            compose_width,
1268        ) else {
1269            return Ok(());
1270        };
1271
1272        let primary_cursor_id = self
1273            .active_window()
1274            .buffers
1275            .splits()
1276            .and_then(|(_, vs)| vs.get(&leaf_id))
1277            .map(|vs| vs.cursors.primary_id())
1278            .unwrap_or(CursorId(0));
1279        let event = Event::MoveCursor {
1280            cursor_id: primary_cursor_id,
1281            old_position: 0,
1282            new_position: target_position,
1283            old_anchor: None,
1284            new_anchor: None,
1285            old_sticky_column: 0,
1286            new_sticky_column: 0,
1287        };
1288
1289        if let Some(event_log) = self.active_window_mut().event_logs.get_mut(&buffer_id) {
1290            event_log.append(event.clone());
1291        }
1292        self.active_window_mut()
1293            .apply_event_to_buffer(buffer_id, leaf_id, &event);
1294
1295        // Now select the word under cursor
1296        self.handle_action(Action::SelectWord)?;
1297
1298        // Set up drag state so subsequent drag events extend selection word-by-word
1299        if let Some(cursor) = self
1300            .windows
1301            .get(&self.active_window)
1302            .and_then(|w| w.buffers.splits())
1303            .map(|(_, vs)| vs)
1304            .expect("active window must have a populated split layout")
1305            .get(&leaf_id)
1306            .map(|vs| vs.cursors.primary())
1307        {
1308            // Store both edges of the selected word so we can use the appropriate
1309            // anchor when dragging forward (use word start) vs backward (use word end).
1310            let sel_start = cursor.selection_start();
1311            let sel_end = cursor.selection_end();
1312            self.active_window_mut().mouse_state.dragging_text_selection = true;
1313            self.active_window_mut().mouse_state.drag_selection_split = Some(split_id);
1314            self.active_window_mut().mouse_state.drag_selection_anchor = Some(sel_start);
1315            self.active_window_mut().mouse_state.drag_selection_by_words = true;
1316            self.active_window_mut().mouse_state.drag_selection_word_end = Some(sel_end);
1317        }
1318
1319        Ok(())
1320    }
1321    /// Handle mouse triple click (down event)
1322    /// Triple-click in editor area selects the entire line under the cursor.
1323    pub(super) fn handle_mouse_triple_click(&mut self, col: u16, row: u16) -> AnyhowResult<()> {
1324        tracing::debug!("handle_mouse_triple_click at col={}, row={}", col, row);
1325
1326        // Mouse-modal overlay: never let a triple-click line-select in the
1327        // buffer below the overlay.
1328        if self.overlay_prompt_active() {
1329            return Ok(());
1330        }
1331
1332        // Handle popups: dismiss if clicking outside, block if clicking inside
1333        if self.is_mouse_over_any_popup(col, row) {
1334            return Ok(());
1335        } else {
1336            self.dismiss_transient_popups();
1337        }
1338
1339        // Find which split/buffer was clicked
1340        let split_areas = self.active_layout().split_areas.clone();
1341        for (split_id, buffer_id, content_rect, _scrollbar_rect, _thumb_start, _thumb_end) in
1342            &split_areas
1343        {
1344            if in_rect(col, row, *content_rect) {
1345                if self.active_window().is_terminal_buffer(*buffer_id) {
1346                    return Ok(());
1347                }
1348
1349                self.active_window_mut().key_context =
1350                    crate::input::keybindings::KeyContext::Normal;
1351
1352                // Use the same pattern as handle_editor_double_click:
1353                // first focus and position cursor, then select line
1354                self.handle_editor_triple_click(col, row, *split_id, *buffer_id, *content_rect)?;
1355                return Ok(());
1356            }
1357        }
1358
1359        Ok(())
1360    }
1361
1362    /// Handle triple-click in editor content area - selects the entire line under cursor
1363    fn handle_editor_triple_click(
1364        &mut self,
1365        col: u16,
1366        row: u16,
1367        split_id: LeafId,
1368        buffer_id: BufferId,
1369        content_rect: ratatui::layout::Rect,
1370    ) -> AnyhowResult<()> {
1371        use crate::model::event::Event;
1372
1373        if self.active_window().is_non_scrollable_buffer(buffer_id) {
1374            return Ok(());
1375        }
1376
1377        // Focus this split
1378        self.focus_split(split_id, buffer_id);
1379
1380        // Get cached view line mappings for this split
1381        let cached_mappings = self
1382            .active_layout()
1383            .view_line_mappings
1384            .get(&split_id)
1385            .cloned();
1386
1387        let leaf_id = split_id;
1388        let fallback = self
1389            .windows
1390            .get(&self.active_window)
1391            .and_then(|w| w.buffers.splits())
1392            .map(|(_, vs)| vs)
1393            .expect("active window must have a populated split layout")
1394            .get(&leaf_id)
1395            .map(|vs| vs.viewport.top_byte)
1396            .unwrap_or(0);
1397
1398        // Get compose width for this split
1399        let compose_width = self
1400            .windows
1401            .get(&self.active_window)
1402            .and_then(|w| w.buffers.splits())
1403            .map(|(_, vs)| vs)
1404            .expect("active window must have a populated split layout")
1405            .get(&leaf_id)
1406            .and_then(|vs| vs.compose_width);
1407
1408        // Pull the bits we need out of the active window separately;
1409        // the per-step helper methods (`apply_event_to_buffer` etc.)
1410        // hide the disjoint sub-field borrowing.
1411        let gutter_width = self
1412            .active_window()
1413            .buffers
1414            .get(&buffer_id)
1415            .map(|s| s.margins.left_total_width() as u16)
1416            .unwrap_or(0);
1417
1418        let Some(target_position) = super::click_geometry::screen_to_buffer_position(
1419            col,
1420            row,
1421            content_rect,
1422            gutter_width,
1423            &cached_mappings,
1424            fallback,
1425            true,
1426            compose_width,
1427        ) else {
1428            return Ok(());
1429        };
1430
1431        let primary_cursor_id = self
1432            .active_window()
1433            .buffers
1434            .splits()
1435            .and_then(|(_, vs)| vs.get(&leaf_id))
1436            .map(|vs| vs.cursors.primary_id())
1437            .unwrap_or(CursorId(0));
1438        let event = Event::MoveCursor {
1439            cursor_id: primary_cursor_id,
1440            old_position: 0,
1441            new_position: target_position,
1442            old_anchor: None,
1443            new_anchor: None,
1444            old_sticky_column: 0,
1445            new_sticky_column: 0,
1446        };
1447
1448        if let Some(event_log) = self.active_window_mut().event_logs.get_mut(&buffer_id) {
1449            event_log.append(event.clone());
1450        }
1451        self.active_window_mut()
1452            .apply_event_to_buffer(buffer_id, leaf_id, &event);
1453
1454        // Now select the entire line
1455        self.handle_action(Action::SelectLine)?;
1456
1457        Ok(())
1458    }
1459
1460    /// Handle mouse click (down event)
1461    /// True while a floating-overlay prompt (e.g. Live Grep / Universal
1462    /// Search) owns the screen. Such overlays are **mouse-modal**: their own
1463    /// targets (result list, scrollbar, and — once wired — toolbar controls)
1464    /// are handled, but every other click is swallowed so it never lands in
1465    /// the buffer below and moves its cursor. Bottom-anchored (non-overlay)
1466    /// prompts are unaffected.
1467    pub(super) fn overlay_prompt_active(&self) -> bool {
1468        self.active_window()
1469            .prompt
1470            .as_ref()
1471            .is_some_and(|p| p.overlay)
1472    }
1473
1474    pub(super) fn handle_mouse_click(
1475        &mut self,
1476        col: u16,
1477        row: u16,
1478        modifiers: crossterm::event::KeyModifiers,
1479    ) -> AnyhowResult<()> {
1480        // Floating widget panel is modal: clicks inside hit-test
1481        // against its widget hits; clicks outside are swallowed so
1482        // the user can't accidentally focus another split while the
1483        // form is up. Auto-dismiss on outside-click is deliberately
1484        // NOT done — the form has explicit Cancel / Esc.
1485        if self.floating_widget_panel.is_some() {
1486            self.handle_floating_widget_click(col, row);
1487            return Ok(());
1488        }
1489        if let Some(r) = self.handle_click_context_menus(col, row) {
1490            return r;
1491        }
1492        if !self.is_mouse_over_any_popup(col, row) {
1493            self.dismiss_transient_popups();
1494        }
1495        if let Some(r) = self.handle_click_suggestions(col, row) {
1496            return r;
1497        }
1498        if let Some(r) = self.handle_click_prompt_scrollbar(col, row) {
1499            return r;
1500        }
1501        if let Some(r) = self.handle_click_popup_scrollbar(col, row) {
1502            return r;
1503        }
1504        if let Some(r) = self.handle_click_global_popups(col, row) {
1505            return r;
1506        }
1507        if let Some(r) = self.handle_click_buffer_popups(col, row) {
1508            return r;
1509        }
1510        if self.is_mouse_over_any_popup(col, row) {
1511            return Ok(());
1512        }
1513        if self.is_file_open_active() && self.handle_file_open_click(col, row) {
1514            return Ok(());
1515        }
1516        if let Some(r) = self.handle_click_menu_bar(col, row) {
1517            return r;
1518        }
1519        if let Some(r) = self.handle_click_file_explorer_area(col, row) {
1520            return r;
1521        }
1522        if let Some(r) = self.handle_click_scrollbar(col, row) {
1523            return r;
1524        }
1525        if let Some(r) = self.handle_click_horizontal_scrollbar(col, row) {
1526            return r;
1527        }
1528        if let Some(r) = self.handle_click_status_bar(col, row) {
1529            return r;
1530        }
1531        if let Some(r) = self.handle_click_search_options(col, row) {
1532            return r;
1533        }
1534        if let Some(r) = self.handle_click_split_separator(col, row) {
1535            return r;
1536        }
1537        if let Some(r) = self.handle_click_split_controls(col, row) {
1538            return r;
1539        }
1540        if let Some(r) = self.handle_click_tab_bar(col, row) {
1541            return r;
1542        }
1543
1544        // A floating-overlay prompt is mouse-modal: its own targets (result
1545        // list, scrollbar) were handled above. A click on a toolbar control
1546        // toggles it through the host (which emits a widget_event); anything
1547        // else — the input row, separator, preview pane, empty space, or a
1548        // click outside the frame — is swallowed here so it never reaches the
1549        // buffer and moves its cursor.
1550        if self.overlay_prompt_active() {
1551            let hit = self
1552                .active_chrome()
1553                .prompt_toolbar_hits
1554                .iter()
1555                .find(|(_, r)| in_rect(col, row, *r))
1556                .map(|(k, _)| k.clone());
1557            if let Some(widget_key) = hit {
1558                // Move keyboard focus to the clicked control so Tab continues
1559                // from here, then flip it through the host (which emits a
1560                // widget_event for the plugin).
1561                if let Some(p) = self.active_window_mut().prompt.as_mut() {
1562                    p.toolbar_focus = Some(widget_key.clone());
1563                }
1564                self.toggle_overlay_toolbar_widget(&widget_key);
1565            }
1566            return Ok(());
1567        }
1568
1569        // Check if click is in editor content area
1570        tracing::debug!(
1571            "handle_mouse_click: checking {} split_areas for click at ({}, {})",
1572            self.active_layout().split_areas.len(),
1573            col,
1574            row
1575        );
1576        for (split_id, buffer_id, content_rect, _scrollbar_rect, _thumb_start, _thumb_end) in
1577            &self.active_layout().split_areas
1578        {
1579            tracing::debug!(
1580                "  split_id={:?}, content_rect=({}, {}, {}x{})",
1581                split_id,
1582                content_rect.x,
1583                content_rect.y,
1584                content_rect.width,
1585                content_rect.height
1586            );
1587            if in_rect(col, row, *content_rect) {
1588                // Click in editor - focus split and position cursor
1589                tracing::debug!("  -> HIT! calling handle_editor_click");
1590                self.handle_editor_click(
1591                    col,
1592                    row,
1593                    *split_id,
1594                    *buffer_id,
1595                    *content_rect,
1596                    modifiers,
1597                )?;
1598                return Ok(());
1599            }
1600        }
1601        tracing::debug!("  -> No split area hit");
1602
1603        Ok(())
1604    }
1605
1606    // ── handle_mouse_click helpers ──────────────────────────────────────────
1607    // Each returns Some(result) if the click was consumed, None to fall through.
1608
1609    fn handle_click_context_menus(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
1610        if self
1611            .active_window_mut()
1612            .file_explorer_context_menu
1613            .is_some()
1614        {
1615            if let Some(result) = self.handle_file_explorer_context_menu_click(col, row) {
1616                return Some(result);
1617            }
1618        }
1619        if self.active_window_mut().tab_context_menu.is_some() {
1620            if let Some(result) = self.handle_tab_context_menu_click(col, row) {
1621                return Some(result);
1622            }
1623        }
1624        None
1625    }
1626
1627    /// Hit-test (col, row) against the suggestions popup. Returns the index
1628    /// of the suggestion under the click, or `None` if the click is outside
1629    /// the inner item area or no suggestions are visible.
1630    fn suggestion_at(&self, col: u16, row: u16) -> Option<usize> {
1631        let (inner_rect, start_idx, _visible_count, total_count) =
1632            self.active_chrome().suggestions_area?;
1633        if col < inner_rect.x
1634            || col >= inner_rect.x + inner_rect.width
1635            || row < inner_rect.y
1636            || row >= inner_rect.y + inner_rect.height
1637        {
1638            return None;
1639        }
1640        let relative_row = (row - inner_rect.y) as usize;
1641        let item_idx = start_idx + relative_row;
1642        if item_idx < total_count {
1643            Some(item_idx)
1644        } else {
1645            None
1646        }
1647    }
1648
1649    fn handle_click_suggestions(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
1650        let item_idx = self.suggestion_at(col, row)?;
1651        let prompt = self.active_window_mut().prompt.as_mut()?;
1652        prompt.selected_suggestion = Some(item_idx);
1653        let confirms = prompt.prompt_type.click_confirms();
1654        if !confirms {
1655            // Mirror keyboard navigation / scroll: sync the input
1656            // to the selected suggestion so the prompt reflects
1657            // what Enter would commit.
1658            if let Some(suggestion) = prompt.suggestions.get(item_idx) {
1659                prompt.input = suggestion.get_value().to_string();
1660                prompt.cursor_pos = prompt.input.len();
1661            }
1662        }
1663        if confirms {
1664            return Some(self.handle_action(Action::PromptConfirm));
1665        }
1666        Some(Ok(()))
1667    }
1668
1669    /// Click handler that always commits the suggestion under the cursor,
1670    /// regardless of `click_confirms`. Used for double-clicks so that
1671    /// preview-on-click prompts still have a mouse-only commit path.
1672    fn handle_click_suggestions_confirm(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
1673        let item_idx = self.suggestion_at(col, row)?;
1674        let prompt = self.active_window_mut().prompt.as_mut()?;
1675        prompt.selected_suggestion = Some(item_idx);
1676        if let Some(suggestion) = prompt.suggestions.get(item_idx) {
1677            prompt.input = suggestion.get_value().to_string();
1678            prompt.cursor_pos = prompt.input.len();
1679        }
1680        Some(self.handle_action(Action::PromptConfirm))
1681    }
1682
1683    /// Click/drag on the floating-overlay prompt's scrollbar
1684    /// (issue #1796). Reuses
1685    /// `view::ui::scrollbar::ScrollbarState::click_to_offset` for
1686    /// the same math the popup-scrollbar handler uses, so thumb
1687    /// behaviour is consistent across the editor.
1688    fn handle_click_prompt_scrollbar(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
1689        use crate::view::ui::scrollbar::ScrollbarState;
1690        let sb_rect = self.active_chrome().suggestions_scrollbar_rect?;
1691        if col < sb_rect.x
1692            || col >= sb_rect.x + sb_rect.width
1693            || row < sb_rect.y
1694            || row >= sb_rect.y + sb_rect.height
1695        {
1696            return None;
1697        }
1698        // Read what the renderer drew so the drag math matches what
1699        // the user sees. `suggestions_area` carries
1700        // (inner_rect, scroll_start_idx, visible_count, total_count).
1701        // Snapshot suggestions_area before borrowing the window's
1702        // prompt — `active_window_mut()` is a method call so the
1703        // compiler can't see that `prompt` and `chrome_layout` are
1704        // disjoint sub-fields.
1705        let suggestions_area_visible = self.active_chrome().suggestions_area.map(|(_, _, v, _)| v);
1706        let active_window_id = self.active_window;
1707        let prompt = self
1708            .windows
1709            .get_mut(&active_window_id)
1710            .and_then(|w| w.prompt.as_mut())?;
1711        let visible = suggestions_area_visible.unwrap_or(prompt.suggestions.len().min(10));
1712        let total = prompt.suggestions.len();
1713        let track_height = sb_rect.height as usize;
1714        let click_row = row.saturating_sub(sb_rect.y) as usize;
1715        let state = ScrollbarState::new(total, visible, prompt.scroll_offset);
1716        prompt.scroll_offset = state.click_to_offset(track_height, click_row);
1717        // Hand off to the drag follow-up so subsequent mouse moves
1718        // keep tracking the thumb.
1719        self.active_window_mut()
1720            .mouse_state
1721            .dragging_prompt_scrollbar = true;
1722        Some(Ok(()))
1723    }
1724
1725    fn handle_click_popup_scrollbar(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
1726        // Collect all needed data before mutating self.
1727        let scrollbar_info: Option<(usize, i32)> =
1728            self.active_chrome().popup_areas.iter().rev().find_map(
1729                |(popup_idx, _popup_rect, inner_rect, _scroll, _n, scrollbar_rect, total_lines)| {
1730                    let sb_rect = scrollbar_rect.as_ref()?;
1731                    if col >= sb_rect.x
1732                        && col < sb_rect.x + sb_rect.width
1733                        && row >= sb_rect.y
1734                        && row < sb_rect.y + sb_rect.height
1735                    {
1736                        let relative_row = (row - sb_rect.y) as usize;
1737                        let track_height = sb_rect.height as usize;
1738                        let visible_lines = inner_rect.height as usize;
1739                        if track_height > 0 && *total_lines > visible_lines {
1740                            let max_scroll = total_lines.saturating_sub(visible_lines);
1741                            let target = if track_height > 1 {
1742                                (relative_row * max_scroll) / (track_height.saturating_sub(1))
1743                            } else {
1744                                0
1745                            };
1746                            Some((*popup_idx, target as i32))
1747                        } else {
1748                            Some((*popup_idx, 0))
1749                        }
1750                    } else {
1751                        None
1752                    }
1753                },
1754            );
1755        let (popup_idx, target_scroll) = scrollbar_info?;
1756        self.active_window_mut()
1757            .mouse_state
1758            .dragging_popup_scrollbar = Some(popup_idx);
1759        self.active_window_mut().mouse_state.drag_start_row = Some(row);
1760        let current_scroll = self
1761            .active_state()
1762            .popups
1763            .get(popup_idx)
1764            .map(|p| p.scroll_offset)
1765            .unwrap_or(0);
1766        self.active_window_mut().mouse_state.drag_start_popup_scroll = Some(current_scroll);
1767        let state = self.active_state_mut();
1768        if let Some(popup) = state.popups.get_mut(popup_idx) {
1769            popup.scroll_by(target_scroll - current_scroll as i32);
1770        }
1771        Some(Ok(()))
1772    }
1773
1774    /// Handle every mouse event while the workspace-trust modal is up. Left
1775    /// clicks act on its controls (radio rows select + confirm; [ OK ] confirms
1776    /// the current selection; the secondary button cancels or quits); the wheel
1777    /// scrolls an overflowing dialog. Everything else is absorbed so nothing
1778    /// reaches the buffer behind the modal.
1779    fn handle_workspace_trust_mouse(
1780        &mut self,
1781        mouse_event: crossterm::event::MouseEvent,
1782    ) -> AnyhowResult<bool> {
1783        use crossterm::event::{MouseButton, MouseEventKind};
1784        let col = mouse_event.column;
1785        let row = mouse_event.row;
1786        let layout = self.active_chrome().workspace_trust_dialog.clone();
1787
1788        match mouse_event.kind {
1789            MouseEventKind::ScrollUp => {
1790                self.workspace_trust_scroll = self.workspace_trust_scroll.saturating_sub(2);
1791            }
1792            MouseEventKind::ScrollDown => {
1793                let max = layout.as_ref().map(|l| l.max_scroll).unwrap_or(0);
1794                self.workspace_trust_scroll = (self.workspace_trust_scroll + 2).min(max);
1795            }
1796            MouseEventKind::Down(MouseButton::Left) => {
1797                if let Some(layout) = layout {
1798                    let hit = |r: ratatui::layout::Rect| in_rect(col, row, r);
1799                    if hit(layout.ok) {
1800                        let idx = self.current_workspace_trust_selection();
1801                        self.confirm_workspace_trust(idx);
1802                    } else if hit(layout.quit) {
1803                        // Secondary: Cancel (close) when voluntarily opened,
1804                        // Quit (exit the editor) for the mandatory open-time gate.
1805                        self.hide_popup();
1806                        if !self.workspace_trust_prompt_cancellable {
1807                            self.should_quit = true;
1808                        }
1809                    } else if let Some(i) = layout.radios.iter().position(|r| hit(*r)) {
1810                        self.confirm_workspace_trust(i);
1811                    }
1812                    // else: click on the dialog body or dimmed backdrop — absorb.
1813                }
1814            }
1815            // Drag / move / release / right-click / horizontal scroll: absorb.
1816            _ => {}
1817        }
1818        Ok(true)
1819    }
1820
1821    fn handle_click_global_popups(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
1822        for (popup_idx, popup_rect, inner_rect, scroll_offset, num_items) in self
1823            .active_chrome()
1824            .global_popup_areas
1825            .clone()
1826            .into_iter()
1827            .rev()
1828        {
1829            if popup_rect.width >= 5 {
1830                let cb_x = popup_rect.x + popup_rect.width - 4;
1831                if row == popup_rect.y && col >= cb_x && col < cb_x + 3 {
1832                    return Some(self.handle_action(Action::PopupCancel));
1833                }
1834            }
1835            if in_rect(col, row, inner_rect) && num_items > 0 {
1836                let relative_row = (row - inner_rect.y) as usize;
1837                let item_idx = scroll_offset + relative_row;
1838                if item_idx < num_items {
1839                    if let Some(popup) = self.global_popups.get_mut(popup_idx) {
1840                        if let crate::view::popup::PopupContent::List { items: _, selected } =
1841                            &mut popup.content
1842                        {
1843                            *selected = item_idx;
1844                        }
1845                    }
1846                    return Some(self.handle_action(Action::PopupConfirm));
1847                }
1848            }
1849        }
1850        None
1851    }
1852
1853    fn handle_click_buffer_popups(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
1854        // Check close-button overlay ("[×]") on each popup.
1855        let close_hit = self.active_chrome().popup_areas.iter().rev().find_map(
1856            |(_idx, popup_rect, _inner, _scroll, _n, _sb, _tl)| {
1857                if popup_rect.width < 5 {
1858                    return None;
1859                }
1860                let cb_x = popup_rect.x + popup_rect.width - 4;
1861                if row == popup_rect.y && col >= cb_x && col < cb_x + 3 {
1862                    Some(())
1863                } else {
1864                    None
1865                }
1866            },
1867        );
1868        if close_hit.is_some() {
1869            return Some(self.handle_action(Action::PopupCancel));
1870        }
1871
1872        // Content area clicks — clone to allow &mut self calls inside the loop.
1873        let popup_areas = self.active_chrome().popup_areas.clone();
1874        for (popup_idx, _popup_rect, inner_rect, scroll_offset, num_items, _, _) in
1875            popup_areas.iter().rev()
1876        {
1877            if !in_rect(col, row, *inner_rect) {
1878                continue;
1879            }
1880            let relative_col = (col - inner_rect.x) as usize;
1881            let relative_row = (row - inner_rect.y) as usize;
1882
1883            let link_url = {
1884                let state = self.active_state();
1885                state
1886                    .popups
1887                    .top()
1888                    .and_then(|p| p.link_at_position(relative_col, relative_row))
1889            };
1890            if let Some(url) = link_url {
1891                #[cfg(feature = "runtime")]
1892                if let Err(e) = open::that(&url) {
1893                    self.set_status_message(format!("Failed to open URL: {}", e));
1894                } else {
1895                    self.set_status_message(format!("Opening: {}", url));
1896                }
1897                return Some(Ok(()));
1898            }
1899
1900            if *num_items > 0 {
1901                let item_idx = scroll_offset + relative_row;
1902                if item_idx < *num_items {
1903                    let state = self.active_state_mut();
1904                    if let Some(popup) = state.popups.top_mut() {
1905                        if let crate::view::popup::PopupContent::List { items: _, selected } =
1906                            &mut popup.content
1907                        {
1908                            *selected = item_idx;
1909                        }
1910                    }
1911                    return Some(self.handle_action(Action::PopupConfirm));
1912                }
1913            }
1914
1915            let is_text_popup = {
1916                let state = self.active_state();
1917                state.popups.top().is_some_and(|p| {
1918                    matches!(
1919                        p.content,
1920                        crate::view::popup::PopupContent::Text(_)
1921                            | crate::view::popup::PopupContent::Markdown(_)
1922                    )
1923                })
1924            };
1925            if is_text_popup {
1926                let line = scroll_offset + relative_row;
1927                let popup_idx_copy = *popup_idx;
1928                let state = self.active_state_mut();
1929                if let Some(popup) = state.popups.top_mut() {
1930                    popup.start_selection(line, relative_col);
1931                }
1932                self.active_window_mut().mouse_state.selecting_in_popup = Some(popup_idx_copy);
1933                return Some(Ok(()));
1934            }
1935        }
1936        None
1937    }
1938
1939    fn handle_click_menu_bar(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
1940        if self.active_window_mut().menu_bar_visible {
1941            // Resolve the hit before any &mut operations to avoid borrow conflicts.
1942            let hit = self
1943                .active_chrome()
1944                .menu_layout
1945                .as_ref()
1946                .and_then(|ml| ml.menu_at(col, row));
1947            let layout_exists = self.active_chrome().menu_layout.is_some();
1948            if layout_exists {
1949                if let Some(menu_idx) = hit {
1950                    if self.menu_state.active_menu == Some(menu_idx) {
1951                        self.close_menu_with_auto_hide();
1952                    } else {
1953                        self.active_window_mut().on_editor_focus_lost();
1954                        self.menu_state.open_menu(menu_idx);
1955                    }
1956                    return Some(Ok(()));
1957                } else if row == 0 {
1958                    self.close_menu_with_auto_hide();
1959                    return Some(Ok(()));
1960                }
1961            }
1962        }
1963
1964        if let Some(active_idx) = self.menu_state.active_menu {
1965            let all_menus: Vec<crate::config::Menu> = self
1966                .menus
1967                .menus
1968                .iter()
1969                .chain(self.menu_state.plugin_menus.iter())
1970                .cloned()
1971                .collect();
1972            if let Some(menu) = all_menus.get(active_idx) {
1973                match self.handle_menu_dropdown_click(col, row, menu) {
1974                    Ok(Some(click_result)) => return Some(click_result),
1975                    Ok(None) => {}
1976                    Err(e) => return Some(Err(e)),
1977                }
1978            }
1979            self.close_menu_with_auto_hide();
1980            return Some(Ok(()));
1981        }
1982
1983        None
1984    }
1985
1986    fn handle_click_file_explorer_area(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
1987        let explorer_area = self.active_layout().file_explorer_area?;
1988        let border_x = explorer_area.x + explorer_area.width.saturating_sub(1);
1989        if col == border_x && row >= explorer_area.y && row < explorer_area.y + explorer_area.height
1990        {
1991            self.active_window_mut().mouse_state.dragging_file_explorer = true;
1992            self.active_window_mut().mouse_state.drag_start_position = Some((col, row));
1993            self.active_window_mut()
1994                .mouse_state
1995                .drag_start_explorer_width = Some(self.active_window().file_explorer_width);
1996            return Some(Ok(()));
1997        }
1998        if in_rect(col, row, explorer_area) {
1999            return Some(self.handle_file_explorer_click(col, row, explorer_area));
2000        }
2001        None
2002    }
2003
2004    fn handle_click_scrollbar(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
2005        let (split_id, buffer_id, scrollbar_rect, is_on_thumb) =
2006            self.active_layout().split_areas.iter().find_map(
2007                |(split_id, buffer_id, _content, scrollbar_rect, thumb_start, thumb_end)| {
2008                    if in_rect(col, row, *scrollbar_rect) {
2009                        let relative_row = row.saturating_sub(scrollbar_rect.y) as usize;
2010                        let on_thumb = relative_row >= *thumb_start && relative_row < *thumb_end;
2011                        Some((*split_id, *buffer_id, *scrollbar_rect, on_thumb))
2012                    } else {
2013                        None
2014                    }
2015                },
2016            )?;
2017
2018        self.focus_split(split_id, buffer_id);
2019        if is_on_thumb {
2020            self.active_window_mut().mouse_state.dragging_scrollbar = Some(split_id);
2021            self.active_window_mut().mouse_state.drag_start_row = Some(row);
2022            if self.active_window().is_composite_buffer(buffer_id) {
2023                if let Some(vs) = self
2024                    .active_window()
2025                    .composite_view_states
2026                    .get(&(split_id, buffer_id))
2027                {
2028                    self.active_window_mut()
2029                        .mouse_state
2030                        .drag_start_composite_scroll_row = Some(vs.scroll_row);
2031                }
2032            } else {
2033                let snap = self
2034                    .windows
2035                    .get(&self.active_window)
2036                    .and_then(|w| w.buffers.splits())
2037                    .map(|(_, vs)| vs)
2038                    .expect("active window must have a populated split layout")
2039                    .get(&split_id)
2040                    .map(|vs| (vs.viewport.top_byte, vs.viewport.top_view_line_offset));
2041                if let Some((top_byte, top_view_line_offset)) = snap {
2042                    let ms = &mut self.active_window_mut().mouse_state;
2043                    ms.drag_start_top_byte = Some(top_byte);
2044                    ms.drag_start_view_line_offset = Some(top_view_line_offset);
2045                }
2046            }
2047        } else {
2048            self.active_window_mut().mouse_state.dragging_scrollbar = Some(split_id);
2049            if let Err(e) = self.active_window_mut().handle_scrollbar_jump(
2050                col,
2051                row,
2052                split_id,
2053                buffer_id,
2054                scrollbar_rect,
2055            ) {
2056                return Some(Err(e));
2057            }
2058            self.active_window_mut().mouse_state.hover_target =
2059                Some(HoverTarget::ScrollbarThumb(split_id));
2060        }
2061        Some(Ok(()))
2062    }
2063
2064    fn handle_click_horizontal_scrollbar(
2065        &mut self,
2066        col: u16,
2067        row: u16,
2068    ) -> Option<AnyhowResult<()>> {
2069        let (split_id, buffer_id, hscrollbar_rect, max_content_width, is_on_thumb) = self
2070            .active_layout()
2071            .horizontal_scrollbar_areas
2072            .iter()
2073            .find_map(
2074                |(
2075                    split_id,
2076                    buffer_id,
2077                    hscrollbar_rect,
2078                    max_content_width,
2079                    thumb_start,
2080                    thumb_end,
2081                )| {
2082                    if col >= hscrollbar_rect.x
2083                        && col < hscrollbar_rect.x + hscrollbar_rect.width
2084                        && row >= hscrollbar_rect.y
2085                        && row < hscrollbar_rect.y + hscrollbar_rect.height
2086                    {
2087                        let relative_col = col.saturating_sub(hscrollbar_rect.x) as usize;
2088                        let on_thumb = relative_col >= *thumb_start && relative_col < *thumb_end;
2089                        Some((
2090                            *split_id,
2091                            *buffer_id,
2092                            *hscrollbar_rect,
2093                            *max_content_width,
2094                            on_thumb,
2095                        ))
2096                    } else {
2097                        None
2098                    }
2099                },
2100            )?;
2101
2102        self.focus_split(split_id, buffer_id);
2103        self.active_window_mut()
2104            .mouse_state
2105            .dragging_horizontal_scrollbar = Some(split_id);
2106        if is_on_thumb {
2107            self.active_window_mut().mouse_state.drag_start_hcol = Some(col);
2108            if let Some(vs) = self
2109                .windows
2110                .get(&self.active_window)
2111                .and_then(|w| w.buffers.splits())
2112                .map(|(_, vs)| vs)
2113                .expect("active window must have a populated split layout")
2114                .get(&split_id)
2115            {
2116                self.active_window_mut().mouse_state.drag_start_left_column =
2117                    Some(vs.viewport.left_column);
2118            }
2119        } else {
2120            self.active_window_mut().mouse_state.drag_start_hcol = None;
2121            self.active_window_mut().mouse_state.drag_start_left_column = None;
2122            let relative_col = col.saturating_sub(hscrollbar_rect.x) as f64;
2123            let track_width = hscrollbar_rect.width as f64;
2124            let ratio = if track_width > 1.0 {
2125                (relative_col / (track_width - 1.0)).clamp(0.0, 1.0)
2126            } else {
2127                0.0
2128            };
2129            if let Some(vs) = self
2130                .windows
2131                .get_mut(&self.active_window)
2132                .and_then(|w| w.split_view_states_mut())
2133                .expect("active window must have a populated split layout")
2134                .get_mut(&split_id)
2135            {
2136                let visible_width = vs.viewport.width as usize;
2137                let max_scroll = max_content_width.saturating_sub(visible_width);
2138                let target_col = (ratio * max_scroll as f64).round() as usize;
2139                vs.viewport.left_column = target_col.min(max_scroll);
2140                vs.viewport.set_skip_ensure_visible();
2141            }
2142        }
2143        Some(Ok(()))
2144    }
2145
2146    fn handle_click_status_bar(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
2147        let (status_row, _status_x, _status_width) = self.active_chrome().status_bar_area?;
2148        if row != status_row {
2149            return None;
2150        }
2151        // Helper: dismiss any open menu-style popup (LSP-Servers, plugin
2152        // action popups, etc.) before opening a new modal UI. Without
2153        // this, clicking a different status-bar indicator while a
2154        // popup is up leaves the popup overlapping the new prompt or
2155        // picker — the user-reported #1941 follow-up.
2156        //
2157        // Skipped for the LSP indicator itself: it has its own toggle
2158        // semantics inside `show_lsp_status_popup` (second click closes
2159        // the popup), which we don't want to undermine.
2160        if let Some((r, s, e)) = self.active_chrome().status_bar_line_ending_area {
2161            if row == r && col >= s && col < e {
2162                self.dismiss_menu_popups_for_prompt();
2163                return Some(self.handle_action(Action::SetLineEnding));
2164            }
2165        }
2166        if let Some((r, s, e)) = self.active_chrome().status_bar_encoding_area {
2167            if row == r && col >= s && col < e {
2168                self.dismiss_menu_popups_for_prompt();
2169                return Some(self.handle_action(Action::SetEncoding));
2170            }
2171        }
2172        if let Some((r, s, e)) = self.active_chrome().status_bar_language_area {
2173            if row == r && col >= s && col < e {
2174                self.dismiss_menu_popups_for_prompt();
2175                return Some(self.handle_action(Action::SetLanguage));
2176            }
2177        }
2178        if let Some((r, s, e)) = self.active_chrome().status_bar_lsp_area {
2179            if row == r && col >= s && col < e {
2180                // Intentionally NOT calling `dismiss_menu_popups_for_prompt`
2181                // here — `show_lsp_status_popup` owns the toggle.
2182                return Some(self.handle_action(Action::ShowLspStatus));
2183            }
2184        }
2185        if let Some((r, s, e)) = self.active_chrome().status_bar_remote_area {
2186            if row == r && col >= s && col < e {
2187                self.dismiss_menu_popups_for_prompt();
2188                return Some(self.handle_action(Action::ShowRemoteIndicatorMenu));
2189            }
2190        }
2191        if let Some((r, s, e)) = self.active_chrome().status_bar_warning_area {
2192            if row == r && col >= s && col < e {
2193                self.dismiss_menu_popups_for_prompt();
2194                return Some(self.handle_action(Action::ShowWarnings));
2195            }
2196        }
2197        if let Some((r, s, e)) = self.active_chrome().status_bar_message_area {
2198            if row == r && col >= s && col < e {
2199                return Some(self.handle_action(Action::ShowStatusLog));
2200            }
2201        }
2202        None
2203    }
2204
2205    fn handle_click_search_options(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
2206        use crate::view::ui::status_bar::SearchOptionsHover;
2207        let layout = self.active_chrome().search_options_layout.clone()?;
2208        match layout.checkbox_at(col, row)? {
2209            SearchOptionsHover::CaseSensitive => {
2210                Some(self.handle_action(Action::ToggleSearchCaseSensitive))
2211            }
2212            SearchOptionsHover::WholeWord => {
2213                Some(self.handle_action(Action::ToggleSearchWholeWord))
2214            }
2215            SearchOptionsHover::Regex => Some(self.handle_action(Action::ToggleSearchRegex)),
2216            SearchOptionsHover::ConfirmEach => {
2217                Some(self.handle_action(Action::ToggleSearchConfirmEach))
2218            }
2219            SearchOptionsHover::None => None,
2220        }
2221    }
2222
2223    fn handle_click_split_separator(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
2224        let separator_areas = self.active_layout().separator_areas.clone();
2225        for (split_id, direction, sep_x, sep_y, sep_length) in &separator_areas {
2226            let is_on_separator = match direction {
2227                SplitDirection::Horizontal => {
2228                    row == *sep_y && col >= *sep_x && col < sep_x + sep_length
2229                }
2230                SplitDirection::Vertical => {
2231                    col == *sep_x && row >= *sep_y && row < sep_y + sep_length
2232                }
2233            };
2234            if is_on_separator {
2235                self.active_window_mut().mouse_state.dragging_separator =
2236                    Some((*split_id, *direction));
2237                self.active_window_mut().mouse_state.drag_start_position = Some((col, row));
2238                let ratio = self
2239                    .split_manager_mut()
2240                    .get_ratio((*split_id).into())
2241                    .or_else(|| self.grouped_split_ratio(*split_id));
2242                if let Some(ratio) = ratio {
2243                    self.active_window_mut().mouse_state.drag_start_ratio = Some(ratio);
2244                }
2245                return Some(Ok(()));
2246            }
2247        }
2248        None
2249    }
2250
2251    fn handle_click_split_controls(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
2252        let close_split_id = self
2253            .active_layout()
2254            .close_split_areas
2255            .iter()
2256            .find(|(_, btn_row, start_col, end_col)| {
2257                row == *btn_row && col >= *start_col && col < *end_col
2258            })
2259            .map(|(split_id, _, _, _)| *split_id);
2260        if let Some(split_id) = close_split_id {
2261            if let Err(e) = self
2262                .windows
2263                .get_mut(&self.active_window)
2264                .and_then(|w| w.split_manager_mut())
2265                .expect("active window must have a populated split layout")
2266                .close_split(split_id)
2267            {
2268                self.set_status_message(
2269                    t!("error.cannot_close_split", error = e.to_string()).to_string(),
2270                );
2271            } else {
2272                let new_active = self
2273                    .windows
2274                    .get(&self.active_window)
2275                    .and_then(|w| w.buffers.splits())
2276                    .map(|(mgr, _)| mgr)
2277                    .expect("active window must have a populated split layout")
2278                    .active_split();
2279                if let Some(buffer_id) = self
2280                    .windows
2281                    .get(&self.active_window)
2282                    .and_then(|w| w.buffers.splits())
2283                    .map(|(mgr, _)| mgr)
2284                    .expect("active window must have a populated split layout")
2285                    .buffer_for_split(new_active)
2286                {
2287                    self.set_active_buffer(buffer_id);
2288                }
2289                self.set_status_message(t!("split.closed").to_string());
2290            }
2291            return Some(Ok(()));
2292        }
2293
2294        let maximize_target = self
2295            .active_layout()
2296            .maximize_split_areas
2297            .iter()
2298            .find(|(_, btn_row, start_col, end_col)| {
2299                row == *btn_row && col >= *start_col && col < *end_col
2300            })
2301            .map(|(split_id, _, _, _)| *split_id);
2302        if let Some(target) = maximize_target {
2303            // Move focus to the clicked split before maximizing. Otherwise
2304            // a click on a non-active split's button leaves the active
2305            // split (now hidden by the maximize) silently capturing
2306            // keystrokes. Skip when already maximized: the unmaximize
2307            // click can only land on the maximized split, which is
2308            // already the active one.
2309            let already_maximized = self
2310                .windows
2311                .get(&self.active_window)
2312                .and_then(|w| w.buffers.splits())
2313                .map(|(mgr, _)| mgr.is_maximized())
2314                .unwrap_or(false);
2315            if !already_maximized {
2316                if let Some(buffer_id) = self
2317                    .windows
2318                    .get(&self.active_window)
2319                    .and_then(|w| w.buffers.splits())
2320                    .map(|(mgr, _)| mgr)
2321                    .expect("active window must have a populated split layout")
2322                    .buffer_for_split(target)
2323                {
2324                    self.focus_split(target, buffer_id);
2325                }
2326            }
2327            match self
2328                .windows
2329                .get_mut(&self.active_window)
2330                .and_then(|w| w.split_manager_mut())
2331                .expect("active window must have a populated split layout")
2332                .toggle_maximize_for(target)
2333            {
2334                Ok(maximized) => {
2335                    let msg = if maximized {
2336                        t!("split.maximized").to_string()
2337                    } else {
2338                        t!("split.restored").to_string()
2339                    };
2340                    self.set_status_message(msg);
2341                }
2342                Err(e) => self.set_status_message(e),
2343            }
2344            return Some(Ok(()));
2345        }
2346
2347        None
2348    }
2349
2350    fn handle_click_tab_bar(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
2351        for (split_id, tab_layout) in &self.active_layout().tab_layouts {
2352            tracing::debug!(
2353                "Tab layout for split {:?}: bar_area={:?}, left_scroll={:?}, right_scroll={:?}",
2354                split_id,
2355                tab_layout.bar_area,
2356                tab_layout.left_scroll_area,
2357                tab_layout.right_scroll_area
2358            );
2359        }
2360        let tab_hit = self
2361            .active_layout()
2362            .tab_layouts
2363            .iter()
2364            .find_map(|(split_id, tab_layout)| {
2365                let hit = tab_layout.hit_test(col, row);
2366                tracing::debug!(
2367                    "Tab hit_test at ({}, {}) for split {:?} returned {:?}",
2368                    col,
2369                    row,
2370                    split_id,
2371                    hit
2372                );
2373                hit.map(|h| (*split_id, h))
2374            });
2375        let (split_id, hit) = tab_hit?;
2376        match hit {
2377            TabHit::CloseButton(target) => {
2378                match target {
2379                    crate::view::split::TabTarget::Buffer(buffer_id) => {
2380                        self.focus_split(split_id, buffer_id);
2381                        self.close_tab_in_split(buffer_id, split_id);
2382                    }
2383                    crate::view::split::TabTarget::Group(group_leaf) => {
2384                        self.close_buffer_group_by_leaf(group_leaf);
2385                    }
2386                }
2387                Some(Ok(()))
2388            }
2389            TabHit::TabName(target) => {
2390                let direction = self
2391                    .windows
2392                    .get(&self.active_window)
2393                    .and_then(|w| w.buffers.splits())
2394                    .map(|(_, vs)| vs)
2395                    .expect("active window must have a populated split layout")
2396                    .get(&split_id)
2397                    .map(|vs| {
2398                        let open = &vs.open_buffers;
2399                        let cur = vs.active_target();
2400                        let cur_idx = open.iter().position(|t| *t == cur);
2401                        let new_idx = open.iter().position(|t| *t == target);
2402                        match (cur_idx, new_idx) {
2403                            (Some(c), Some(n)) if n > c => 1,
2404                            (Some(c), Some(n)) if n < c => -1,
2405                            _ => 0,
2406                        }
2407                    })
2408                    .unwrap_or(0);
2409                self.active_window_mut()
2410                    .animate_tab_switch(split_id, direction);
2411                match target {
2412                    crate::view::split::TabTarget::Buffer(buffer_id) => {
2413                        self.focus_split(split_id, buffer_id);
2414                        self.active_window_mut()
2415                            .promote_buffer_from_preview(buffer_id);
2416                        self.active_window_mut().mouse_state.dragging_tab = Some(
2417                            super::types::TabDragState::new(buffer_id, split_id, (col, row)),
2418                        );
2419                    }
2420                    crate::view::split::TabTarget::Group(group_leaf) => {
2421                        self.activate_group_tab(split_id, group_leaf);
2422                    }
2423                }
2424                Some(Ok(()))
2425            }
2426            TabHit::ScrollLeft => {
2427                self.set_status_message("ScrollLeft clicked!".to_string());
2428                if let Some(vs) = self
2429                    .windows
2430                    .get_mut(&self.active_window)
2431                    .and_then(|w| w.split_view_states_mut())
2432                    .expect("active window must have a populated split layout")
2433                    .get_mut(&split_id)
2434                {
2435                    vs.tab_scroll_offset = vs.tab_scroll_offset.saturating_sub(10);
2436                }
2437                Some(Ok(()))
2438            }
2439            TabHit::ScrollRight => {
2440                self.set_status_message("ScrollRight clicked!".to_string());
2441                if let Some(vs) = self
2442                    .windows
2443                    .get_mut(&self.active_window)
2444                    .and_then(|w| w.split_view_states_mut())
2445                    .expect("active window must have a populated split layout")
2446                    .get_mut(&split_id)
2447                {
2448                    vs.tab_scroll_offset = vs.tab_scroll_offset.saturating_add(10);
2449                }
2450                Some(Ok(()))
2451            }
2452            TabHit::BarBackground => None,
2453        }
2454    }
2455
2456    /// Handle mouse drag event
2457    pub(super) fn handle_mouse_drag(&mut self, col: u16, row: u16) -> AnyhowResult<()> {
2458        // Floating-panel list scrollbar drag takes precedence — the
2459        // modal panel owns the input channel while it's up.
2460        if self.try_widget_scrollbar_drag(row) {
2461            let _ = col;
2462            return Ok(());
2463        }
2464        // Mouse-modal overlay: the only legitimate drag is on the overlay's
2465        // own result-list scrollbar (its drag flag was set on mouse-down).
2466        // Anything else — text-selection drag in the buffer, a buffer
2467        // scrollbar behind the overlay — is swallowed so the buffer stays put.
2468        if self.overlay_prompt_active()
2469            && !self.active_window().mouse_state.dragging_prompt_scrollbar
2470        {
2471            return Ok(());
2472        }
2473
2474        // If dragging scrollbar, update scroll position
2475        if let Some(dragging_split_id) = self.active_window_mut().mouse_state.dragging_scrollbar {
2476            // Snapshot split_areas so we don't borrow `self.active_layout()` and
2477            // `self.active_window_mut()` simultaneously below.
2478            let split_areas = self.active_layout().split_areas.clone();
2479            for (split_id, buffer_id, _content_rect, scrollbar_rect, _thumb_start, _thumb_end) in
2480                &split_areas
2481            {
2482                if *split_id == dragging_split_id {
2483                    // Check if we started dragging from the thumb (have drag_start_row)
2484                    if self.active_window().mouse_state.drag_start_row.is_some() {
2485                        // Relative drag from thumb
2486                        self.active_window_mut().handle_scrollbar_drag_relative(
2487                            row,
2488                            *split_id,
2489                            *buffer_id,
2490                            *scrollbar_rect,
2491                        )?;
2492                    } else {
2493                        // Jump drag (started from track)
2494                        self.active_window_mut().handle_scrollbar_jump(
2495                            col,
2496                            row,
2497                            *split_id,
2498                            *buffer_id,
2499                            *scrollbar_rect,
2500                        )?;
2501                    }
2502                    return Ok(());
2503                }
2504            }
2505        }
2506
2507        // If dragging horizontal scrollbar, update horizontal scroll position
2508        if let Some(dragging_split_id) = self
2509            .active_window_mut()
2510            .mouse_state
2511            .dragging_horizontal_scrollbar
2512        {
2513            // Clone the scrollbar layout so the loop doesn't hold an
2514            // immutable borrow on `self` while it mutates
2515            // `self.split_view_states`. The active window's layout cache
2516            // is repopulated each frame, so a one-frame snapshot is fine.
2517            let hscrollbar_areas = self.active_layout().horizontal_scrollbar_areas.clone();
2518            for (
2519                split_id,
2520                _buffer_id,
2521                hscrollbar_rect,
2522                max_content_width,
2523                thumb_start,
2524                thumb_end,
2525            ) in &hscrollbar_areas
2526            {
2527                if *split_id == dragging_split_id {
2528                    let track_width = hscrollbar_rect.width as f64;
2529                    if track_width <= 1.0 {
2530                        break;
2531                    }
2532
2533                    if let (Some(drag_start_hcol), Some(drag_start_left_column)) = (
2534                        self.active_window_mut().mouse_state.drag_start_hcol,
2535                        self.active_window_mut().mouse_state.drag_start_left_column,
2536                    ) {
2537                        // Relative drag from thumb - move proportionally to mouse offset
2538                        // Use thumb size to compute the correct ratio so thumb tracks with mouse
2539                        let col_offset = (col as i32) - (drag_start_hcol as i32);
2540                        if let Some(view_state) = self
2541                            .windows
2542                            .get_mut(&self.active_window)
2543                            .and_then(|w| w.split_view_states_mut())
2544                            .expect("active window must have a populated split layout")
2545                            .get_mut(&dragging_split_id)
2546                        {
2547                            let visible_width = view_state.viewport.width as usize;
2548                            let max_scroll = max_content_width.saturating_sub(visible_width);
2549                            if max_scroll > 0 {
2550                                let thumb_size = thumb_end.saturating_sub(*thumb_start).max(1);
2551                                let track_travel = (track_width - thumb_size as f64).max(1.0);
2552                                let scroll_per_pixel = max_scroll as f64 / track_travel;
2553                                let scroll_offset =
2554                                    (col_offset as f64 * scroll_per_pixel).round() as i64;
2555                                let new_left =
2556                                    (drag_start_left_column as i64 + scroll_offset).max(0) as usize;
2557                                view_state.viewport.left_column = new_left.min(max_scroll);
2558                                view_state.viewport.set_skip_ensure_visible();
2559                            }
2560                        }
2561                    } else {
2562                        // Jump drag (started from track) - jump to absolute position
2563                        let relative_col = col.saturating_sub(hscrollbar_rect.x) as f64;
2564                        let ratio = (relative_col / (track_width - 1.0)).clamp(0.0, 1.0);
2565
2566                        if let Some(view_state) = self
2567                            .windows
2568                            .get_mut(&self.active_window)
2569                            .and_then(|w| w.split_view_states_mut())
2570                            .expect("active window must have a populated split layout")
2571                            .get_mut(&dragging_split_id)
2572                        {
2573                            let visible_width = view_state.viewport.width as usize;
2574                            let max_scroll = max_content_width.saturating_sub(visible_width);
2575                            let target_col = (ratio * max_scroll as f64).round() as usize;
2576                            view_state.viewport.left_column = target_col.min(max_scroll);
2577                            view_state.viewport.set_skip_ensure_visible();
2578                        }
2579                    }
2580
2581                    return Ok(());
2582                }
2583            }
2584        }
2585
2586        // If selecting text in popup, extend selection
2587        if let Some(popup_idx) = self.active_window_mut().mouse_state.selecting_in_popup {
2588            // Find the popup area from cached layout
2589            if let Some((_, _, inner_rect, scroll_offset, _, _, _)) = self
2590                .active_chrome()
2591                .popup_areas
2592                .iter()
2593                .find(|(idx, _, _, _, _, _, _)| *idx == popup_idx)
2594            {
2595                // Check if mouse is within the popup inner area
2596                if col >= inner_rect.x
2597                    && col < inner_rect.x + inner_rect.width
2598                    && row >= inner_rect.y
2599                    && row < inner_rect.y + inner_rect.height
2600                {
2601                    let relative_col = (col - inner_rect.x) as usize;
2602                    let relative_row = (row - inner_rect.y) as usize;
2603                    let line = scroll_offset + relative_row;
2604
2605                    let state = self.active_state_mut();
2606                    if let Some(popup) = state.popups.get_mut(popup_idx) {
2607                        popup.extend_selection(line, relative_col);
2608                    }
2609                }
2610            }
2611            return Ok(());
2612        }
2613
2614        // If dragging the floating-overlay prompt's scrollbar
2615        // (issue #1796), update its scroll_offset using the same
2616        // math as the click handler. Same shared-widget logic the
2617        // popup-scrollbar drag uses below.
2618        if self
2619            .active_window_mut()
2620            .mouse_state
2621            .dragging_prompt_scrollbar
2622        {
2623            use crate::view::ui::scrollbar::ScrollbarState;
2624            // Snapshot chrome rects up front so the prompt borrow on
2625            // active_window_mut() doesn't conflict.
2626            let sb_rect = self.active_chrome().suggestions_scrollbar_rect;
2627            let suggestions_area_visible =
2628                self.active_chrome().suggestions_area.map(|(_, _, v, _)| v);
2629            let active_window_id = self.active_window;
2630            if let (Some(sb_rect), Some(prompt)) = (
2631                sb_rect,
2632                self.windows
2633                    .get_mut(&active_window_id)
2634                    .and_then(|w| w.prompt.as_mut()),
2635            ) {
2636                let visible = suggestions_area_visible.unwrap_or(prompt.suggestions.len().min(10));
2637                let total = prompt.suggestions.len();
2638                let track_height = sb_rect.height as usize;
2639                // Allow dragging slightly past the top/bottom; clamp
2640                // here rather than rejecting so the thumb keeps up
2641                // with a fast mouse.
2642                let clamped_row =
2643                    row.clamp(sb_rect.y, sb_rect.y + sb_rect.height.saturating_sub(1));
2644                let click_row = clamped_row.saturating_sub(sb_rect.y) as usize;
2645                let state = ScrollbarState::new(total, visible, prompt.scroll_offset);
2646                prompt.scroll_offset = state.click_to_offset(track_height, click_row);
2647            }
2648            return Ok(());
2649        }
2650
2651        // If dragging popup scrollbar, update popup scroll position
2652        if let Some(popup_idx) = self
2653            .active_window_mut()
2654            .mouse_state
2655            .dragging_popup_scrollbar
2656        {
2657            // Find the popup's scrollbar rect from cached layout
2658            if let Some((_, _, inner_rect, _, _, Some(sb_rect), total_lines)) = self
2659                .active_chrome()
2660                .popup_areas
2661                .iter()
2662                .find(|(idx, _, _, _, _, _, _)| *idx == popup_idx)
2663            {
2664                let track_height = sb_rect.height as usize;
2665                let visible_lines = inner_rect.height as usize;
2666
2667                if track_height > 0 && *total_lines > visible_lines {
2668                    let relative_row = row.saturating_sub(sb_rect.y) as usize;
2669                    let max_scroll = total_lines.saturating_sub(visible_lines);
2670                    let target_scroll = if track_height > 1 {
2671                        (relative_row * max_scroll) / (track_height.saturating_sub(1))
2672                    } else {
2673                        0
2674                    };
2675
2676                    let state = self.active_state_mut();
2677                    if let Some(popup) = state.popups.get_mut(popup_idx) {
2678                        let current_scroll = popup.scroll_offset as i32;
2679                        let delta = target_scroll as i32 - current_scroll;
2680                        popup.scroll_by(delta);
2681                    }
2682                }
2683            }
2684            return Ok(());
2685        }
2686
2687        // If dragging separator, update split ratio
2688        if let Some((split_id, direction)) = self.active_window_mut().mouse_state.dragging_separator
2689        {
2690            self.handle_separator_drag(col, row, split_id, direction)?;
2691            return Ok(());
2692        }
2693
2694        // If dragging file explorer border, update width
2695        if self.active_window_mut().mouse_state.dragging_file_explorer {
2696            self.handle_file_explorer_border_drag(col)?;
2697            return Ok(());
2698        }
2699
2700        // If dragging to select text
2701        if self.active_window_mut().mouse_state.dragging_text_selection {
2702            self.handle_text_selection_drag(col, row)?;
2703            return Ok(());
2704        }
2705
2706        // If dragging a tab, update position and compute drop zone
2707        if self.active_window_mut().mouse_state.dragging_tab.is_some() {
2708            self.handle_tab_drag(col, row)?;
2709            return Ok(());
2710        }
2711
2712        Ok(())
2713    }
2714
2715    /// Handle text selection drag - extends selection from anchor to current position
2716    fn handle_text_selection_drag(&mut self, col: u16, row: u16) -> AnyhowResult<()> {
2717        use crate::model::event::Event;
2718        use crate::primitives::word_navigation::{find_word_end, find_word_start};
2719
2720        let Some(split_id) = self.active_window_mut().mouse_state.drag_selection_split else {
2721            return Ok(());
2722        };
2723        let Some(anchor_position) = self.active_window_mut().mouse_state.drag_selection_anchor
2724        else {
2725            return Ok(());
2726        };
2727
2728        // Find the buffer and content rect for this split in one pass
2729        let Some((buffer_id, content_rect)) = self
2730            .active_layout()
2731            .split_areas
2732            .iter()
2733            .find(|(sid, _, _, _, _, _)| *sid == split_id)
2734            .map(|(_, bid, rect, _, _, _)| (*bid, *rect))
2735        else {
2736            return Ok(());
2737        };
2738
2739        // Get cached view line mappings for this split
2740        let cached_mappings = self
2741            .active_layout()
2742            .view_line_mappings
2743            .get(&split_id)
2744            .cloned();
2745
2746        let leaf_id = split_id;
2747
2748        // Get fallback from SplitViewState viewport
2749        let fallback = self
2750            .windows
2751            .get(&self.active_window)
2752            .and_then(|w| w.buffers.splits())
2753            .map(|(_, vs)| vs)
2754            .expect("active window must have a populated split layout")
2755            .get(&leaf_id)
2756            .map(|vs| vs.viewport.top_byte)
2757            .unwrap_or(0);
2758
2759        // Get compose width for this split
2760        let compose_width = self
2761            .windows
2762            .get(&self.active_window)
2763            .and_then(|w| w.buffers.splits())
2764            .map(|(_, vs)| vs)
2765            .expect("active window must have a populated split layout")
2766            .get(&leaf_id)
2767            .and_then(|vs| vs.compose_width);
2768
2769        // Calculate the target position and selection geometry by
2770        // reading buffer state directly, then dispatch the move via
2771        // Window helpers.
2772        let drag_by_words = self.active_window_mut().mouse_state.drag_selection_by_words;
2773        let drag_word_end = self.active_window_mut().mouse_state.drag_selection_word_end;
2774
2775        let Some((target_position, new_position, anchor_position, new_sticky_column)) = self
2776            .active_window()
2777            .buffers
2778            .get(&buffer_id)
2779            .and_then(|state| {
2780                let gutter_width = state.margins.left_total_width() as u16;
2781                let target_position = super::click_geometry::screen_to_buffer_position(
2782                    col,
2783                    row,
2784                    content_rect,
2785                    gutter_width,
2786                    &cached_mappings,
2787                    fallback,
2788                    true, // Allow gutter clicks for drag selection
2789                    compose_width,
2790                )?;
2791                let (new_position, anchor_pos) = if drag_by_words {
2792                    if target_position >= anchor_position {
2793                        (
2794                            find_word_end(&state.buffer, target_position),
2795                            anchor_position,
2796                        )
2797                    } else {
2798                        let word_end = drag_word_end.unwrap_or(anchor_position);
2799                        (find_word_start(&state.buffer, target_position), word_end)
2800                    }
2801                } else {
2802                    (target_position, anchor_position)
2803                };
2804                let new_sticky_column = state
2805                    .buffer
2806                    .offset_to_position(new_position)
2807                    .map(|pos| pos.column);
2808                Some((target_position, new_position, anchor_pos, new_sticky_column))
2809            })
2810        else {
2811            return Ok(());
2812        };
2813        let _ = target_position;
2814
2815        let (primary_cursor_id, old_position, old_anchor, old_sticky_column) = self
2816            .active_window()
2817            .buffers
2818            .splits()
2819            .and_then(|(_, vs)| vs.get(&leaf_id))
2820            .map(|vs| {
2821                let cursor = vs.cursors.primary();
2822                (
2823                    vs.cursors.primary_id(),
2824                    cursor.position,
2825                    cursor.anchor,
2826                    cursor.sticky_column,
2827                )
2828            })
2829            .unwrap_or((CursorId(0), 0, None, 0));
2830
2831        let event = Event::MoveCursor {
2832            cursor_id: primary_cursor_id,
2833            old_position,
2834            new_position,
2835            old_anchor,
2836            new_anchor: Some(anchor_position),
2837            old_sticky_column,
2838            new_sticky_column: new_sticky_column.unwrap_or(old_sticky_column),
2839        };
2840
2841        if let Some(event_log) = self.active_window_mut().event_logs.get_mut(&buffer_id) {
2842            event_log.append(event.clone());
2843        }
2844        self.active_window_mut()
2845            .apply_event_to_buffer(buffer_id, leaf_id, &event);
2846
2847        Ok(())
2848    }
2849
2850    /// Handle file explorer border drag for resizing
2851    pub(super) fn handle_file_explorer_border_drag(&mut self, col: u16) -> AnyhowResult<()> {
2852        let Some((start_col, _start_row)) =
2853            self.active_window_mut().mouse_state.drag_start_position
2854        else {
2855            return Ok(());
2856        };
2857        let Some(start_width) = self
2858            .active_window_mut()
2859            .mouse_state
2860            .drag_start_explorer_width
2861        else {
2862            return Ok(());
2863        };
2864
2865        let delta = col as i32 - start_col as i32;
2866        let total_width = self.terminal_width as i32;
2867
2868        // Drag preserves the variant the user chose. A user editing
2869        // columns doesn't want their mode silently flipped to percent
2870        // just because they grabbed the divider.
2871        if total_width > 0 {
2872            use crate::config::ExplorerWidth;
2873            self.active_window_mut().file_explorer_width = match start_width {
2874                ExplorerWidth::Percent(start_pct) => {
2875                    let percent_delta = (delta * 100) / total_width;
2876                    let new_pct = (start_pct as i32 + percent_delta).clamp(0, 100) as u8;
2877                    ExplorerWidth::Percent(new_pct)
2878                }
2879                ExplorerWidth::Columns(start_cols) => {
2880                    let new_cols = (start_cols as i32 + delta).clamp(0, total_width) as u16;
2881                    ExplorerWidth::Columns(new_cols)
2882                }
2883            };
2884        }
2885
2886        Ok(())
2887    }
2888
2889    /// Handle separator drag for split resizing
2890    pub(super) fn handle_separator_drag(
2891        &mut self,
2892        col: u16,
2893        row: u16,
2894        split_id: ContainerId,
2895        direction: SplitDirection,
2896    ) -> AnyhowResult<()> {
2897        let Some((start_col, start_row)) = self.active_window_mut().mouse_state.drag_start_position
2898        else {
2899            return Ok(());
2900        };
2901        let Some(start_ratio) = self.active_window_mut().mouse_state.drag_start_ratio else {
2902            return Ok(());
2903        };
2904        let Some(editor_area) = self.active_layout().editor_content_area else {
2905            return Ok(());
2906        };
2907
2908        // Calculate the delta in screen space
2909        let (delta, total_size) = match direction {
2910            SplitDirection::Horizontal => {
2911                // For horizontal splits, we move the separator up/down (row changes)
2912                let delta = row as i32 - start_row as i32;
2913                let total = editor_area.height as i32;
2914                (delta, total)
2915            }
2916            SplitDirection::Vertical => {
2917                // For vertical splits, we move the separator left/right (col changes)
2918                let delta = col as i32 - start_col as i32;
2919                let total = editor_area.width as i32;
2920                (delta, total)
2921            }
2922        };
2923
2924        // Convert screen delta to ratio delta
2925        // The ratio represents the fraction of space the first split gets
2926        if total_size > 0 {
2927            let ratio_delta = delta as f32 / total_size as f32;
2928            let new_ratio = (start_ratio + ratio_delta).clamp(0.1, 0.9);
2929
2930            // Update the split ratio. The container may live in the main
2931            // split tree or inside a stashed Grouped subtree (buffer group
2932            // panels like the theme editor); try the main tree first and
2933            // fall back to the grouped subtrees.
2934            if self
2935                .windows
2936                .get(&self.active_window)
2937                .and_then(|w| w.buffers.splits())
2938                .map(|(mgr, _)| mgr)
2939                .expect("active window must have a populated split layout")
2940                .get_ratio(split_id.into())
2941                .is_some()
2942            {
2943                self.windows
2944                    .get_mut(&self.active_window)
2945                    .and_then(|w| w.split_manager_mut())
2946                    .expect("active window must have a populated split layout")
2947                    .set_ratio(split_id, new_ratio);
2948            } else {
2949                self.set_grouped_split_ratio(split_id, new_ratio);
2950            }
2951        }
2952
2953        Ok(())
2954    }
2955
2956    /// Handle right-click event
2957    pub(super) fn handle_right_click(&mut self, col: u16, row: u16) -> AnyhowResult<()> {
2958        let frame_w = self.active_chrome().last_frame_width;
2959        let frame_h = self.active_chrome().last_frame_height;
2960        if let Some(ref menu) = self.active_window().file_explorer_context_menu {
2961            let (menu_x, menu_y) = menu.clamped_position(frame_w, frame_h);
2962            let menu_width = super::types::FILE_EXPLORER_CONTEXT_MENU_WIDTH;
2963            let menu_height = menu.height();
2964            if col >= menu_x
2965                && col < menu_x + menu_width
2966                && row >= menu_y
2967                && row < menu_y + menu_height
2968            {
2969                return Ok(());
2970            }
2971        }
2972
2973        // First check if a tab context menu is open and the click is on a menu item
2974        if let Some(ref menu) = self.active_window_mut().tab_context_menu {
2975            let menu_x = menu.position.0;
2976            let menu_y = menu.position.1;
2977            let menu_width = 22u16; // "Close to the Right" + padding
2978            let menu_height = super::types::TabContextMenuItem::all().len() as u16 + 2; // items + borders
2979
2980            // Check if click is inside the menu
2981            if col >= menu_x
2982                && col < menu_x + menu_width
2983                && row >= menu_y
2984                && row < menu_y + menu_height
2985            {
2986                // Click inside menu - let left-click handler deal with it
2987                return Ok(());
2988            }
2989        }
2990
2991        if let Some(explorer_area) = self.active_layout().file_explorer_area {
2992            if col >= explorer_area.x
2993                && col < explorer_area.x + explorer_area.width
2994                && row < explorer_area.y + explorer_area.height
2995                && row > explorer_area.y
2996            // skip title row
2997            {
2998                let relative_row = row.saturating_sub(explorer_area.y + 1);
2999                let (is_multi, is_root_selected) =
3000                    if let Some(explorer) = self.file_explorer_mut().as_mut() {
3001                        let display_nodes = explorer.get_display_nodes();
3002                        let scroll_offset = explorer.get_scroll_offset();
3003                        let clicked_index = (relative_row as usize) + scroll_offset;
3004                        let mut clicked_is_root = false;
3005                        if clicked_index < display_nodes.len() {
3006                            let (node_id, _) = display_nodes[clicked_index];
3007                            explorer.set_selected(Some(node_id));
3008                            clicked_is_root = node_id == explorer.tree().root_id();
3009                        }
3010                        (explorer.has_multi_selection(), clicked_is_root)
3011                    } else {
3012                        (false, false)
3013                    };
3014                self.active_window_mut().key_context =
3015                    crate::input::keybindings::KeyContext::FileExplorer;
3016                self.active_window_mut().tab_context_menu = None;
3017                self.active_window_mut().file_explorer_context_menu =
3018                    Some(super::types::FileExplorerContextMenu::new(
3019                        col,
3020                        row + 1,
3021                        is_multi,
3022                        is_root_selected,
3023                    ));
3024                return Ok(());
3025            }
3026        }
3027
3028        self.active_window_mut().file_explorer_context_menu = None;
3029
3030        // Check if right-click is on a tab
3031        let tab_hit = self
3032            .active_layout()
3033            .tab_layouts
3034            .iter()
3035            .find_map(
3036                |(split_id, tab_layout)| match tab_layout.hit_test(col, row) {
3037                    Some(TabHit::TabName(target) | TabHit::CloseButton(target)) => {
3038                        // Context menu only makes sense for buffer tabs; groups are
3039                        // plugin-managed and closed via the close button.
3040                        target.as_buffer().map(|bid| (*split_id, bid))
3041                    }
3042                    _ => None,
3043                },
3044            );
3045
3046        if let Some((split_id, buffer_id)) = tab_hit {
3047            // Open tab context menu
3048            self.active_window_mut().tab_context_menu =
3049                Some(TabContextMenu::new(buffer_id, split_id, col, row + 1));
3050        } else {
3051            // Click outside tab - close context menu if open
3052            self.active_window_mut().tab_context_menu = None;
3053        }
3054
3055        Ok(())
3056    }
3057
3058    /// Handle left-click on tab context menu
3059    pub(super) fn handle_tab_context_menu_click(
3060        &mut self,
3061        col: u16,
3062        row: u16,
3063    ) -> Option<AnyhowResult<()>> {
3064        let menu = self.active_window_mut().tab_context_menu.as_ref()?;
3065        let menu_x = menu.position.0;
3066        let menu_y = menu.position.1;
3067        let menu_width = 22u16;
3068        let items = super::types::TabContextMenuItem::all();
3069        let menu_height = items.len() as u16 + 2; // items + borders
3070
3071        // Check if click is inside the menu area
3072        if col < menu_x || col >= menu_x + menu_width || row < menu_y || row >= menu_y + menu_height
3073        {
3074            // Click outside menu - close it
3075            self.active_window_mut().tab_context_menu = None;
3076            return Some(Ok(()));
3077        }
3078
3079        // Check if click is on the border (first or last row)
3080        if row == menu_y || row == menu_y + menu_height - 1 {
3081            return Some(Ok(()));
3082        }
3083
3084        // Calculate which item was clicked (accounting for border)
3085        let item_idx = (row - menu_y - 1) as usize;
3086        if item_idx >= items.len() {
3087            return Some(Ok(()));
3088        }
3089
3090        // Get the menu state before closing it
3091        let buffer_id = menu.buffer_id;
3092        let split_id = menu.split_id;
3093        let item = items[item_idx];
3094
3095        // Close the menu
3096        self.active_window_mut().tab_context_menu = None;
3097
3098        // Execute the action
3099        Some(self.execute_tab_context_menu_action(item, buffer_id, split_id))
3100    }
3101
3102    /// Execute a tab context menu action
3103    fn execute_tab_context_menu_action(
3104        &mut self,
3105        item: super::types::TabContextMenuItem,
3106        buffer_id: BufferId,
3107        leaf_id: LeafId,
3108    ) -> AnyhowResult<()> {
3109        use super::types::TabContextMenuItem;
3110        match item {
3111            TabContextMenuItem::Close => {
3112                self.close_tab_in_split(buffer_id, leaf_id);
3113            }
3114            TabContextMenuItem::CloseOthers => {
3115                self.close_other_tabs_in_split(buffer_id, leaf_id);
3116            }
3117            TabContextMenuItem::CloseToRight => {
3118                self.close_tabs_to_right_in_split(buffer_id, leaf_id);
3119            }
3120            TabContextMenuItem::CloseToLeft => {
3121                self.close_tabs_to_left_in_split(buffer_id, leaf_id);
3122            }
3123            TabContextMenuItem::CloseAll => {
3124                self.close_all_tabs_in_split(leaf_id);
3125            }
3126            TabContextMenuItem::CopyRelativePath => {
3127                self.copy_buffer_path(buffer_id, true);
3128            }
3129            TabContextMenuItem::CopyFullPath => {
3130                self.copy_buffer_path(buffer_id, false);
3131            }
3132        }
3133
3134        Ok(())
3135    }
3136
3137    /// Handle keyboard navigation for the file explorer context menu.
3138    /// Returns `Some` if the key was consumed, `None` to let normal dispatch continue.
3139    pub(super) fn handle_file_explorer_context_menu_key(
3140        &mut self,
3141        code: crossterm::event::KeyCode,
3142        modifiers: crossterm::event::KeyModifiers,
3143    ) -> Option<AnyhowResult<()>> {
3144        use crossterm::event::KeyCode;
3145        use crossterm::event::KeyModifiers;
3146
3147        if modifiers != KeyModifiers::NONE {
3148            return None;
3149        }
3150
3151        match code {
3152            KeyCode::Up => {
3153                if let Some(ref mut menu) = self.active_window_mut().file_explorer_context_menu {
3154                    menu.prev_item();
3155                }
3156                Some(Ok(()))
3157            }
3158            KeyCode::Down => {
3159                if let Some(ref mut menu) = self.active_window_mut().file_explorer_context_menu {
3160                    menu.next_item();
3161                }
3162                Some(Ok(()))
3163            }
3164            KeyCode::Enter => {
3165                let item = {
3166                    let menu = self
3167                        .active_window_mut()
3168                        .file_explorer_context_menu
3169                        .as_ref()?;
3170                    menu.items()[menu.highlighted]
3171                };
3172                self.active_window_mut().file_explorer_context_menu = None;
3173                self.execute_file_explorer_context_menu_action(item);
3174                Some(Ok(()))
3175            }
3176            KeyCode::Esc => {
3177                self.active_window_mut().file_explorer_context_menu = None;
3178                Some(Ok(()))
3179            }
3180            _ => None,
3181        }
3182    }
3183
3184    /// Handle left-click on the file explorer context menu
3185    pub(super) fn handle_file_explorer_context_menu_click(
3186        &mut self,
3187        col: u16,
3188        row: u16,
3189    ) -> Option<AnyhowResult<()>> {
3190        // Extract all needed values while the immutable borrow is live, then mutate.
3191        let frame_w = self.active_chrome().last_frame_width;
3192        let frame_h = self.active_chrome().last_frame_height;
3193        let clicked_item: Option<super::types::FileExplorerContextMenuItem> = {
3194            let menu = self.active_window().file_explorer_context_menu.as_ref()?;
3195            let (menu_x, menu_y) = menu.clamped_position(frame_w, frame_h);
3196            let menu_width = super::types::FILE_EXPLORER_CONTEXT_MENU_WIDTH;
3197            let menu_height = menu.height();
3198
3199            if col < menu_x
3200                || col >= menu_x + menu_width
3201                || row < menu_y
3202                || row >= menu_y + menu_height
3203            {
3204                self.active_window_mut().file_explorer_context_menu = None;
3205                return Some(Ok(()));
3206            }
3207
3208            if row == menu_y || row == menu_y + menu_height - 1 {
3209                return Some(Ok(()));
3210            }
3211
3212            let item_idx = (row - menu_y - 1) as usize;
3213            menu.items().get(item_idx).copied()
3214        };
3215
3216        self.active_window_mut().file_explorer_context_menu = None;
3217        if let Some(item) = clicked_item {
3218            self.execute_file_explorer_context_menu_action(item);
3219        }
3220        Some(Ok(()))
3221    }
3222
3223    fn execute_file_explorer_context_menu_action(
3224        &mut self,
3225        item: super::types::FileExplorerContextMenuItem,
3226    ) {
3227        use super::types::FileExplorerContextMenuItem;
3228        match item {
3229            FileExplorerContextMenuItem::NewFile => self.file_explorer_new_file(),
3230            FileExplorerContextMenuItem::NewDirectory => self.file_explorer_new_directory(),
3231            FileExplorerContextMenuItem::Rename => self.file_explorer_rename(),
3232            FileExplorerContextMenuItem::Cut => self.active_window_mut().file_explorer_cut(),
3233            FileExplorerContextMenuItem::Copy => self.active_window_mut().file_explorer_copy(),
3234            FileExplorerContextMenuItem::Paste => self.file_explorer_paste(),
3235            FileExplorerContextMenuItem::Duplicate => self.file_explorer_duplicate(),
3236            FileExplorerContextMenuItem::Delete => self.file_explorer_delete(),
3237            FileExplorerContextMenuItem::CopyFullPath => self.file_explorer_copy_path(false),
3238            FileExplorerContextMenuItem::CopyRelativePath => self.file_explorer_copy_path(true),
3239        }
3240    }
3241
3242    /// Show a tooltip for a file explorer status indicator
3243    fn show_file_explorer_status_tooltip(&mut self, path: std::path::PathBuf, col: u16, row: u16) {
3244        use crate::view::popup::{Popup, PopupPosition};
3245        use ratatui::style::Style;
3246
3247        let is_directory = path.is_dir();
3248
3249        // Get the decoration for this file to determine the status
3250        let decoration = self
3251            .active_window()
3252            .file_explorer_decoration_cache
3253            .direct_for_path(&path)
3254            .cloned();
3255
3256        // For directories, also check bubbled decoration
3257        let bubbled_decoration = if is_directory && decoration.is_none() {
3258            self.active_window()
3259                .file_explorer_decoration_cache
3260                .bubbled_for_path(&path)
3261                .cloned()
3262        } else {
3263            None
3264        };
3265
3266        // Check if file/folder has unsaved changes in editor
3267        let has_unsaved_changes = if is_directory {
3268            // Check if any buffer under this directory has unsaved changes
3269            self.windows
3270                .get(&self.active_window)
3271                .map(|w| &w.buffers)
3272                .expect("active window present")
3273                .iter()
3274                .any(|(buffer_id, state)| {
3275                    if state.buffer.is_modified() {
3276                        if let Some(metadata) = self.active_window().buffer_metadata.get(buffer_id)
3277                        {
3278                            if let Some(file_path) = metadata.file_path() {
3279                                return file_path.starts_with(&path);
3280                            }
3281                        }
3282                    }
3283                    false
3284                })
3285        } else {
3286            self.windows
3287                .get(&self.active_window)
3288                .map(|w| &w.buffers)
3289                .expect("active window present")
3290                .iter()
3291                .any(|(buffer_id, state)| {
3292                    if state.buffer.is_modified() {
3293                        if let Some(metadata) = self.active_window().buffer_metadata.get(buffer_id)
3294                        {
3295                            return metadata.file_path() == Some(&path);
3296                        }
3297                    }
3298                    false
3299                })
3300        };
3301
3302        // Build tooltip content
3303        let mut lines: Vec<String> = Vec::new();
3304
3305        if let Some(decoration) = &decoration {
3306            let symbol = &decoration.symbol;
3307            let explanation = match symbol.as_str() {
3308                "U" => "Untracked - File is not tracked by git",
3309                "M" => "Modified - File has unstaged changes",
3310                "A" => "Added - File is staged for commit",
3311                "D" => "Deleted - File is staged for deletion",
3312                "R" => "Renamed - File has been renamed",
3313                "C" => "Copied - File has been copied",
3314                "!" => "Conflicted - File has merge conflicts",
3315                "●" => "Has changes - Contains modified files",
3316                _ => "Unknown status",
3317            };
3318            lines.push(format!("{} - {}", symbol, explanation));
3319        } else if bubbled_decoration.is_some() {
3320            lines.push("● - Contains modified files".to_string());
3321        } else if has_unsaved_changes {
3322            if is_directory {
3323                lines.push("● - Contains unsaved changes".to_string());
3324            } else {
3325                lines.push("● - Unsaved changes in editor".to_string());
3326            }
3327        } else {
3328            return; // No status to show
3329        }
3330
3331        // For directories, show list of modified files
3332        if is_directory {
3333            // get_modified_files_in_directory returns None if no files, so no need to check is_empty()
3334            if let Some(modified_files) = self.get_modified_files_in_directory(&path) {
3335                lines.push(String::new()); // Empty line separator
3336                lines.push("Modified files:".to_string());
3337                // Resolve symlinks for proper prefix stripping
3338                let resolved_path = path.canonicalize().unwrap_or_else(|_| path.clone());
3339                const MAX_FILES: usize = 8;
3340                for (i, file) in modified_files.iter().take(MAX_FILES).enumerate() {
3341                    // Show relative path from the directory
3342                    let display_name = file
3343                        .strip_prefix(&resolved_path)
3344                        .unwrap_or(file)
3345                        .to_string_lossy()
3346                        .to_string();
3347                    lines.push(format!("  {}", display_name));
3348                    if i == MAX_FILES - 1 && modified_files.len() > MAX_FILES {
3349                        lines.push(format!(
3350                            "  ... and {} more",
3351                            modified_files.len() - MAX_FILES
3352                        ));
3353                        break;
3354                    }
3355                }
3356            }
3357        } else {
3358            // For files, try to get git diff stats
3359            if let Some(stats) = self.get_git_diff_stats(&path) {
3360                lines.push(String::new()); // Empty line separator
3361                lines.push(stats);
3362            }
3363        }
3364
3365        if lines.is_empty() {
3366            return;
3367        }
3368
3369        // Create popup
3370        let mut popup = Popup::text(lines, &*self.theme.read().unwrap());
3371        popup.title = Some("Git Status".to_string());
3372        popup.transient = true;
3373        popup.position = PopupPosition::Fixed { x: col, y: row + 1 };
3374        popup.width = 50;
3375        popup.max_height = 15;
3376        popup.border_style = Style::default().fg(self.theme.read().unwrap().popup_border_fg);
3377        popup.background_style = Style::default().bg(self.theme.read().unwrap().popup_bg);
3378
3379        // Show the popup
3380        let __buffer_id = self.active_buffer();
3381        if let Some(state) = self
3382            .windows
3383            .get_mut(&self.active_window)
3384            .map(|w| &mut w.buffers)
3385            .expect("active window present")
3386            .get_mut(&__buffer_id)
3387        {
3388            state.popups.show(popup);
3389        }
3390    }
3391
3392    /// Dismiss the file explorer status tooltip
3393    fn dismiss_file_explorer_status_tooltip(&mut self) {
3394        // Dismiss any transient popups
3395        let __buffer_id = self.active_buffer();
3396        if let Some(state) = self
3397            .windows
3398            .get_mut(&self.active_window)
3399            .map(|w| &mut w.buffers)
3400            .expect("active window present")
3401            .get_mut(&__buffer_id)
3402        {
3403            state.popups.dismiss_transient();
3404        }
3405    }
3406
3407    /// Get git diff stats for a file (insertions/deletions)
3408    fn get_git_diff_stats(&self, path: &std::path::Path) -> Option<String> {
3409        use crate::services::process_hidden::HideWindow;
3410        use std::process::Command;
3411
3412        // Run git diff --numstat for the file
3413        let output = Command::new("git")
3414            .args(["diff", "--numstat", "--"])
3415            .arg(path)
3416            .current_dir(self.working_dir())
3417            .hide_window()
3418            .output()
3419            .ok()?;
3420
3421        if !output.status.success() {
3422            return None;
3423        }
3424
3425        let stdout = String::from_utf8_lossy(&output.stdout);
3426        let line = stdout.lines().next()?;
3427        let parts: Vec<&str> = line.split('\t').collect();
3428
3429        if parts.len() >= 2 {
3430            let insertions = parts[0];
3431            let deletions = parts[1];
3432
3433            // Handle binary files (shows as -)
3434            if insertions == "-" && deletions == "-" {
3435                return Some("Binary file changed".to_string());
3436            }
3437
3438            let ins: i32 = insertions.parse().unwrap_or(0);
3439            let del: i32 = deletions.parse().unwrap_or(0);
3440
3441            if ins > 0 || del > 0 {
3442                return Some(format!("+{} -{} lines", ins, del));
3443            }
3444        }
3445
3446        // Also check staged changes
3447        let staged_output = Command::new("git")
3448            .args(["diff", "--numstat", "--cached", "--"])
3449            .arg(path)
3450            .current_dir(self.working_dir())
3451            .hide_window()
3452            .output()
3453            .ok()?;
3454
3455        if staged_output.status.success() {
3456            let staged_stdout = String::from_utf8_lossy(&staged_output.stdout);
3457            if let Some(line) = staged_stdout.lines().next() {
3458                let parts: Vec<&str> = line.split('\t').collect();
3459                if parts.len() >= 2 {
3460                    let insertions = parts[0];
3461                    let deletions = parts[1];
3462
3463                    if insertions == "-" && deletions == "-" {
3464                        return Some("Binary file staged".to_string());
3465                    }
3466
3467                    let ins: i32 = insertions.parse().unwrap_or(0);
3468                    let del: i32 = deletions.parse().unwrap_or(0);
3469
3470                    if ins > 0 || del > 0 {
3471                        return Some(format!("+{} -{} lines (staged)", ins, del));
3472                    }
3473                }
3474            }
3475        }
3476
3477        None
3478    }
3479
3480    /// Get list of modified files in a directory
3481    fn get_modified_files_in_directory(
3482        &self,
3483        dir_path: &std::path::Path,
3484    ) -> Option<Vec<std::path::PathBuf>> {
3485        use crate::services::process_hidden::HideWindow;
3486        use std::process::Command;
3487
3488        // Resolve symlinks to get the actual directory path
3489        let resolved_path = dir_path
3490            .canonicalize()
3491            .unwrap_or_else(|_| dir_path.to_path_buf());
3492
3493        // Run git status --porcelain to get list of modified files
3494        let output = Command::new("git")
3495            .args(["status", "--porcelain", "--"])
3496            .arg(&resolved_path)
3497            .current_dir(self.working_dir())
3498            .hide_window()
3499            .output()
3500            .ok()?;
3501
3502        if !output.status.success() {
3503            return None;
3504        }
3505
3506        let stdout = String::from_utf8_lossy(&output.stdout);
3507        let modified_files: Vec<std::path::PathBuf> = stdout
3508            .lines()
3509            .filter_map(|line| {
3510                // Git porcelain format: XY filename
3511                // where XY is the status (M, A, D, ??, etc.)
3512                if line.len() > 3 {
3513                    let file_part = &line[3..];
3514                    // Handle renamed files (old -> new format)
3515                    let file_name = if file_part.contains(" -> ") {
3516                        file_part.split(" -> ").last().unwrap_or(file_part)
3517                    } else {
3518                        file_part
3519                    };
3520                    Some(self.working_dir().join(file_name))
3521                } else {
3522                    None
3523                }
3524            })
3525            .collect();
3526
3527        if modified_files.is_empty() {
3528            None
3529        } else {
3530            Some(modified_files)
3531        }
3532    }
3533
3534    /// Hit-test a click against the floating widget panel. Clicks
3535    /// inside the panel's inner rect resolve to a widget row/byte
3536    /// and fire `widget_event` via the same path
3537    /// Forward a vertical-wheel scroll to the active floating
3538    /// widget panel — same plumbing the orchestrator's
3539    /// embedded-widget panels use, but the floating panel
3540    /// doesn't show up in `split_at_position` so it needs its
3541    /// own dispatch entry point. Returns `true` when the panel
3542    /// is active AND the mouse is inside its inner rect (so the
3543    /// caller knows the wheel was consumed and shouldn't fall
3544    /// through to buffer scrolling).
3545    fn handle_floating_widget_panel_wheel(&mut self, col: u16, row: u16, delta: i32) -> bool {
3546        let inner = match self.floating_widget_panel.as_ref() {
3547            Some(fwp) => match fwp.last_inner_rect {
3548                Some(rect) => rect,
3549                None => return false,
3550            },
3551            None => return false,
3552        };
3553        if col < inner.x || col >= inner.x + inner.width {
3554            return false;
3555        }
3556        if row < inner.y || row >= inner.y + inner.height {
3557            return false;
3558        }
3559        self.handle_widget_panel_wheel(super::FLOATING_PANEL_BUFFER_ID, delta)
3560    }
3561
3562    /// Try to start a floating-panel list scrollbar drag. Returns
3563    /// true if the press landed on a scrollbar track (so the caller
3564    /// skips row hit-testing — the bar overlaps the list's rightmost
3565    /// column). Reuses the canonical `ScrollbarMouse`/`ScrollbarState`.
3566    fn try_widget_scrollbar_press(&mut self, col: u16, row: u16) -> bool {
3567        use crate::view::ui::scrollbar::ScrollbarState;
3568        let (panel_id, tracks) = match self.floating_widget_panel.as_ref() {
3569            Some(fwp) => (fwp.panel_id, fwp.scrollbar_tracks.clone()),
3570            None => return false,
3571        };
3572        for t in &tracks {
3573            let state = ScrollbarState::new(t.total, t.visible, t.scroll);
3574            let pressed = self
3575                .floating_widget_panel
3576                .as_mut()
3577                .and_then(|fwp| fwp.scrollbar_mouse.press(state, t.rect, col, row));
3578            if let Some(new_offset) = pressed {
3579                if let Some(fwp) = self.floating_widget_panel.as_mut() {
3580                    fwp.scrollbar_drag_key = Some(t.list_key.clone());
3581                }
3582                self.apply_widget_scroll(panel_id, &t.list_key, new_offset, t.visible);
3583                return true;
3584            }
3585        }
3586        false
3587    }
3588
3589    /// Continue an in-flight floating-panel scrollbar drag. Returns
3590    /// true if a drag is active (the press captured a `list_key`).
3591    fn try_widget_scrollbar_drag(&mut self, row: u16) -> bool {
3592        use crate::view::ui::scrollbar::ScrollbarState;
3593        let (panel_id, key) = match self.floating_widget_panel.as_ref() {
3594            Some(fwp) => match &fwp.scrollbar_drag_key {
3595                Some(k) => (fwp.panel_id, k.clone()),
3596                None => return false,
3597            },
3598            None => return false,
3599        };
3600        // The track geometry for the dragged list (its rect may have
3601        // shifted if the panel re-rendered between events).
3602        let track = self.floating_widget_panel.as_ref().and_then(|fwp| {
3603            fwp.scrollbar_tracks
3604                .iter()
3605                .find(|t| t.list_key == key)
3606                .cloned()
3607        });
3608        let Some(t) = track else {
3609            return false;
3610        };
3611        let state = ScrollbarState::new(t.total, t.visible, t.scroll);
3612        let new_offset = self
3613            .floating_widget_panel
3614            .as_mut()
3615            .and_then(|fwp| fwp.scrollbar_mouse.drag(state, t.rect, row));
3616        if let Some(off) = new_offset {
3617            self.apply_widget_scroll(panel_id, &key, off, t.visible);
3618        }
3619        true
3620    }
3621
3622    /// End any in-flight floating-panel scrollbar drag.
3623    pub(super) fn release_widget_scrollbar(&mut self) {
3624        if let Some(fwp) = self.floating_widget_panel.as_mut() {
3625            fwp.scrollbar_mouse.release();
3626            fwp.scrollbar_drag_key = None;
3627        }
3628    }
3629
3630    /// Apply a host-driven scroll to a panel list (scrollbar press /
3631    /// drag): update the registry's instance state, re-render, and —
3632    /// when the list has a live selection that moved into the new
3633    /// window — notify the plugin so its own selection mirror +
3634    /// preview stay in sync with the thumb.
3635    fn apply_widget_scroll(
3636        &mut self,
3637        panel_id: u64,
3638        list_key: &str,
3639        new_offset: usize,
3640        visible: usize,
3641    ) {
3642        let moved_sel = self.widget_registry.set_list_scroll(
3643            panel_id,
3644            list_key,
3645            new_offset as u32,
3646            visible as u32,
3647        );
3648        self.rerender_widget_panel(panel_id);
3649        if let Some(sel) = moved_sel {
3650            if self
3651                .plugin_manager
3652                .read()
3653                .unwrap()
3654                .has_hook_handlers("widget_event")
3655            {
3656                self.plugin_manager.read().unwrap().run_hook(
3657                    "widget_event",
3658                    crate::services::plugins::hooks::HookArgs::WidgetEvent {
3659                        panel_id,
3660                        widget_key: list_key.to_string(),
3661                        event_type: "select".to_string(),
3662                        payload: serde_json::json!({ "index": sel as i64 }),
3663                    },
3664                );
3665            }
3666        }
3667    }
3668
3669    /// `handle_editor_click` uses; clicks outside the rect are
3670    /// swallowed without dismissing the panel.
3671    fn handle_floating_widget_click(&mut self, col: u16, row: u16) {
3672        // Scrollbar press wins over row hit-testing (the bar overlaps
3673        // the list's rightmost column).
3674        if self.try_widget_scrollbar_press(col, row) {
3675            return;
3676        }
3677        let (panel_id, inner) = match self.floating_widget_panel.as_ref() {
3678            Some(fwp) => match fwp.last_inner_rect {
3679                Some(rect) => (fwp.panel_id, rect),
3680                None => return,
3681            },
3682            None => return,
3683        };
3684        if col < inner.x || col >= inner.x + inner.width {
3685            return;
3686        }
3687        if row < inner.y || row >= inner.y + inner.height {
3688            return;
3689        }
3690        let brow = (row - inner.y) as u32;
3691        let entries = self
3692            .floating_widget_panel
3693            .as_ref()
3694            .map(|f| f.entries.clone())
3695            .unwrap_or_default();
3696        let local_screen_col = (col - inner.x) as usize;
3697        let bcol = match entries.get(brow as usize) {
3698            Some(entry) => screen_col_to_byte(&entry.text, local_screen_col),
3699            None => return,
3700        };
3701        let (hit_payload, hit_event, hit_key, hit_kind) =
3702            match self
3703                .widget_registry
3704                .hit_test(super::FLOATING_PANEL_BUFFER_ID, brow, bcol as u32)
3705            {
3706                Some((_, hit)) => (
3707                    hit.payload.clone(),
3708                    hit.event_type.to_string(),
3709                    hit.widget_key.clone(),
3710                    hit.widget_kind,
3711                ),
3712                None => return,
3713            };
3714        if !hit_key.is_empty() {
3715            let tabbable = self
3716                .widget_registry
3717                .get(panel_id)
3718                .map(|p| p.tabbable.iter().any(|k| k == &hit_key))
3719                .unwrap_or(false);
3720            if tabbable {
3721                self.set_panel_focus_and_notify(panel_id, hit_key.clone());
3722            }
3723            self.rerender_widget_panel(panel_id);
3724        }
3725        let handled_specially = if hit_kind == "tree" && hit_event == "expand" {
3726            if let Some(item_key) = hit_payload.get("key").and_then(|v| v.as_str()) {
3727                self.handle_widget_tree_expand_toggle(panel_id, &hit_key, item_key);
3728                true
3729            } else {
3730                false
3731            }
3732        } else {
3733            false
3734        };
3735        if !handled_specially
3736            && self
3737                .plugin_manager
3738                .read()
3739                .unwrap()
3740                .has_hook_handlers("widget_event")
3741        {
3742            self.plugin_manager.read().unwrap().run_hook(
3743                "widget_event",
3744                crate::services::plugins::hooks::HookArgs::WidgetEvent {
3745                    panel_id,
3746                    widget_key: hit_key,
3747                    event_type: hit_event,
3748                    payload: hit_payload,
3749                },
3750            );
3751        }
3752    }
3753}
3754
3755/// Translate a display-column offset within a rendered line into a
3756/// UTF-8 byte offset inside the entry's text. Walks the string
3757/// codepoint-by-codepoint using `unicode-width` so wide glyphs
3758/// (CJK, emoji) advance by their actual screen width.
3759fn screen_col_to_byte(text: &str, target_col: usize) -> usize {
3760    use unicode_width::UnicodeWidthChar;
3761    let mut byte = 0;
3762    let mut col = 0usize;
3763    for ch in text.chars() {
3764        let w = UnicodeWidthChar::width(ch).unwrap_or(0);
3765        if col + w > target_col {
3766            return byte;
3767        }
3768        col += w;
3769        byte += ch.len_utf8();
3770    }
3771    byte
3772}