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