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