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