Skip to main content

fresh/app/
mouse_input.rs

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