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