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