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