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