1use 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
21fn 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 pub fn handle_mouse(
30 &mut self,
31 mouse_event: crossterm::event::MouseEvent,
32 ) -> AnyhowResult<bool> {
33 use crossterm::event::{MouseButton, MouseEventKind};
34
35 let col = mouse_event.column;
36 let row = mouse_event.row;
37
38 let (is_double_click, is_triple_click) = self.detect_multi_click(&mouse_event, col, row);
39
40 if self.keybinding_editor.is_some() {
42 return self.handle_keybinding_editor_mouse(mouse_event);
43 }
44
45 if self.settings_state.as_ref().is_some_and(|s| s.visible) {
47 return self.handle_settings_mouse(mouse_event, is_double_click);
48 }
49
50 if self.calibration_wizard.is_some() {
52 return Ok(false);
53 }
54
55 if self.global_popups.top().is_some_and(|p| {
59 matches!(
60 p.resolver,
61 crate::view::popup::PopupResolver::WorkspaceTrust
62 )
63 }) {
64 return self.handle_workspace_trust_mouse(mouse_event);
65 }
66
67 let mut needs_render = false;
69 if let Some(ref prompt) = self.active_window_mut().prompt {
70 if matches!(prompt.prompt_type, PromptType::LspRename { .. }) {
71 self.cancel_prompt();
72 needs_render = true;
73 }
74 }
75
76 let cursor_moved = self.active_window_mut().mouse_cursor_position != Some((col, row));
79 self.active_window_mut().mouse_cursor_position = Some((col, row));
80 if self.active_window_mut().gpm_active && cursor_moved {
81 needs_render = true;
82 }
83
84 tracing::trace!(
85 "handle_mouse: kind={:?}, col={}, row={}",
86 mouse_event.kind,
87 col,
88 row
89 );
90
91 if let Some(result) =
94 self.active_window_mut()
95 .try_forward_mouse_to_terminal(col, row, mouse_event)
96 {
97 return result;
98 }
99
100 if self.active_window_mut().theme_info_popup.is_some() {
102 if let MouseEventKind::Down(MouseButton::Left) = mouse_event.kind {
103 if let Some((popup_rect, button_row_offset)) = self.theme_info_popup_rect() {
104 if in_rect(col, row, popup_rect) {
105 let actual_button_row = popup_rect.y + button_row_offset;
107 if row == actual_button_row {
108 let fg_key = self
109 .active_window_mut()
110 .theme_info_popup
111 .as_ref()
112 .and_then(|p| p.info.fg_key.clone());
113 self.active_window_mut().theme_info_popup = None;
114 if let Some(key) = fg_key {
115 self.fire_theme_inspect_hook(key);
116 }
117 return Ok(true);
118 }
119 return Ok(true);
121 }
122 }
123 self.active_window_mut().theme_info_popup = None;
125 needs_render = true;
126 }
127 }
128
129 match mouse_event.kind {
130 MouseEventKind::Down(MouseButton::Left) => {
131 if is_double_click || is_triple_click {
132 if let Some((buffer_id, byte_pos)) =
133 self.fold_toggle_line_at_screen_position(col, row)
134 {
135 self.active_window_mut()
136 .toggle_fold_at_byte(buffer_id, byte_pos);
137 needs_render = true;
138 return Ok(needs_render);
139 }
140 }
141 if is_triple_click {
142 self.handle_mouse_triple_click(col, row)?;
144 needs_render = true;
145 return Ok(needs_render);
146 }
147 if is_double_click {
148 self.handle_mouse_double_click(col, row)?;
150 needs_render = true;
151 return Ok(needs_render);
152 }
153 self.handle_mouse_click(col, row, mouse_event.modifiers)?;
154 needs_render = true;
155 }
156 MouseEventKind::Drag(MouseButton::Left) => {
157 self.handle_mouse_drag(col, row)?;
158 needs_render = true;
159 }
160 MouseEventKind::Up(MouseButton::Left) => {
161 let was_dragging_separator = self
163 .active_window_mut()
164 .mouse_state
165 .dragging_separator
166 .is_some();
167
168 if let Some(drag_state) = self.active_window_mut().mouse_state.dragging_tab.take() {
170 if drag_state.is_dragging() {
171 if let Some(drop_zone) = drag_state.drop_zone {
172 self.execute_tab_drop(
173 drag_state.buffer_id,
174 drag_state.source_split_id,
175 drop_zone,
176 );
177 }
178 }
179 }
180
181 self.release_widget_scrollbar();
183 self.active_window_mut().mouse_state.dragging_scrollbar = None;
184 self.active_window_mut().mouse_state.drag_start_row = None;
185 self.active_window_mut().mouse_state.drag_start_top_byte = None;
186 self.active_window_mut()
187 .mouse_state
188 .dragging_horizontal_scrollbar = None;
189 self.active_window_mut().mouse_state.drag_start_hcol = None;
190 self.active_window_mut().mouse_state.drag_start_left_column = None;
191 self.active_window_mut().mouse_state.dragging_separator = None;
192 self.active_window_mut().mouse_state.drag_start_position = None;
193 self.active_window_mut().mouse_state.drag_start_ratio = None;
194 self.active_window_mut().mouse_state.dragging_file_explorer = false;
195 self.active_window_mut()
196 .mouse_state
197 .drag_start_explorer_width = None;
198 self.active_window_mut().mouse_state.dragging_text_selection = false;
200 self.active_window_mut().mouse_state.drag_selection_split = None;
201 self.active_window_mut().mouse_state.drag_selection_anchor = None;
202 self.active_window_mut().mouse_state.drag_selection_by_words = false;
203 self.active_window_mut().mouse_state.drag_selection_word_end = None;
204 self.active_window_mut()
206 .mouse_state
207 .dragging_popup_scrollbar = None;
208 self.active_window_mut().mouse_state.drag_start_popup_scroll = None;
209 self.active_window_mut()
211 .mouse_state
212 .dragging_prompt_scrollbar = false;
213 self.active_window_mut().mouse_state.selecting_in_popup = None;
215
216 if was_dragging_separator {
218 self.active_window_mut().resize_visible_terminals();
219 }
220
221 needs_render = true;
222 }
223 MouseEventKind::Moved => {
224 {
226 let content_rect = self
228 .active_layout()
229 .split_areas
230 .iter()
231 .find(|(_, _, content_rect, _, _, _)| in_rect(col, row, *content_rect))
232 .map(|(_, _, rect, _, _, _)| *rect);
233
234 let (content_x, content_y) = content_rect.map(|r| (r.x, r.y)).unwrap_or((0, 0));
235
236 self.plugin_manager.read().unwrap().run_hook(
237 "mouse_move",
238 HookArgs::MouseMove {
239 column: col,
240 row,
241 content_x,
242 content_y,
243 },
244 );
245 }
246
247 let hover_changed = self.update_hover_target(col, row);
250 needs_render = needs_render || hover_changed;
251
252 if let Some((popup_rect, button_row_offset)) = self.theme_info_popup_rect() {
254 let button_row = popup_rect.y + button_row_offset;
255 let new_highlighted = row == button_row
256 && col >= popup_rect.x
257 && col < popup_rect.x + popup_rect.width;
258 if let Some(ref mut popup) = self.active_window_mut().theme_info_popup {
259 if popup.button_highlighted != new_highlighted {
260 popup.button_highlighted = new_highlighted;
261 needs_render = true;
262 }
263 }
264 }
265
266 self.update_lsp_hover_state(col, row);
268 }
269 MouseEventKind::ScrollUp => {
270 self.handle_vertical_scroll(col, row, mouse_event.modifiers, -3)?;
271 needs_render = true;
272 }
273 MouseEventKind::ScrollDown => {
274 self.handle_vertical_scroll(col, row, mouse_event.modifiers, 3)?;
275 needs_render = true;
276 }
277 MouseEventKind::ScrollLeft => {
278 self.active_window_mut()
280 .handle_horizontal_scroll(col, row, -3)?;
281 needs_render = true;
282 }
283 MouseEventKind::ScrollRight => {
284 self.active_window_mut()
286 .handle_horizontal_scroll(col, row, 3)?;
287 needs_render = true;
288 }
289 MouseEventKind::Down(MouseButton::Right) => {
290 if self.overlay_prompt_active() {
294 needs_render = true;
295 } else if mouse_event
296 .modifiers
297 .contains(crossterm::event::KeyModifiers::CONTROL)
298 {
299 self.show_theme_info_popup(col, row)?;
301 needs_render = true;
302 } else {
303 self.handle_right_click(col, row)?;
305 needs_render = true;
306 }
307 }
308 _ => {
309 }
311 }
312
313 self.active_window_mut().mouse_state.last_position = Some((col, row));
314 Ok(needs_render)
315 }
316
317 fn detect_multi_click(
319 &mut self,
320 mouse_event: &crossterm::event::MouseEvent,
321 col: u16,
322 row: u16,
323 ) -> (bool, bool) {
324 use crossterm::event::{MouseButton, MouseEventKind};
325 if !matches!(mouse_event.kind, MouseEventKind::Down(MouseButton::Left)) {
326 return (false, false);
327 }
328 let now = self.time_source.now();
329 let threshold = std::time::Duration::from_millis(self.config.editor.double_click_time_ms);
330 let is_consecutive = if let (Some(prev_time), Some(prev_pos)) = (
331 self.active_window_mut().previous_click_time,
332 self.active_window_mut().previous_click_position,
333 ) {
334 now.duration_since(prev_time) < threshold && prev_pos == (col, row)
335 } else {
336 false
337 };
338 if is_consecutive {
339 self.active_window_mut().click_count += 1;
340 } else {
341 self.active_window_mut().click_count = 1;
342 }
343 self.active_window_mut().previous_click_time = Some(now);
344 self.active_window_mut().previous_click_position = Some((col, row));
345 let is_triple = self.active_window_mut().click_count >= 3;
346 let is_double = self.active_window_mut().click_count == 2;
347 if is_triple {
348 self.active_window_mut().click_count = 0;
349 self.active_window_mut().previous_click_time = None;
350 self.active_window_mut().previous_click_position = None;
351 }
352 (is_double, is_triple)
353 }
354
355 fn handle_vertical_scroll(
358 &mut self,
359 col: u16,
360 row: u16,
361 modifiers: crossterm::event::KeyModifiers,
362 delta: i32,
363 ) -> AnyhowResult<()> {
364 if modifiers.contains(crossterm::event::KeyModifiers::SHIFT) {
365 self.active_window_mut()
366 .handle_horizontal_scroll(col, row, delta)?;
367 } else if self.handle_prompt_scroll(delta) {
368 } else if self.is_file_open_active()
370 && self.is_mouse_over_file_browser(col, row)
371 && self.handle_file_open_scroll(delta)
372 {
373 } else if self.is_mouse_over_any_popup(col, row) {
375 self.scroll_popup(delta);
376 } else if self.handle_floating_widget_panel_wheel(col, row, delta) {
377 } else if self
382 .active_window()
383 .split_at_position(col, row)
384 .map(|(_, buffer_id)| self.handle_widget_panel_wheel(buffer_id, delta))
385 .unwrap_or(false)
386 {
387 } else {
389 if self.active_window().terminal_mode
390 && self
391 .active_window()
392 .is_terminal_buffer(self.active_buffer())
393 {
394 {
395 let __b = self.active_buffer();
396 self.active_window_mut().sync_terminal_to_buffer(__b);
397 };
398 self.active_window_mut().terminal_mode = false;
399 self.active_window_mut().key_context =
400 crate::input::keybindings::KeyContext::Normal;
401 }
402 self.dismiss_transient_popups();
403 self.active_window_mut()
404 .handle_mouse_scroll(col, row, delta)?;
405 }
406 Ok(())
407 }
408
409 pub(super) fn update_hover_target(&mut self, col: u16, row: u16) -> bool {
412 let old_target = self.active_window_mut().mouse_state.hover_target.clone();
413 let new_target = self.compute_hover_target(col, row);
414 let changed = old_target != new_target;
415 self.active_window_mut().mouse_state.hover_target = new_target.clone();
416
417 if let Some(active_menu_idx) = self.menu_state.active_menu {
420 let all_menus: Vec<crate::config::Menu> = self
421 .menus
422 .menus
423 .iter()
424 .chain(self.menu_state.plugin_menus.iter())
425 .cloned()
426 .collect();
427 if let Some(HoverTarget::MenuBarItem(hovered_menu_idx)) = new_target.clone() {
428 if hovered_menu_idx != active_menu_idx {
429 self.menu_state.open_menu(hovered_menu_idx);
430 return true; }
432 }
433
434 if let Some(HoverTarget::MenuDropdownItem(_, item_idx)) = new_target.clone() {
436 if self.menu_state.submenu_path.first() == Some(&item_idx) {
439 tracing::trace!(
440 "menu hover: staying on submenu parent item_idx={}, submenu_path={:?}",
441 item_idx,
442 self.menu_state.submenu_path
443 );
444 return changed;
445 }
446
447 if !self.menu_state.submenu_path.is_empty() {
449 tracing::trace!(
450 "menu hover: clearing submenu_path={:?} for different item_idx={}",
451 self.menu_state.submenu_path,
452 item_idx
453 );
454 self.menu_state.submenu_path.clear();
455 self.menu_state.highlighted_item = Some(item_idx);
456 return true;
457 }
458
459 if let Some(menu) = all_menus.get(active_menu_idx) {
461 if let Some(crate::config::MenuItem::Submenu { items, .. }) =
462 menu.items.get(item_idx)
463 {
464 if !items.is_empty() {
465 tracing::trace!("menu hover: opening submenu at item_idx={}", item_idx);
466 self.menu_state.submenu_path.push(item_idx);
467 self.menu_state.highlighted_item = Some(0);
468 return true;
469 }
470 }
471 }
472 if self.menu_state.highlighted_item != Some(item_idx) {
474 self.menu_state.highlighted_item = Some(item_idx);
475 return true;
476 }
477 }
478
479 if let Some(HoverTarget::SubmenuItem(depth, item_idx)) = new_target {
481 if self.menu_state.submenu_path.len() > depth
485 && self.menu_state.submenu_path.get(depth) == Some(&item_idx)
486 {
487 tracing::trace!(
488 "menu hover: staying on nested submenu parent depth={}, item_idx={}, submenu_path={:?}",
489 depth,
490 item_idx,
491 self.menu_state.submenu_path
492 );
493 return changed;
494 }
495
496 if self.menu_state.submenu_path.len() > depth {
498 tracing::trace!(
499 "menu hover: truncating submenu_path={:?} to depth={} for item_idx={}",
500 self.menu_state.submenu_path,
501 depth,
502 item_idx
503 );
504 self.menu_state.submenu_path.truncate(depth);
505 }
506
507 if let Some(items) = self
509 .menu_state
510 .get_current_items(&all_menus, active_menu_idx)
511 {
512 if let Some(crate::config::MenuItem::Submenu {
514 items: sub_items, ..
515 }) = items.get(item_idx)
516 {
517 if !sub_items.is_empty()
518 && !self.menu_state.submenu_path.contains(&item_idx)
519 {
520 tracing::trace!(
521 "menu hover: opening nested submenu at depth={}, item_idx={}",
522 depth,
523 item_idx
524 );
525 self.menu_state.submenu_path.push(item_idx);
526 self.menu_state.highlighted_item = Some(0);
527 return true;
528 }
529 }
530 if self.menu_state.highlighted_item != Some(item_idx) {
532 self.menu_state.highlighted_item = Some(item_idx);
533 return true;
534 }
535 }
536 }
537 }
538
539 if let Some(HoverTarget::TabContextMenuItem(item_idx)) = new_target.clone() {
541 if let Some(ref mut menu) = self.active_window_mut().tab_context_menu {
542 if menu.highlighted != item_idx {
543 menu.highlighted = item_idx;
544 return true;
545 }
546 }
547 }
548
549 if let Some(&HoverTarget::FileExplorerContextMenuItem(item_idx)) = new_target.as_ref() {
550 if let Some(ref mut menu) = self.active_window_mut().file_explorer_context_menu {
551 if menu.highlighted != item_idx {
552 menu.highlighted = item_idx;
553 return true;
554 }
555 }
556 }
557
558 if old_target != new_target
561 && matches!(
562 old_target,
563 Some(HoverTarget::FileExplorerStatusIndicator(_))
564 )
565 {
566 self.dismiss_file_explorer_status_tooltip();
567 }
568
569 if let Some(HoverTarget::FileExplorerStatusIndicator(ref path)) = new_target {
570 if old_target != new_target {
572 self.show_file_explorer_status_tooltip(path.clone(), col, row);
573 return true;
574 }
575 }
576
577 changed
578 }
579
580 fn update_lsp_hover_state(&mut self, col: u16, row: u16) {
589 tracing::trace!(col, row, "update_lsp_hover_state: raw mouse position");
590
591 if self.active_window_mut().theme_info_popup.is_some()
594 || self.active_window_mut().tab_context_menu.is_some()
595 || self
596 .active_window_mut()
597 .file_explorer_context_menu
598 .is_some()
599 {
600 if self
601 .active_window_mut()
602 .mouse_state
603 .lsp_hover_state
604 .is_some()
605 {
606 self.active_window_mut().mouse_state.lsp_hover_state = None;
607 self.active_window_mut().mouse_state.lsp_hover_request_sent = false;
608 self.dismiss_transient_popups();
609 }
610 return;
611 }
612
613 if self.is_mouse_over_transient_popup(col, row) {
615 return;
616 }
617
618 let split_info = self
620 .active_layout()
621 .split_areas
622 .iter()
623 .find(|(_, _, content_rect, _, _, _)| in_rect(col, row, *content_rect))
624 .map(|(split_id, buffer_id, content_rect, _, _, _)| {
625 (*split_id, *buffer_id, *content_rect)
626 });
627
628 let Some((split_id, buffer_id, content_rect)) = split_info else {
629 if self
631 .active_window_mut()
632 .mouse_state
633 .lsp_hover_state
634 .is_some()
635 {
636 self.active_window_mut().mouse_state.lsp_hover_state = None;
637 self.active_window_mut().mouse_state.lsp_hover_request_sent = false;
638 self.dismiss_transient_popups();
639 }
640 return;
641 };
642
643 let cached_mappings = self
645 .active_layout()
646 .view_line_mappings
647 .get(&split_id)
648 .cloned();
649 let gutter_width = self
650 .buffers()
651 .get(&buffer_id)
652 .map(|s| s.margins.left_total_width() as u16)
653 .unwrap_or(0);
654 let fallback = self
655 .buffers()
656 .get(&buffer_id)
657 .map(|s| s.buffer.len())
658 .unwrap_or(0);
659
660 let compose_width = self
662 .windows
663 .get(&self.active_window)
664 .and_then(|w| w.buffers.splits())
665 .map(|(_, vs)| vs)
666 .expect("active window must have a populated split layout")
667 .get(&split_id)
668 .and_then(|vs| vs.compose_width);
669
670 let Some(byte_pos) = super::click_geometry::screen_to_buffer_position(
672 col,
673 row,
674 content_rect,
675 gutter_width,
676 &cached_mappings,
677 fallback,
678 false, compose_width,
680 ) else {
681 if self
685 .active_window_mut()
686 .mouse_state
687 .lsp_hover_state
688 .is_some()
689 {
690 self.active_window_mut().mouse_state.lsp_hover_state = None;
691 self.active_window_mut().mouse_state.lsp_hover_request_sent = false;
692 }
693 return;
694 };
695
696 let content_col = col.saturating_sub(content_rect.x);
698 let text_col = content_col.saturating_sub(gutter_width) as usize;
699 let visual_row = row.saturating_sub(content_rect.y) as usize;
700
701 let line_info = cached_mappings
702 .as_ref()
703 .and_then(|mappings| mappings.get(visual_row))
704 .map(|line_mapping| {
705 (
706 line_mapping.visual_to_char.len(),
707 line_mapping.line_end_byte,
708 )
709 });
710
711 let is_past_line_end_or_empty = line_info
712 .map(|(line_len, _)| {
713 if line_len <= 1 {
715 return true;
716 }
717 text_col >= line_len
718 })
719 .unwrap_or(true);
721
722 tracing::trace!(
723 col,
724 row,
725 content_col,
726 text_col,
727 visual_row,
728 gutter_width,
729 byte_pos,
730 ?line_info,
731 is_past_line_end_or_empty,
732 "update_lsp_hover_state: position check"
733 );
734
735 if is_past_line_end_or_empty {
736 tracing::trace!(
737 "update_lsp_hover_state: mouse past line end or empty line, clearing hover"
738 );
739 if self
744 .active_window_mut()
745 .mouse_state
746 .lsp_hover_state
747 .is_some()
748 {
749 self.active_window_mut().mouse_state.lsp_hover_state = None;
750 self.active_window_mut().mouse_state.lsp_hover_request_sent = false;
751 }
752 return;
753 }
754
755 if let Some((start, end)) = self.active_window_mut().hover.symbol_range() {
757 if byte_pos >= start && byte_pos < end {
758 return;
760 }
761 }
762
763 if let Some((old_pos, _, _, _)) = self.active_window_mut().mouse_state.lsp_hover_state {
765 if old_pos == byte_pos {
766 return;
768 }
769 }
775
776 self.active_window_mut().mouse_state.lsp_hover_state =
778 Some((byte_pos, std::time::Instant::now(), col, row));
779 self.active_window_mut().mouse_state.lsp_hover_request_sent = false;
780 }
781
782 fn is_mouse_over_transient_popup(&self, col: u16, row: u16) -> bool {
784 let layouts = popup_areas_to_layout_info(&self.active_chrome().popup_areas);
785 let hit_tester = PopupHitTester::new(&layouts, &self.active_state().popups);
786 hit_tester.is_over_transient_popup(col, row)
787 }
788
789 fn is_mouse_over_any_popup(&self, col: u16, row: u16) -> bool {
791 for (_, popup_area, _, _, _) in &self.active_chrome().global_popup_areas {
794 if in_rect(col, row, *popup_area) {
795 return true;
796 }
797 }
798 if let Some(outer) = self.active_chrome().suggestions_outer_area {
802 if in_rect(col, row, outer) {
803 return true;
804 }
805 }
806 let layouts = popup_areas_to_layout_info(&self.active_chrome().popup_areas);
807 let hit_tester = PopupHitTester::new(&layouts, &self.active_state().popups);
808 hit_tester.is_over_popup(col, row)
809 }
810
811 fn is_mouse_over_file_browser(&self, col: u16, row: u16) -> bool {
813 self.active_window()
814 .file_browser_layout
815 .as_ref()
816 .is_some_and(|layout| layout.contains(col, row))
817 }
818
819 fn compute_hover_target(&self, col: u16, row: u16) -> Option<HoverTarget> {
824 if let Some(ref menu) = self.active_window().file_explorer_context_menu {
825 let (menu_x, menu_y) = menu.clamped_position(
826 self.active_chrome().last_frame_width,
827 self.active_chrome().last_frame_height,
828 );
829 let menu_width = super::types::FILE_EXPLORER_CONTEXT_MENU_WIDTH;
830 let menu_height = menu.height();
831
832 if col >= menu_x
833 && col < menu_x + menu_width
834 && row > menu_y
835 && row < menu_y + menu_height - 1
836 {
837 let item_idx = (row - menu_y - 1) as usize;
838 if item_idx < menu.items().len() {
839 return Some(HoverTarget::FileExplorerContextMenuItem(item_idx));
840 }
841 }
842 }
843
844 if let Some(ref menu) = self.active_window().tab_context_menu {
846 let menu_x = menu.position.0;
847 let menu_y = menu.position.1;
848 let menu_width = 22u16;
849 let items = super::types::TabContextMenuItem::all();
850 let menu_height = items.len() as u16 + 2;
851
852 if col >= menu_x
853 && col < menu_x + menu_width
854 && row > menu_y
855 && row < menu_y + menu_height - 1
856 {
857 let item_idx = (row - menu_y - 1) as usize;
858 if item_idx < items.len() {
859 return Some(HoverTarget::TabContextMenuItem(item_idx));
860 }
861 }
862 }
863
864 if let Some((inner_rect, start_idx, _visible_count, total_count)) =
866 &self.active_chrome().suggestions_area
867 {
868 if in_rect(col, row, *inner_rect) {
869 let relative_row = (row - inner_rect.y) as usize;
870 let item_idx = start_idx + relative_row;
871
872 if item_idx < *total_count {
873 return Some(HoverTarget::SuggestionItem(item_idx));
874 }
875 }
876 }
877
878 for (popup_idx, _popup_rect, inner_rect, scroll_offset, num_items, _, _) in
881 self.active_chrome().popup_areas.iter().rev()
882 {
883 if in_rect(col, row, *inner_rect) && *num_items > 0 {
884 let relative_row = (row - inner_rect.y) as usize;
886 let item_idx = scroll_offset + relative_row;
887
888 if item_idx < *num_items {
889 return Some(HoverTarget::PopupListItem(*popup_idx, item_idx));
890 }
891 }
892 }
893
894 if self.is_file_open_active() {
896 if let Some(hover) = self.compute_file_browser_hover(col, row) {
897 return Some(hover);
898 }
899 }
900
901 if self.active_window().menu_bar_visible {
904 if let Some(ref menu_layout) = self.active_chrome().menu_layout {
905 if let Some(menu_idx) = menu_layout.menu_at(col, row) {
906 return Some(HoverTarget::MenuBarItem(menu_idx));
907 }
908 }
909 }
910
911 if let Some(active_idx) = self.menu_state.active_menu {
913 if let Some(hover) = self.compute_menu_dropdown_hover(col, row, active_idx) {
914 return Some(hover);
915 }
916 }
917
918 if let Some(explorer_area) = self.active_layout().file_explorer_area {
920 let close_button_x = explorer_area.x + explorer_area.width.saturating_sub(3);
922 if row == explorer_area.y
923 && col >= close_button_x
924 && col < explorer_area.x + explorer_area.width
925 {
926 return Some(HoverTarget::FileExplorerCloseButton);
927 }
928
929 let content_start_y = explorer_area.y + 1; let content_end_y = explorer_area.y + explorer_area.height.saturating_sub(1); let status_indicator_x = explorer_area.x + explorer_area.width.saturating_sub(3); if row >= content_start_y
936 && row < content_end_y
937 && col >= status_indicator_x
938 && col < explorer_area.x + explorer_area.width.saturating_sub(1)
939 {
940 if let Some(explorer) = self.file_explorer().as_ref() {
942 let relative_row = row.saturating_sub(content_start_y) as usize;
943 let scroll_offset = explorer.get_scroll_offset();
944 let item_index = relative_row + scroll_offset;
945 let display_nodes = explorer.get_display_nodes();
946
947 if item_index < display_nodes.len() {
948 let (node_id, _indent) = display_nodes[item_index];
949 if let Some(node) = explorer.tree().get_node(node_id) {
950 return Some(HoverTarget::FileExplorerStatusIndicator(
951 node.entry.path.clone(),
952 ));
953 }
954 }
955 }
956 }
957
958 let border_x = explorer_area.x + explorer_area.width.saturating_sub(1);
961 if col == border_x
962 && row >= explorer_area.y
963 && row < explorer_area.y + explorer_area.height
964 {
965 return Some(HoverTarget::FileExplorerBorder);
966 }
967 }
968
969 for (split_id, direction, sep_x, sep_y, sep_length) in &self.active_layout().separator_areas
971 {
972 let is_on_separator = match direction {
973 SplitDirection::Horizontal => {
974 row == *sep_y && col >= *sep_x && col < sep_x + sep_length
975 }
976 SplitDirection::Vertical => {
977 col == *sep_x && row >= *sep_y && row < sep_y + sep_length
978 }
979 };
980
981 if is_on_separator {
982 return Some(HoverTarget::SplitSeparator(*split_id, *direction));
983 }
984 }
985
986 for (split_id, btn_row, start_col, end_col) in &self.active_layout().close_split_areas {
989 if row == *btn_row && col >= *start_col && col < *end_col {
990 return Some(HoverTarget::CloseSplitButton(*split_id));
991 }
992 }
993
994 for (split_id, btn_row, start_col, end_col) in &self.active_layout().maximize_split_areas {
995 if row == *btn_row && col >= *start_col && col < *end_col {
996 return Some(HoverTarget::MaximizeSplitButton(*split_id));
997 }
998 }
999
1000 for (split_id, tab_layout) in &self.active_layout().tab_layouts {
1001 match tab_layout.hit_test(col, row) {
1002 Some(TabHit::CloseButton(target)) => {
1003 return Some(HoverTarget::TabCloseButton(target, *split_id));
1004 }
1005 Some(TabHit::TabName(target)) => {
1006 return Some(HoverTarget::TabName(target, *split_id));
1007 }
1008 Some(TabHit::ScrollLeft)
1009 | Some(TabHit::ScrollRight)
1010 | Some(TabHit::BarBackground)
1011 | None => {}
1012 }
1013 }
1014
1015 for (split_id, _buffer_id, _content_rect, scrollbar_rect, thumb_start, thumb_end) in
1017 &self.active_layout().split_areas
1018 {
1019 if in_rect(col, row, *scrollbar_rect) {
1020 let relative_row = row.saturating_sub(scrollbar_rect.y) as usize;
1021 let is_on_thumb = relative_row >= *thumb_start && relative_row < *thumb_end;
1022
1023 if is_on_thumb {
1024 return Some(HoverTarget::ScrollbarThumb(*split_id));
1025 } else {
1026 return Some(HoverTarget::ScrollbarTrack(*split_id, relative_row as u16));
1027 }
1028 }
1029 }
1030
1031 if let Some((status_row, _status_x, _status_width)) = self.active_chrome().status_bar_area {
1033 if row == status_row {
1034 let indicators = [
1035 (
1036 self.active_chrome().status_bar_line_ending_area,
1037 HoverTarget::StatusBarLineEndingIndicator,
1038 ),
1039 (
1040 self.active_chrome().status_bar_encoding_area,
1041 HoverTarget::StatusBarEncodingIndicator,
1042 ),
1043 (
1044 self.active_chrome().status_bar_language_area,
1045 HoverTarget::StatusBarLanguageIndicator,
1046 ),
1047 (
1048 self.active_chrome().status_bar_lsp_area,
1049 HoverTarget::StatusBarLspIndicator,
1050 ),
1051 (
1052 self.active_chrome().status_bar_remote_area,
1053 HoverTarget::StatusBarRemoteIndicator,
1054 ),
1055 (
1056 self.active_chrome().status_bar_warning_area,
1057 HoverTarget::StatusBarWarningBadge,
1058 ),
1059 ];
1060 for (area, target) in indicators {
1061 if let Some((indicator_row, start, end)) = area {
1062 if row == indicator_row && col >= start && col < end {
1063 return Some(target);
1064 }
1065 }
1066 }
1067 }
1068 }
1069
1070 if let Some(ref layout) = self.active_chrome().search_options_layout {
1072 use crate::view::ui::status_bar::SearchOptionsHover;
1073 if let Some(hover) = layout.checkbox_at(col, row) {
1074 return Some(match hover {
1075 SearchOptionsHover::CaseSensitive => HoverTarget::SearchOptionCaseSensitive,
1076 SearchOptionsHover::WholeWord => HoverTarget::SearchOptionWholeWord,
1077 SearchOptionsHover::Regex => HoverTarget::SearchOptionRegex,
1078 SearchOptionsHover::ConfirmEach => HoverTarget::SearchOptionConfirmEach,
1079 SearchOptionsHover::None => return None,
1080 });
1081 }
1082 }
1083
1084 None
1086 }
1087
1088 pub(super) fn handle_mouse_double_click(&mut self, col: u16, row: u16) -> AnyhowResult<()> {
1091 tracing::debug!("handle_mouse_double_click at col={}, row={}", col, row);
1092
1093 if let Some(r) = self.handle_click_suggestions_confirm(col, row) {
1097 return r;
1098 }
1099
1100 if self.overlay_prompt_active() {
1103 return Ok(());
1104 }
1105
1106 if self.is_mouse_over_any_popup(col, row) {
1108 return Ok(());
1110 } else {
1111 self.dismiss_transient_popups();
1113 }
1114
1115 if self.handle_file_open_double_click(col, row) {
1117 return Ok(());
1118 }
1119
1120 if let Some(explorer_area) = self.active_layout().file_explorer_area {
1122 if col >= explorer_area.x
1123 && col < explorer_area.x + explorer_area.width
1124 && row > explorer_area.y && row < explorer_area.y + explorer_area.height
1126 {
1127 self.file_explorer_open_file()?;
1129 return Ok(());
1130 }
1131 }
1132
1133 let split_areas = self.active_layout().split_areas.clone();
1135 for (split_id, buffer_id, content_rect, _scrollbar_rect, _thumb_start, _thumb_end) in
1136 &split_areas
1137 {
1138 if in_rect(col, row, *content_rect) {
1139 if self.active_window().is_terminal_buffer(*buffer_id) {
1141 self.active_window_mut().key_context =
1142 crate::input::keybindings::KeyContext::Terminal;
1143 return Ok(());
1145 }
1146
1147 self.active_window_mut().key_context =
1148 crate::input::keybindings::KeyContext::Normal;
1149
1150 self.handle_editor_double_click(col, row, *split_id, *buffer_id, *content_rect)?;
1152 return Ok(());
1153 }
1154 }
1155
1156 Ok(())
1157 }
1158
1159 fn handle_editor_double_click(
1161 &mut self,
1162 col: u16,
1163 row: u16,
1164 split_id: LeafId,
1165 buffer_id: BufferId,
1166 content_rect: ratatui::layout::Rect,
1167 ) -> AnyhowResult<()> {
1168 use crate::model::event::Event;
1169
1170 if self.active_window().is_non_scrollable_buffer(buffer_id) {
1174 return Ok(());
1175 }
1176
1177 self.focus_split(split_id, buffer_id);
1179
1180 let cached_mappings = self
1182 .active_layout()
1183 .view_line_mappings
1184 .get(&split_id)
1185 .cloned();
1186
1187 let leaf_id = split_id;
1189 let fallback = self
1190 .windows
1191 .get(&self.active_window)
1192 .and_then(|w| w.buffers.splits())
1193 .map(|(_, vs)| vs)
1194 .expect("active window must have a populated split layout")
1195 .get(&leaf_id)
1196 .map(|vs| vs.viewport.top_byte)
1197 .unwrap_or(0);
1198
1199 let compose_width = self
1201 .windows
1202 .get(&self.active_window)
1203 .and_then(|w| w.buffers.splits())
1204 .map(|(_, vs)| vs)
1205 .expect("active window must have a populated split layout")
1206 .get(&leaf_id)
1207 .and_then(|vs| vs.compose_width);
1208
1209 let gutter_width = self
1213 .active_window()
1214 .buffers
1215 .get(&buffer_id)
1216 .map(|s| s.margins.left_total_width() as u16)
1217 .unwrap_or(0);
1218
1219 let Some(target_position) = super::click_geometry::screen_to_buffer_position(
1220 col,
1221 row,
1222 content_rect,
1223 gutter_width,
1224 &cached_mappings,
1225 fallback,
1226 true, compose_width,
1228 ) else {
1229 return Ok(());
1230 };
1231
1232 let primary_cursor_id = self
1233 .active_window()
1234 .buffers
1235 .splits()
1236 .and_then(|(_, vs)| vs.get(&leaf_id))
1237 .map(|vs| vs.cursors.primary_id())
1238 .unwrap_or(CursorId(0));
1239 let event = Event::MoveCursor {
1240 cursor_id: primary_cursor_id,
1241 old_position: 0,
1242 new_position: target_position,
1243 old_anchor: None,
1244 new_anchor: None,
1245 old_sticky_column: 0,
1246 new_sticky_column: 0,
1247 };
1248
1249 if let Some(event_log) = self.active_window_mut().event_logs.get_mut(&buffer_id) {
1250 event_log.append(event.clone());
1251 }
1252 self.active_window_mut()
1253 .apply_event_to_buffer(buffer_id, leaf_id, &event);
1254
1255 self.handle_action(Action::SelectWord)?;
1257
1258 if let Some(cursor) = self
1260 .windows
1261 .get(&self.active_window)
1262 .and_then(|w| w.buffers.splits())
1263 .map(|(_, vs)| vs)
1264 .expect("active window must have a populated split layout")
1265 .get(&leaf_id)
1266 .map(|vs| vs.cursors.primary())
1267 {
1268 let sel_start = cursor.selection_start();
1271 let sel_end = cursor.selection_end();
1272 self.active_window_mut().mouse_state.dragging_text_selection = true;
1273 self.active_window_mut().mouse_state.drag_selection_split = Some(split_id);
1274 self.active_window_mut().mouse_state.drag_selection_anchor = Some(sel_start);
1275 self.active_window_mut().mouse_state.drag_selection_by_words = true;
1276 self.active_window_mut().mouse_state.drag_selection_word_end = Some(sel_end);
1277 }
1278
1279 Ok(())
1280 }
1281 pub(super) fn handle_mouse_triple_click(&mut self, col: u16, row: u16) -> AnyhowResult<()> {
1284 tracing::debug!("handle_mouse_triple_click at col={}, row={}", col, row);
1285
1286 if self.overlay_prompt_active() {
1289 return Ok(());
1290 }
1291
1292 if self.is_mouse_over_any_popup(col, row) {
1294 return Ok(());
1295 } else {
1296 self.dismiss_transient_popups();
1297 }
1298
1299 let split_areas = self.active_layout().split_areas.clone();
1301 for (split_id, buffer_id, content_rect, _scrollbar_rect, _thumb_start, _thumb_end) in
1302 &split_areas
1303 {
1304 if in_rect(col, row, *content_rect) {
1305 if self.active_window().is_terminal_buffer(*buffer_id) {
1306 return Ok(());
1307 }
1308
1309 self.active_window_mut().key_context =
1310 crate::input::keybindings::KeyContext::Normal;
1311
1312 self.handle_editor_triple_click(col, row, *split_id, *buffer_id, *content_rect)?;
1315 return Ok(());
1316 }
1317 }
1318
1319 Ok(())
1320 }
1321
1322 fn handle_editor_triple_click(
1324 &mut self,
1325 col: u16,
1326 row: u16,
1327 split_id: LeafId,
1328 buffer_id: BufferId,
1329 content_rect: ratatui::layout::Rect,
1330 ) -> AnyhowResult<()> {
1331 use crate::model::event::Event;
1332
1333 if self.active_window().is_non_scrollable_buffer(buffer_id) {
1334 return Ok(());
1335 }
1336
1337 self.focus_split(split_id, buffer_id);
1339
1340 let cached_mappings = self
1342 .active_layout()
1343 .view_line_mappings
1344 .get(&split_id)
1345 .cloned();
1346
1347 let leaf_id = split_id;
1348 let fallback = self
1349 .windows
1350 .get(&self.active_window)
1351 .and_then(|w| w.buffers.splits())
1352 .map(|(_, vs)| vs)
1353 .expect("active window must have a populated split layout")
1354 .get(&leaf_id)
1355 .map(|vs| vs.viewport.top_byte)
1356 .unwrap_or(0);
1357
1358 let compose_width = self
1360 .windows
1361 .get(&self.active_window)
1362 .and_then(|w| w.buffers.splits())
1363 .map(|(_, vs)| vs)
1364 .expect("active window must have a populated split layout")
1365 .get(&leaf_id)
1366 .and_then(|vs| vs.compose_width);
1367
1368 let gutter_width = self
1372 .active_window()
1373 .buffers
1374 .get(&buffer_id)
1375 .map(|s| s.margins.left_total_width() as u16)
1376 .unwrap_or(0);
1377
1378 let Some(target_position) = super::click_geometry::screen_to_buffer_position(
1379 col,
1380 row,
1381 content_rect,
1382 gutter_width,
1383 &cached_mappings,
1384 fallback,
1385 true,
1386 compose_width,
1387 ) else {
1388 return Ok(());
1389 };
1390
1391 let primary_cursor_id = self
1392 .active_window()
1393 .buffers
1394 .splits()
1395 .and_then(|(_, vs)| vs.get(&leaf_id))
1396 .map(|vs| vs.cursors.primary_id())
1397 .unwrap_or(CursorId(0));
1398 let event = Event::MoveCursor {
1399 cursor_id: primary_cursor_id,
1400 old_position: 0,
1401 new_position: target_position,
1402 old_anchor: None,
1403 new_anchor: None,
1404 old_sticky_column: 0,
1405 new_sticky_column: 0,
1406 };
1407
1408 if let Some(event_log) = self.active_window_mut().event_logs.get_mut(&buffer_id) {
1409 event_log.append(event.clone());
1410 }
1411 self.active_window_mut()
1412 .apply_event_to_buffer(buffer_id, leaf_id, &event);
1413
1414 self.handle_action(Action::SelectLine)?;
1416
1417 Ok(())
1418 }
1419
1420 pub(super) fn overlay_prompt_active(&self) -> bool {
1428 self.active_window()
1429 .prompt
1430 .as_ref()
1431 .is_some_and(|p| p.overlay)
1432 }
1433
1434 pub(super) fn handle_mouse_click(
1435 &mut self,
1436 col: u16,
1437 row: u16,
1438 modifiers: crossterm::event::KeyModifiers,
1439 ) -> AnyhowResult<()> {
1440 if self.floating_widget_panel.is_some() {
1446 self.handle_floating_widget_click(col, row);
1447 return Ok(());
1448 }
1449 if let Some(r) = self.handle_click_context_menus(col, row) {
1450 return r;
1451 }
1452 if !self.is_mouse_over_any_popup(col, row) {
1453 self.dismiss_transient_popups();
1454 }
1455 if let Some(r) = self.handle_click_suggestions(col, row) {
1456 return r;
1457 }
1458 if let Some(r) = self.handle_click_prompt_scrollbar(col, row) {
1459 return r;
1460 }
1461 if let Some(r) = self.handle_click_popup_scrollbar(col, row) {
1462 return r;
1463 }
1464 if let Some(r) = self.handle_click_global_popups(col, row) {
1465 return r;
1466 }
1467 if let Some(r) = self.handle_click_buffer_popups(col, row) {
1468 return r;
1469 }
1470 if self.is_mouse_over_any_popup(col, row) {
1471 return Ok(());
1472 }
1473 if self.is_file_open_active() && self.handle_file_open_click(col, row) {
1474 return Ok(());
1475 }
1476 if let Some(r) = self.handle_click_menu_bar(col, row) {
1477 return r;
1478 }
1479 if let Some(r) = self.handle_click_file_explorer_area(col, row) {
1480 return r;
1481 }
1482 if let Some(r) = self.handle_click_scrollbar(col, row) {
1483 return r;
1484 }
1485 if let Some(r) = self.handle_click_horizontal_scrollbar(col, row) {
1486 return r;
1487 }
1488 if let Some(r) = self.handle_click_status_bar(col, row) {
1489 return r;
1490 }
1491 if let Some(r) = self.handle_click_search_options(col, row) {
1492 return r;
1493 }
1494 if let Some(r) = self.handle_click_split_separator(col, row) {
1495 return r;
1496 }
1497 if let Some(r) = self.handle_click_split_controls(col, row) {
1498 return r;
1499 }
1500 if let Some(r) = self.handle_click_tab_bar(col, row) {
1501 return r;
1502 }
1503
1504 if self.overlay_prompt_active() {
1511 let hit = self
1512 .active_chrome()
1513 .prompt_toolbar_hits
1514 .iter()
1515 .find(|(_, r)| in_rect(col, row, *r))
1516 .map(|(k, _)| k.clone());
1517 if let Some(widget_key) = hit {
1518 if let Some(p) = self.active_window_mut().prompt.as_mut() {
1522 p.toolbar_focus = Some(widget_key.clone());
1523 }
1524 self.toggle_overlay_toolbar_widget(&widget_key);
1525 }
1526 return Ok(());
1527 }
1528
1529 tracing::debug!(
1531 "handle_mouse_click: checking {} split_areas for click at ({}, {})",
1532 self.active_layout().split_areas.len(),
1533 col,
1534 row
1535 );
1536 for (split_id, buffer_id, content_rect, _scrollbar_rect, _thumb_start, _thumb_end) in
1537 &self.active_layout().split_areas
1538 {
1539 tracing::debug!(
1540 " split_id={:?}, content_rect=({}, {}, {}x{})",
1541 split_id,
1542 content_rect.x,
1543 content_rect.y,
1544 content_rect.width,
1545 content_rect.height
1546 );
1547 if in_rect(col, row, *content_rect) {
1548 tracing::debug!(" -> HIT! calling handle_editor_click");
1550 self.handle_editor_click(
1551 col,
1552 row,
1553 *split_id,
1554 *buffer_id,
1555 *content_rect,
1556 modifiers,
1557 )?;
1558 return Ok(());
1559 }
1560 }
1561 tracing::debug!(" -> No split area hit");
1562
1563 Ok(())
1564 }
1565
1566 fn handle_click_context_menus(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
1570 if self
1571 .active_window_mut()
1572 .file_explorer_context_menu
1573 .is_some()
1574 {
1575 if let Some(result) = self.handle_file_explorer_context_menu_click(col, row) {
1576 return Some(result);
1577 }
1578 }
1579 if self.active_window_mut().tab_context_menu.is_some() {
1580 if let Some(result) = self.handle_tab_context_menu_click(col, row) {
1581 return Some(result);
1582 }
1583 }
1584 None
1585 }
1586
1587 fn suggestion_at(&self, col: u16, row: u16) -> Option<usize> {
1591 let (inner_rect, start_idx, _visible_count, total_count) =
1592 self.active_chrome().suggestions_area?;
1593 if col < inner_rect.x
1594 || col >= inner_rect.x + inner_rect.width
1595 || row < inner_rect.y
1596 || row >= inner_rect.y + inner_rect.height
1597 {
1598 return None;
1599 }
1600 let relative_row = (row - inner_rect.y) as usize;
1601 let item_idx = start_idx + relative_row;
1602 if item_idx < total_count {
1603 Some(item_idx)
1604 } else {
1605 None
1606 }
1607 }
1608
1609 fn handle_click_suggestions(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
1610 let item_idx = self.suggestion_at(col, row)?;
1611 let prompt = self.active_window_mut().prompt.as_mut()?;
1612 prompt.selected_suggestion = Some(item_idx);
1613 let confirms = prompt.prompt_type.click_confirms();
1614 if !confirms {
1615 if let Some(suggestion) = prompt.suggestions.get(item_idx) {
1619 prompt.input = suggestion.get_value().to_string();
1620 prompt.cursor_pos = prompt.input.len();
1621 }
1622 }
1623 if confirms {
1624 return Some(self.handle_action(Action::PromptConfirm));
1625 }
1626 Some(Ok(()))
1627 }
1628
1629 fn handle_click_suggestions_confirm(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
1633 let item_idx = self.suggestion_at(col, row)?;
1634 let prompt = self.active_window_mut().prompt.as_mut()?;
1635 prompt.selected_suggestion = Some(item_idx);
1636 if let Some(suggestion) = prompt.suggestions.get(item_idx) {
1637 prompt.input = suggestion.get_value().to_string();
1638 prompt.cursor_pos = prompt.input.len();
1639 }
1640 Some(self.handle_action(Action::PromptConfirm))
1641 }
1642
1643 fn handle_click_prompt_scrollbar(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
1649 use crate::view::ui::scrollbar::ScrollbarState;
1650 let sb_rect = self.active_chrome().suggestions_scrollbar_rect?;
1651 if col < sb_rect.x
1652 || col >= sb_rect.x + sb_rect.width
1653 || row < sb_rect.y
1654 || row >= sb_rect.y + sb_rect.height
1655 {
1656 return None;
1657 }
1658 let suggestions_area_visible = self.active_chrome().suggestions_area.map(|(_, _, v, _)| v);
1666 let active_window_id = self.active_window;
1667 let prompt = self
1668 .windows
1669 .get_mut(&active_window_id)
1670 .and_then(|w| w.prompt.as_mut())?;
1671 let visible = suggestions_area_visible.unwrap_or(prompt.suggestions.len().min(10));
1672 let total = prompt.suggestions.len();
1673 let track_height = sb_rect.height as usize;
1674 let click_row = row.saturating_sub(sb_rect.y) as usize;
1675 let state = ScrollbarState::new(total, visible, prompt.scroll_offset);
1676 prompt.scroll_offset = state.click_to_offset(track_height, click_row);
1677 self.active_window_mut()
1680 .mouse_state
1681 .dragging_prompt_scrollbar = true;
1682 Some(Ok(()))
1683 }
1684
1685 fn handle_click_popup_scrollbar(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
1686 let scrollbar_info: Option<(usize, i32)> =
1688 self.active_chrome().popup_areas.iter().rev().find_map(
1689 |(popup_idx, _popup_rect, inner_rect, _scroll, _n, scrollbar_rect, total_lines)| {
1690 let sb_rect = scrollbar_rect.as_ref()?;
1691 if col >= sb_rect.x
1692 && col < sb_rect.x + sb_rect.width
1693 && row >= sb_rect.y
1694 && row < sb_rect.y + sb_rect.height
1695 {
1696 let relative_row = (row - sb_rect.y) as usize;
1697 let track_height = sb_rect.height as usize;
1698 let visible_lines = inner_rect.height as usize;
1699 if track_height > 0 && *total_lines > visible_lines {
1700 let max_scroll = total_lines.saturating_sub(visible_lines);
1701 let target = if track_height > 1 {
1702 (relative_row * max_scroll) / (track_height.saturating_sub(1))
1703 } else {
1704 0
1705 };
1706 Some((*popup_idx, target as i32))
1707 } else {
1708 Some((*popup_idx, 0))
1709 }
1710 } else {
1711 None
1712 }
1713 },
1714 );
1715 let (popup_idx, target_scroll) = scrollbar_info?;
1716 self.active_window_mut()
1717 .mouse_state
1718 .dragging_popup_scrollbar = Some(popup_idx);
1719 self.active_window_mut().mouse_state.drag_start_row = Some(row);
1720 let current_scroll = self
1721 .active_state()
1722 .popups
1723 .get(popup_idx)
1724 .map(|p| p.scroll_offset)
1725 .unwrap_or(0);
1726 self.active_window_mut().mouse_state.drag_start_popup_scroll = Some(current_scroll);
1727 let state = self.active_state_mut();
1728 if let Some(popup) = state.popups.get_mut(popup_idx) {
1729 popup.scroll_by(target_scroll - current_scroll as i32);
1730 }
1731 Some(Ok(()))
1732 }
1733
1734 fn handle_workspace_trust_mouse(
1740 &mut self,
1741 mouse_event: crossterm::event::MouseEvent,
1742 ) -> AnyhowResult<bool> {
1743 use crossterm::event::{MouseButton, MouseEventKind};
1744 let col = mouse_event.column;
1745 let row = mouse_event.row;
1746 let layout = self.active_chrome().workspace_trust_dialog.clone();
1747
1748 match mouse_event.kind {
1749 MouseEventKind::ScrollUp => {
1750 self.workspace_trust_scroll = self.workspace_trust_scroll.saturating_sub(2);
1751 }
1752 MouseEventKind::ScrollDown => {
1753 let max = layout.as_ref().map(|l| l.max_scroll).unwrap_or(0);
1754 self.workspace_trust_scroll = (self.workspace_trust_scroll + 2).min(max);
1755 }
1756 MouseEventKind::Down(MouseButton::Left) => {
1757 if let Some(layout) = layout {
1758 let hit = |r: ratatui::layout::Rect| in_rect(col, row, r);
1759 if hit(layout.ok) {
1760 let idx = self.current_workspace_trust_selection();
1761 self.confirm_workspace_trust(idx);
1762 } else if hit(layout.quit) {
1763 self.hide_popup();
1766 if !self.workspace_trust_prompt_cancellable {
1767 self.should_quit = true;
1768 }
1769 } else if let Some(i) = layout.radios.iter().position(|r| hit(*r)) {
1770 self.confirm_workspace_trust(i);
1771 }
1772 }
1774 }
1775 _ => {}
1777 }
1778 Ok(true)
1779 }
1780
1781 fn handle_click_global_popups(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
1782 for (popup_idx, popup_rect, inner_rect, scroll_offset, num_items) in self
1783 .active_chrome()
1784 .global_popup_areas
1785 .clone()
1786 .into_iter()
1787 .rev()
1788 {
1789 if popup_rect.width >= 5 {
1790 let cb_x = popup_rect.x + popup_rect.width - 4;
1791 if row == popup_rect.y && col >= cb_x && col < cb_x + 3 {
1792 return Some(self.handle_action(Action::PopupCancel));
1793 }
1794 }
1795 if in_rect(col, row, inner_rect) && num_items > 0 {
1796 let relative_row = (row - inner_rect.y) as usize;
1797 let item_idx = scroll_offset + relative_row;
1798 if item_idx < num_items {
1799 if let Some(popup) = self.global_popups.get_mut(popup_idx) {
1800 if let crate::view::popup::PopupContent::List { items: _, selected } =
1801 &mut popup.content
1802 {
1803 *selected = item_idx;
1804 }
1805 }
1806 return Some(self.handle_action(Action::PopupConfirm));
1807 }
1808 }
1809 }
1810 None
1811 }
1812
1813 fn handle_click_buffer_popups(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
1814 let close_hit = self.active_chrome().popup_areas.iter().rev().find_map(
1816 |(_idx, popup_rect, _inner, _scroll, _n, _sb, _tl)| {
1817 if popup_rect.width < 5 {
1818 return None;
1819 }
1820 let cb_x = popup_rect.x + popup_rect.width - 4;
1821 if row == popup_rect.y && col >= cb_x && col < cb_x + 3 {
1822 Some(())
1823 } else {
1824 None
1825 }
1826 },
1827 );
1828 if close_hit.is_some() {
1829 return Some(self.handle_action(Action::PopupCancel));
1830 }
1831
1832 let popup_areas = self.active_chrome().popup_areas.clone();
1834 for (popup_idx, _popup_rect, inner_rect, scroll_offset, num_items, _, _) in
1835 popup_areas.iter().rev()
1836 {
1837 if !in_rect(col, row, *inner_rect) {
1838 continue;
1839 }
1840 let relative_col = (col - inner_rect.x) as usize;
1841 let relative_row = (row - inner_rect.y) as usize;
1842
1843 let link_url = {
1844 let state = self.active_state();
1845 state
1846 .popups
1847 .top()
1848 .and_then(|p| p.link_at_position(relative_col, relative_row))
1849 };
1850 if let Some(url) = link_url {
1851 #[cfg(feature = "runtime")]
1852 if let Err(e) = open::that(&url) {
1853 self.set_status_message(format!("Failed to open URL: {}", e));
1854 } else {
1855 self.set_status_message(format!("Opening: {}", url));
1856 }
1857 return Some(Ok(()));
1858 }
1859
1860 if *num_items > 0 {
1861 let item_idx = scroll_offset + relative_row;
1862 if item_idx < *num_items {
1863 let state = self.active_state_mut();
1864 if let Some(popup) = state.popups.top_mut() {
1865 if let crate::view::popup::PopupContent::List { items: _, selected } =
1866 &mut popup.content
1867 {
1868 *selected = item_idx;
1869 }
1870 }
1871 return Some(self.handle_action(Action::PopupConfirm));
1872 }
1873 }
1874
1875 let is_text_popup = {
1876 let state = self.active_state();
1877 state.popups.top().is_some_and(|p| {
1878 matches!(
1879 p.content,
1880 crate::view::popup::PopupContent::Text(_)
1881 | crate::view::popup::PopupContent::Markdown(_)
1882 )
1883 })
1884 };
1885 if is_text_popup {
1886 let line = scroll_offset + relative_row;
1887 let popup_idx_copy = *popup_idx;
1888 let state = self.active_state_mut();
1889 if let Some(popup) = state.popups.top_mut() {
1890 popup.start_selection(line, relative_col);
1891 }
1892 self.active_window_mut().mouse_state.selecting_in_popup = Some(popup_idx_copy);
1893 return Some(Ok(()));
1894 }
1895 }
1896 None
1897 }
1898
1899 fn handle_click_menu_bar(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
1900 if self.active_window_mut().menu_bar_visible {
1901 let hit = self
1903 .active_chrome()
1904 .menu_layout
1905 .as_ref()
1906 .and_then(|ml| ml.menu_at(col, row));
1907 let layout_exists = self.active_chrome().menu_layout.is_some();
1908 if layout_exists {
1909 if let Some(menu_idx) = hit {
1910 if self.menu_state.active_menu == Some(menu_idx) {
1911 self.close_menu_with_auto_hide();
1912 } else {
1913 self.active_window_mut().on_editor_focus_lost();
1914 self.menu_state.open_menu(menu_idx);
1915 }
1916 return Some(Ok(()));
1917 } else if row == 0 {
1918 self.close_menu_with_auto_hide();
1919 return Some(Ok(()));
1920 }
1921 }
1922 }
1923
1924 if let Some(active_idx) = self.menu_state.active_menu {
1925 let all_menus: Vec<crate::config::Menu> = self
1926 .menus
1927 .menus
1928 .iter()
1929 .chain(self.menu_state.plugin_menus.iter())
1930 .cloned()
1931 .collect();
1932 if let Some(menu) = all_menus.get(active_idx) {
1933 match self.handle_menu_dropdown_click(col, row, menu) {
1934 Ok(Some(click_result)) => return Some(click_result),
1935 Ok(None) => {}
1936 Err(e) => return Some(Err(e)),
1937 }
1938 }
1939 self.close_menu_with_auto_hide();
1940 return Some(Ok(()));
1941 }
1942
1943 None
1944 }
1945
1946 fn handle_click_file_explorer_area(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
1947 let explorer_area = self.active_layout().file_explorer_area?;
1948 let border_x = explorer_area.x + explorer_area.width.saturating_sub(1);
1949 if col == border_x && row >= explorer_area.y && row < explorer_area.y + explorer_area.height
1950 {
1951 self.active_window_mut().mouse_state.dragging_file_explorer = true;
1952 self.active_window_mut().mouse_state.drag_start_position = Some((col, row));
1953 self.active_window_mut()
1954 .mouse_state
1955 .drag_start_explorer_width = Some(self.active_window().file_explorer_width);
1956 return Some(Ok(()));
1957 }
1958 if in_rect(col, row, explorer_area) {
1959 return Some(self.handle_file_explorer_click(col, row, explorer_area));
1960 }
1961 None
1962 }
1963
1964 fn handle_click_scrollbar(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
1965 let (split_id, buffer_id, scrollbar_rect, is_on_thumb) =
1966 self.active_layout().split_areas.iter().find_map(
1967 |(split_id, buffer_id, _content, scrollbar_rect, thumb_start, thumb_end)| {
1968 if in_rect(col, row, *scrollbar_rect) {
1969 let relative_row = row.saturating_sub(scrollbar_rect.y) as usize;
1970 let on_thumb = relative_row >= *thumb_start && relative_row < *thumb_end;
1971 Some((*split_id, *buffer_id, *scrollbar_rect, on_thumb))
1972 } else {
1973 None
1974 }
1975 },
1976 )?;
1977
1978 self.focus_split(split_id, buffer_id);
1979 if is_on_thumb {
1980 self.active_window_mut().mouse_state.dragging_scrollbar = Some(split_id);
1981 self.active_window_mut().mouse_state.drag_start_row = Some(row);
1982 if self.active_window().is_composite_buffer(buffer_id) {
1983 if let Some(vs) = self
1984 .active_window()
1985 .composite_view_states
1986 .get(&(split_id, buffer_id))
1987 {
1988 self.active_window_mut()
1989 .mouse_state
1990 .drag_start_composite_scroll_row = Some(vs.scroll_row);
1991 }
1992 } else {
1993 let snap = self
1994 .windows
1995 .get(&self.active_window)
1996 .and_then(|w| w.buffers.splits())
1997 .map(|(_, vs)| vs)
1998 .expect("active window must have a populated split layout")
1999 .get(&split_id)
2000 .map(|vs| (vs.viewport.top_byte, vs.viewport.top_view_line_offset));
2001 if let Some((top_byte, top_view_line_offset)) = snap {
2002 let ms = &mut self.active_window_mut().mouse_state;
2003 ms.drag_start_top_byte = Some(top_byte);
2004 ms.drag_start_view_line_offset = Some(top_view_line_offset);
2005 }
2006 }
2007 } else {
2008 self.active_window_mut().mouse_state.dragging_scrollbar = Some(split_id);
2009 if let Err(e) = self.active_window_mut().handle_scrollbar_jump(
2010 col,
2011 row,
2012 split_id,
2013 buffer_id,
2014 scrollbar_rect,
2015 ) {
2016 return Some(Err(e));
2017 }
2018 self.active_window_mut().mouse_state.hover_target =
2019 Some(HoverTarget::ScrollbarThumb(split_id));
2020 }
2021 Some(Ok(()))
2022 }
2023
2024 fn handle_click_horizontal_scrollbar(
2025 &mut self,
2026 col: u16,
2027 row: u16,
2028 ) -> Option<AnyhowResult<()>> {
2029 let (split_id, buffer_id, hscrollbar_rect, max_content_width, is_on_thumb) = self
2030 .active_layout()
2031 .horizontal_scrollbar_areas
2032 .iter()
2033 .find_map(
2034 |(
2035 split_id,
2036 buffer_id,
2037 hscrollbar_rect,
2038 max_content_width,
2039 thumb_start,
2040 thumb_end,
2041 )| {
2042 if col >= hscrollbar_rect.x
2043 && col < hscrollbar_rect.x + hscrollbar_rect.width
2044 && row >= hscrollbar_rect.y
2045 && row < hscrollbar_rect.y + hscrollbar_rect.height
2046 {
2047 let relative_col = col.saturating_sub(hscrollbar_rect.x) as usize;
2048 let on_thumb = relative_col >= *thumb_start && relative_col < *thumb_end;
2049 Some((
2050 *split_id,
2051 *buffer_id,
2052 *hscrollbar_rect,
2053 *max_content_width,
2054 on_thumb,
2055 ))
2056 } else {
2057 None
2058 }
2059 },
2060 )?;
2061
2062 self.focus_split(split_id, buffer_id);
2063 self.active_window_mut()
2064 .mouse_state
2065 .dragging_horizontal_scrollbar = Some(split_id);
2066 if is_on_thumb {
2067 self.active_window_mut().mouse_state.drag_start_hcol = Some(col);
2068 if let Some(vs) = self
2069 .windows
2070 .get(&self.active_window)
2071 .and_then(|w| w.buffers.splits())
2072 .map(|(_, vs)| vs)
2073 .expect("active window must have a populated split layout")
2074 .get(&split_id)
2075 {
2076 self.active_window_mut().mouse_state.drag_start_left_column =
2077 Some(vs.viewport.left_column);
2078 }
2079 } else {
2080 self.active_window_mut().mouse_state.drag_start_hcol = None;
2081 self.active_window_mut().mouse_state.drag_start_left_column = None;
2082 let relative_col = col.saturating_sub(hscrollbar_rect.x) as f64;
2083 let track_width = hscrollbar_rect.width as f64;
2084 let ratio = if track_width > 1.0 {
2085 (relative_col / (track_width - 1.0)).clamp(0.0, 1.0)
2086 } else {
2087 0.0
2088 };
2089 if let Some(vs) = self
2090 .windows
2091 .get_mut(&self.active_window)
2092 .and_then(|w| w.split_view_states_mut())
2093 .expect("active window must have a populated split layout")
2094 .get_mut(&split_id)
2095 {
2096 let visible_width = vs.viewport.width as usize;
2097 let max_scroll = max_content_width.saturating_sub(visible_width);
2098 let target_col = (ratio * max_scroll as f64).round() as usize;
2099 vs.viewport.left_column = target_col.min(max_scroll);
2100 vs.viewport.set_skip_ensure_visible();
2101 }
2102 }
2103 Some(Ok(()))
2104 }
2105
2106 fn handle_click_status_bar(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
2107 let (status_row, _status_x, _status_width) = self.active_chrome().status_bar_area?;
2108 if row != status_row {
2109 return None;
2110 }
2111 if let Some((r, s, e)) = self.active_chrome().status_bar_line_ending_area {
2121 if row == r && col >= s && col < e {
2122 self.dismiss_menu_popups_for_prompt();
2123 return Some(self.handle_action(Action::SetLineEnding));
2124 }
2125 }
2126 if let Some((r, s, e)) = self.active_chrome().status_bar_encoding_area {
2127 if row == r && col >= s && col < e {
2128 self.dismiss_menu_popups_for_prompt();
2129 return Some(self.handle_action(Action::SetEncoding));
2130 }
2131 }
2132 if let Some((r, s, e)) = self.active_chrome().status_bar_language_area {
2133 if row == r && col >= s && col < e {
2134 self.dismiss_menu_popups_for_prompt();
2135 return Some(self.handle_action(Action::SetLanguage));
2136 }
2137 }
2138 if let Some((r, s, e)) = self.active_chrome().status_bar_lsp_area {
2139 if row == r && col >= s && col < e {
2140 return Some(self.handle_action(Action::ShowLspStatus));
2143 }
2144 }
2145 if let Some((r, s, e)) = self.active_chrome().status_bar_remote_area {
2146 if row == r && col >= s && col < e {
2147 self.dismiss_menu_popups_for_prompt();
2148 return Some(self.handle_action(Action::ShowRemoteIndicatorMenu));
2149 }
2150 }
2151 if let Some((r, s, e)) = self.active_chrome().status_bar_warning_area {
2152 if row == r && col >= s && col < e {
2153 self.dismiss_menu_popups_for_prompt();
2154 return Some(self.handle_action(Action::ShowWarnings));
2155 }
2156 }
2157 if let Some((r, s, e)) = self.active_chrome().status_bar_message_area {
2158 if row == r && col >= s && col < e {
2159 return Some(self.handle_action(Action::ShowStatusLog));
2160 }
2161 }
2162 None
2163 }
2164
2165 fn handle_click_search_options(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
2166 use crate::view::ui::status_bar::SearchOptionsHover;
2167 let layout = self.active_chrome().search_options_layout.clone()?;
2168 match layout.checkbox_at(col, row)? {
2169 SearchOptionsHover::CaseSensitive => {
2170 Some(self.handle_action(Action::ToggleSearchCaseSensitive))
2171 }
2172 SearchOptionsHover::WholeWord => {
2173 Some(self.handle_action(Action::ToggleSearchWholeWord))
2174 }
2175 SearchOptionsHover::Regex => Some(self.handle_action(Action::ToggleSearchRegex)),
2176 SearchOptionsHover::ConfirmEach => {
2177 Some(self.handle_action(Action::ToggleSearchConfirmEach))
2178 }
2179 SearchOptionsHover::None => None,
2180 }
2181 }
2182
2183 fn handle_click_split_separator(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
2184 let separator_areas = self.active_layout().separator_areas.clone();
2185 for (split_id, direction, sep_x, sep_y, sep_length) in &separator_areas {
2186 let is_on_separator = match direction {
2187 SplitDirection::Horizontal => {
2188 row == *sep_y && col >= *sep_x && col < sep_x + sep_length
2189 }
2190 SplitDirection::Vertical => {
2191 col == *sep_x && row >= *sep_y && row < sep_y + sep_length
2192 }
2193 };
2194 if is_on_separator {
2195 self.active_window_mut().mouse_state.dragging_separator =
2196 Some((*split_id, *direction));
2197 self.active_window_mut().mouse_state.drag_start_position = Some((col, row));
2198 let ratio = self
2199 .split_manager_mut()
2200 .get_ratio((*split_id).into())
2201 .or_else(|| self.grouped_split_ratio(*split_id));
2202 if let Some(ratio) = ratio {
2203 self.active_window_mut().mouse_state.drag_start_ratio = Some(ratio);
2204 }
2205 return Some(Ok(()));
2206 }
2207 }
2208 None
2209 }
2210
2211 fn handle_click_split_controls(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
2212 let close_split_id = self
2213 .active_layout()
2214 .close_split_areas
2215 .iter()
2216 .find(|(_, btn_row, start_col, end_col)| {
2217 row == *btn_row && col >= *start_col && col < *end_col
2218 })
2219 .map(|(split_id, _, _, _)| *split_id);
2220 if let Some(split_id) = close_split_id {
2221 if let Err(e) = self
2222 .windows
2223 .get_mut(&self.active_window)
2224 .and_then(|w| w.split_manager_mut())
2225 .expect("active window must have a populated split layout")
2226 .close_split(split_id)
2227 {
2228 self.set_status_message(
2229 t!("error.cannot_close_split", error = e.to_string()).to_string(),
2230 );
2231 } else {
2232 let new_active = self
2233 .windows
2234 .get(&self.active_window)
2235 .and_then(|w| w.buffers.splits())
2236 .map(|(mgr, _)| mgr)
2237 .expect("active window must have a populated split layout")
2238 .active_split();
2239 if let Some(buffer_id) = self
2240 .windows
2241 .get(&self.active_window)
2242 .and_then(|w| w.buffers.splits())
2243 .map(|(mgr, _)| mgr)
2244 .expect("active window must have a populated split layout")
2245 .buffer_for_split(new_active)
2246 {
2247 self.set_active_buffer(buffer_id);
2248 }
2249 self.set_status_message(t!("split.closed").to_string());
2250 }
2251 return Some(Ok(()));
2252 }
2253
2254 let maximize_target = self
2255 .active_layout()
2256 .maximize_split_areas
2257 .iter()
2258 .find(|(_, btn_row, start_col, end_col)| {
2259 row == *btn_row && col >= *start_col && col < *end_col
2260 })
2261 .map(|(split_id, _, _, _)| *split_id);
2262 if let Some(target) = maximize_target {
2263 let already_maximized = self
2270 .windows
2271 .get(&self.active_window)
2272 .and_then(|w| w.buffers.splits())
2273 .map(|(mgr, _)| mgr.is_maximized())
2274 .unwrap_or(false);
2275 if !already_maximized {
2276 if let Some(buffer_id) = self
2277 .windows
2278 .get(&self.active_window)
2279 .and_then(|w| w.buffers.splits())
2280 .map(|(mgr, _)| mgr)
2281 .expect("active window must have a populated split layout")
2282 .buffer_for_split(target)
2283 {
2284 self.focus_split(target, buffer_id);
2285 }
2286 }
2287 match self
2288 .windows
2289 .get_mut(&self.active_window)
2290 .and_then(|w| w.split_manager_mut())
2291 .expect("active window must have a populated split layout")
2292 .toggle_maximize_for(target)
2293 {
2294 Ok(maximized) => {
2295 let msg = if maximized {
2296 t!("split.maximized").to_string()
2297 } else {
2298 t!("split.restored").to_string()
2299 };
2300 self.set_status_message(msg);
2301 }
2302 Err(e) => self.set_status_message(e),
2303 }
2304 return Some(Ok(()));
2305 }
2306
2307 None
2308 }
2309
2310 fn handle_click_tab_bar(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
2311 for (split_id, tab_layout) in &self.active_layout().tab_layouts {
2312 tracing::debug!(
2313 "Tab layout for split {:?}: bar_area={:?}, left_scroll={:?}, right_scroll={:?}",
2314 split_id,
2315 tab_layout.bar_area,
2316 tab_layout.left_scroll_area,
2317 tab_layout.right_scroll_area
2318 );
2319 }
2320 let tab_hit = self
2321 .active_layout()
2322 .tab_layouts
2323 .iter()
2324 .find_map(|(split_id, tab_layout)| {
2325 let hit = tab_layout.hit_test(col, row);
2326 tracing::debug!(
2327 "Tab hit_test at ({}, {}) for split {:?} returned {:?}",
2328 col,
2329 row,
2330 split_id,
2331 hit
2332 );
2333 hit.map(|h| (*split_id, h))
2334 });
2335 let (split_id, hit) = tab_hit?;
2336 match hit {
2337 TabHit::CloseButton(target) => {
2338 match target {
2339 crate::view::split::TabTarget::Buffer(buffer_id) => {
2340 self.focus_split(split_id, buffer_id);
2341 self.close_tab_in_split(buffer_id, split_id);
2342 }
2343 crate::view::split::TabTarget::Group(group_leaf) => {
2344 self.close_buffer_group_by_leaf(group_leaf);
2345 }
2346 }
2347 Some(Ok(()))
2348 }
2349 TabHit::TabName(target) => {
2350 let direction = self
2351 .windows
2352 .get(&self.active_window)
2353 .and_then(|w| w.buffers.splits())
2354 .map(|(_, vs)| vs)
2355 .expect("active window must have a populated split layout")
2356 .get(&split_id)
2357 .map(|vs| {
2358 let open = &vs.open_buffers;
2359 let cur = vs.active_target();
2360 let cur_idx = open.iter().position(|t| *t == cur);
2361 let new_idx = open.iter().position(|t| *t == target);
2362 match (cur_idx, new_idx) {
2363 (Some(c), Some(n)) if n > c => 1,
2364 (Some(c), Some(n)) if n < c => -1,
2365 _ => 0,
2366 }
2367 })
2368 .unwrap_or(0);
2369 self.active_window_mut()
2370 .animate_tab_switch(split_id, direction);
2371 match target {
2372 crate::view::split::TabTarget::Buffer(buffer_id) => {
2373 self.focus_split(split_id, buffer_id);
2374 self.active_window_mut()
2375 .promote_buffer_from_preview(buffer_id);
2376 self.active_window_mut().mouse_state.dragging_tab = Some(
2377 super::types::TabDragState::new(buffer_id, split_id, (col, row)),
2378 );
2379 }
2380 crate::view::split::TabTarget::Group(group_leaf) => {
2381 self.activate_group_tab(split_id, group_leaf);
2382 }
2383 }
2384 Some(Ok(()))
2385 }
2386 TabHit::ScrollLeft => {
2387 self.set_status_message("ScrollLeft clicked!".to_string());
2388 if let Some(vs) = self
2389 .windows
2390 .get_mut(&self.active_window)
2391 .and_then(|w| w.split_view_states_mut())
2392 .expect("active window must have a populated split layout")
2393 .get_mut(&split_id)
2394 {
2395 vs.tab_scroll_offset = vs.tab_scroll_offset.saturating_sub(10);
2396 }
2397 Some(Ok(()))
2398 }
2399 TabHit::ScrollRight => {
2400 self.set_status_message("ScrollRight clicked!".to_string());
2401 if let Some(vs) = self
2402 .windows
2403 .get_mut(&self.active_window)
2404 .and_then(|w| w.split_view_states_mut())
2405 .expect("active window must have a populated split layout")
2406 .get_mut(&split_id)
2407 {
2408 vs.tab_scroll_offset = vs.tab_scroll_offset.saturating_add(10);
2409 }
2410 Some(Ok(()))
2411 }
2412 TabHit::BarBackground => None,
2413 }
2414 }
2415
2416 pub(super) fn handle_mouse_drag(&mut self, col: u16, row: u16) -> AnyhowResult<()> {
2418 if self.try_widget_scrollbar_drag(row) {
2421 let _ = col;
2422 return Ok(());
2423 }
2424 if self.overlay_prompt_active()
2429 && !self.active_window().mouse_state.dragging_prompt_scrollbar
2430 {
2431 return Ok(());
2432 }
2433
2434 if let Some(dragging_split_id) = self.active_window_mut().mouse_state.dragging_scrollbar {
2436 let split_areas = self.active_layout().split_areas.clone();
2439 for (split_id, buffer_id, _content_rect, scrollbar_rect, _thumb_start, _thumb_end) in
2440 &split_areas
2441 {
2442 if *split_id == dragging_split_id {
2443 if self.active_window().mouse_state.drag_start_row.is_some() {
2445 self.active_window_mut().handle_scrollbar_drag_relative(
2447 row,
2448 *split_id,
2449 *buffer_id,
2450 *scrollbar_rect,
2451 )?;
2452 } else {
2453 self.active_window_mut().handle_scrollbar_jump(
2455 col,
2456 row,
2457 *split_id,
2458 *buffer_id,
2459 *scrollbar_rect,
2460 )?;
2461 }
2462 return Ok(());
2463 }
2464 }
2465 }
2466
2467 if let Some(dragging_split_id) = self
2469 .active_window_mut()
2470 .mouse_state
2471 .dragging_horizontal_scrollbar
2472 {
2473 let hscrollbar_areas = self.active_layout().horizontal_scrollbar_areas.clone();
2478 for (
2479 split_id,
2480 _buffer_id,
2481 hscrollbar_rect,
2482 max_content_width,
2483 thumb_start,
2484 thumb_end,
2485 ) in &hscrollbar_areas
2486 {
2487 if *split_id == dragging_split_id {
2488 let track_width = hscrollbar_rect.width as f64;
2489 if track_width <= 1.0 {
2490 break;
2491 }
2492
2493 if let (Some(drag_start_hcol), Some(drag_start_left_column)) = (
2494 self.active_window_mut().mouse_state.drag_start_hcol,
2495 self.active_window_mut().mouse_state.drag_start_left_column,
2496 ) {
2497 let col_offset = (col as i32) - (drag_start_hcol as i32);
2500 if let Some(view_state) = self
2501 .windows
2502 .get_mut(&self.active_window)
2503 .and_then(|w| w.split_view_states_mut())
2504 .expect("active window must have a populated split layout")
2505 .get_mut(&dragging_split_id)
2506 {
2507 let visible_width = view_state.viewport.width as usize;
2508 let max_scroll = max_content_width.saturating_sub(visible_width);
2509 if max_scroll > 0 {
2510 let thumb_size = thumb_end.saturating_sub(*thumb_start).max(1);
2511 let track_travel = (track_width - thumb_size as f64).max(1.0);
2512 let scroll_per_pixel = max_scroll as f64 / track_travel;
2513 let scroll_offset =
2514 (col_offset as f64 * scroll_per_pixel).round() as i64;
2515 let new_left =
2516 (drag_start_left_column as i64 + scroll_offset).max(0) as usize;
2517 view_state.viewport.left_column = new_left.min(max_scroll);
2518 view_state.viewport.set_skip_ensure_visible();
2519 }
2520 }
2521 } else {
2522 let relative_col = col.saturating_sub(hscrollbar_rect.x) as f64;
2524 let ratio = (relative_col / (track_width - 1.0)).clamp(0.0, 1.0);
2525
2526 if let Some(view_state) = self
2527 .windows
2528 .get_mut(&self.active_window)
2529 .and_then(|w| w.split_view_states_mut())
2530 .expect("active window must have a populated split layout")
2531 .get_mut(&dragging_split_id)
2532 {
2533 let visible_width = view_state.viewport.width as usize;
2534 let max_scroll = max_content_width.saturating_sub(visible_width);
2535 let target_col = (ratio * max_scroll as f64).round() as usize;
2536 view_state.viewport.left_column = target_col.min(max_scroll);
2537 view_state.viewport.set_skip_ensure_visible();
2538 }
2539 }
2540
2541 return Ok(());
2542 }
2543 }
2544 }
2545
2546 if let Some(popup_idx) = self.active_window_mut().mouse_state.selecting_in_popup {
2548 if let Some((_, _, inner_rect, scroll_offset, _, _, _)) = self
2550 .active_chrome()
2551 .popup_areas
2552 .iter()
2553 .find(|(idx, _, _, _, _, _, _)| *idx == popup_idx)
2554 {
2555 if col >= inner_rect.x
2557 && col < inner_rect.x + inner_rect.width
2558 && row >= inner_rect.y
2559 && row < inner_rect.y + inner_rect.height
2560 {
2561 let relative_col = (col - inner_rect.x) as usize;
2562 let relative_row = (row - inner_rect.y) as usize;
2563 let line = scroll_offset + relative_row;
2564
2565 let state = self.active_state_mut();
2566 if let Some(popup) = state.popups.get_mut(popup_idx) {
2567 popup.extend_selection(line, relative_col);
2568 }
2569 }
2570 }
2571 return Ok(());
2572 }
2573
2574 if self
2579 .active_window_mut()
2580 .mouse_state
2581 .dragging_prompt_scrollbar
2582 {
2583 use crate::view::ui::scrollbar::ScrollbarState;
2584 let sb_rect = self.active_chrome().suggestions_scrollbar_rect;
2587 let suggestions_area_visible =
2588 self.active_chrome().suggestions_area.map(|(_, _, v, _)| v);
2589 let active_window_id = self.active_window;
2590 if let (Some(sb_rect), Some(prompt)) = (
2591 sb_rect,
2592 self.windows
2593 .get_mut(&active_window_id)
2594 .and_then(|w| w.prompt.as_mut()),
2595 ) {
2596 let visible = suggestions_area_visible.unwrap_or(prompt.suggestions.len().min(10));
2597 let total = prompt.suggestions.len();
2598 let track_height = sb_rect.height as usize;
2599 let clamped_row =
2603 row.clamp(sb_rect.y, sb_rect.y + sb_rect.height.saturating_sub(1));
2604 let click_row = clamped_row.saturating_sub(sb_rect.y) as usize;
2605 let state = ScrollbarState::new(total, visible, prompt.scroll_offset);
2606 prompt.scroll_offset = state.click_to_offset(track_height, click_row);
2607 }
2608 return Ok(());
2609 }
2610
2611 if let Some(popup_idx) = self
2613 .active_window_mut()
2614 .mouse_state
2615 .dragging_popup_scrollbar
2616 {
2617 if let Some((_, _, inner_rect, _, _, Some(sb_rect), total_lines)) = self
2619 .active_chrome()
2620 .popup_areas
2621 .iter()
2622 .find(|(idx, _, _, _, _, _, _)| *idx == popup_idx)
2623 {
2624 let track_height = sb_rect.height as usize;
2625 let visible_lines = inner_rect.height as usize;
2626
2627 if track_height > 0 && *total_lines > visible_lines {
2628 let relative_row = row.saturating_sub(sb_rect.y) as usize;
2629 let max_scroll = total_lines.saturating_sub(visible_lines);
2630 let target_scroll = if track_height > 1 {
2631 (relative_row * max_scroll) / (track_height.saturating_sub(1))
2632 } else {
2633 0
2634 };
2635
2636 let state = self.active_state_mut();
2637 if let Some(popup) = state.popups.get_mut(popup_idx) {
2638 let current_scroll = popup.scroll_offset as i32;
2639 let delta = target_scroll as i32 - current_scroll;
2640 popup.scroll_by(delta);
2641 }
2642 }
2643 }
2644 return Ok(());
2645 }
2646
2647 if let Some((split_id, direction)) = self.active_window_mut().mouse_state.dragging_separator
2649 {
2650 self.handle_separator_drag(col, row, split_id, direction)?;
2651 return Ok(());
2652 }
2653
2654 if self.active_window_mut().mouse_state.dragging_file_explorer {
2656 self.handle_file_explorer_border_drag(col)?;
2657 return Ok(());
2658 }
2659
2660 if self.active_window_mut().mouse_state.dragging_text_selection {
2662 self.handle_text_selection_drag(col, row)?;
2663 return Ok(());
2664 }
2665
2666 if self.active_window_mut().mouse_state.dragging_tab.is_some() {
2668 self.handle_tab_drag(col, row)?;
2669 return Ok(());
2670 }
2671
2672 Ok(())
2673 }
2674
2675 fn handle_text_selection_drag(&mut self, col: u16, row: u16) -> AnyhowResult<()> {
2677 use crate::model::event::Event;
2678 use crate::primitives::word_navigation::{find_word_end, find_word_start};
2679
2680 let Some(split_id) = self.active_window_mut().mouse_state.drag_selection_split else {
2681 return Ok(());
2682 };
2683 let Some(anchor_position) = self.active_window_mut().mouse_state.drag_selection_anchor
2684 else {
2685 return Ok(());
2686 };
2687
2688 let Some((buffer_id, content_rect)) = self
2690 .active_layout()
2691 .split_areas
2692 .iter()
2693 .find(|(sid, _, _, _, _, _)| *sid == split_id)
2694 .map(|(_, bid, rect, _, _, _)| (*bid, *rect))
2695 else {
2696 return Ok(());
2697 };
2698
2699 let cached_mappings = self
2701 .active_layout()
2702 .view_line_mappings
2703 .get(&split_id)
2704 .cloned();
2705
2706 let leaf_id = split_id;
2707
2708 let fallback = self
2710 .windows
2711 .get(&self.active_window)
2712 .and_then(|w| w.buffers.splits())
2713 .map(|(_, vs)| vs)
2714 .expect("active window must have a populated split layout")
2715 .get(&leaf_id)
2716 .map(|vs| vs.viewport.top_byte)
2717 .unwrap_or(0);
2718
2719 let compose_width = self
2721 .windows
2722 .get(&self.active_window)
2723 .and_then(|w| w.buffers.splits())
2724 .map(|(_, vs)| vs)
2725 .expect("active window must have a populated split layout")
2726 .get(&leaf_id)
2727 .and_then(|vs| vs.compose_width);
2728
2729 let drag_by_words = self.active_window_mut().mouse_state.drag_selection_by_words;
2733 let drag_word_end = self.active_window_mut().mouse_state.drag_selection_word_end;
2734
2735 let Some((target_position, new_position, anchor_position, new_sticky_column)) = self
2736 .active_window()
2737 .buffers
2738 .get(&buffer_id)
2739 .and_then(|state| {
2740 let gutter_width = state.margins.left_total_width() as u16;
2741 let target_position = super::click_geometry::screen_to_buffer_position(
2742 col,
2743 row,
2744 content_rect,
2745 gutter_width,
2746 &cached_mappings,
2747 fallback,
2748 true, compose_width,
2750 )?;
2751 let (new_position, anchor_pos) = if drag_by_words {
2752 if target_position >= anchor_position {
2753 (
2754 find_word_end(&state.buffer, target_position),
2755 anchor_position,
2756 )
2757 } else {
2758 let word_end = drag_word_end.unwrap_or(anchor_position);
2759 (find_word_start(&state.buffer, target_position), word_end)
2760 }
2761 } else {
2762 (target_position, anchor_position)
2763 };
2764 let new_sticky_column = state
2765 .buffer
2766 .offset_to_position(new_position)
2767 .map(|pos| pos.column);
2768 Some((target_position, new_position, anchor_pos, new_sticky_column))
2769 })
2770 else {
2771 return Ok(());
2772 };
2773 let _ = target_position;
2774
2775 let (primary_cursor_id, old_position, old_anchor, old_sticky_column) = self
2776 .active_window()
2777 .buffers
2778 .splits()
2779 .and_then(|(_, vs)| vs.get(&leaf_id))
2780 .map(|vs| {
2781 let cursor = vs.cursors.primary();
2782 (
2783 vs.cursors.primary_id(),
2784 cursor.position,
2785 cursor.anchor,
2786 cursor.sticky_column,
2787 )
2788 })
2789 .unwrap_or((CursorId(0), 0, None, 0));
2790
2791 let event = Event::MoveCursor {
2792 cursor_id: primary_cursor_id,
2793 old_position,
2794 new_position,
2795 old_anchor,
2796 new_anchor: Some(anchor_position),
2797 old_sticky_column,
2798 new_sticky_column: new_sticky_column.unwrap_or(old_sticky_column),
2799 };
2800
2801 if let Some(event_log) = self.active_window_mut().event_logs.get_mut(&buffer_id) {
2802 event_log.append(event.clone());
2803 }
2804 self.active_window_mut()
2805 .apply_event_to_buffer(buffer_id, leaf_id, &event);
2806
2807 Ok(())
2808 }
2809
2810 pub(super) fn handle_file_explorer_border_drag(&mut self, col: u16) -> AnyhowResult<()> {
2812 let Some((start_col, _start_row)) =
2813 self.active_window_mut().mouse_state.drag_start_position
2814 else {
2815 return Ok(());
2816 };
2817 let Some(start_width) = self
2818 .active_window_mut()
2819 .mouse_state
2820 .drag_start_explorer_width
2821 else {
2822 return Ok(());
2823 };
2824
2825 let delta = col as i32 - start_col as i32;
2826 let total_width = self.terminal_width as i32;
2827
2828 if total_width > 0 {
2832 use crate::config::ExplorerWidth;
2833 self.active_window_mut().file_explorer_width = match start_width {
2834 ExplorerWidth::Percent(start_pct) => {
2835 let percent_delta = (delta * 100) / total_width;
2836 let new_pct = (start_pct as i32 + percent_delta).clamp(0, 100) as u8;
2837 ExplorerWidth::Percent(new_pct)
2838 }
2839 ExplorerWidth::Columns(start_cols) => {
2840 let new_cols = (start_cols as i32 + delta).clamp(0, total_width) as u16;
2841 ExplorerWidth::Columns(new_cols)
2842 }
2843 };
2844 }
2845
2846 Ok(())
2847 }
2848
2849 pub(super) fn handle_separator_drag(
2851 &mut self,
2852 col: u16,
2853 row: u16,
2854 split_id: ContainerId,
2855 direction: SplitDirection,
2856 ) -> AnyhowResult<()> {
2857 let Some((start_col, start_row)) = self.active_window_mut().mouse_state.drag_start_position
2858 else {
2859 return Ok(());
2860 };
2861 let Some(start_ratio) = self.active_window_mut().mouse_state.drag_start_ratio else {
2862 return Ok(());
2863 };
2864 let Some(editor_area) = self.active_layout().editor_content_area else {
2865 return Ok(());
2866 };
2867
2868 let (delta, total_size) = match direction {
2870 SplitDirection::Horizontal => {
2871 let delta = row as i32 - start_row as i32;
2873 let total = editor_area.height as i32;
2874 (delta, total)
2875 }
2876 SplitDirection::Vertical => {
2877 let delta = col as i32 - start_col as i32;
2879 let total = editor_area.width as i32;
2880 (delta, total)
2881 }
2882 };
2883
2884 if total_size > 0 {
2887 let ratio_delta = delta as f32 / total_size as f32;
2888 let new_ratio = (start_ratio + ratio_delta).clamp(0.1, 0.9);
2889
2890 if self
2895 .windows
2896 .get(&self.active_window)
2897 .and_then(|w| w.buffers.splits())
2898 .map(|(mgr, _)| mgr)
2899 .expect("active window must have a populated split layout")
2900 .get_ratio(split_id.into())
2901 .is_some()
2902 {
2903 self.windows
2904 .get_mut(&self.active_window)
2905 .and_then(|w| w.split_manager_mut())
2906 .expect("active window must have a populated split layout")
2907 .set_ratio(split_id, new_ratio);
2908 } else {
2909 self.set_grouped_split_ratio(split_id, new_ratio);
2910 }
2911 }
2912
2913 Ok(())
2914 }
2915
2916 pub(super) fn handle_right_click(&mut self, col: u16, row: u16) -> AnyhowResult<()> {
2918 let frame_w = self.active_chrome().last_frame_width;
2919 let frame_h = self.active_chrome().last_frame_height;
2920 if let Some(ref menu) = self.active_window().file_explorer_context_menu {
2921 let (menu_x, menu_y) = menu.clamped_position(frame_w, frame_h);
2922 let menu_width = super::types::FILE_EXPLORER_CONTEXT_MENU_WIDTH;
2923 let menu_height = menu.height();
2924 if col >= menu_x
2925 && col < menu_x + menu_width
2926 && row >= menu_y
2927 && row < menu_y + menu_height
2928 {
2929 return Ok(());
2930 }
2931 }
2932
2933 if let Some(ref menu) = self.active_window_mut().tab_context_menu {
2935 let menu_x = menu.position.0;
2936 let menu_y = menu.position.1;
2937 let menu_width = 22u16; let menu_height = super::types::TabContextMenuItem::all().len() as u16 + 2; if col >= menu_x
2942 && col < menu_x + menu_width
2943 && row >= menu_y
2944 && row < menu_y + menu_height
2945 {
2946 return Ok(());
2948 }
2949 }
2950
2951 if let Some(explorer_area) = self.active_layout().file_explorer_area {
2952 if col >= explorer_area.x
2953 && col < explorer_area.x + explorer_area.width
2954 && row < explorer_area.y + explorer_area.height
2955 && row > explorer_area.y
2956 {
2958 let relative_row = row.saturating_sub(explorer_area.y + 1);
2959 let (is_multi, is_root_selected) =
2960 if let Some(explorer) = self.file_explorer_mut().as_mut() {
2961 let display_nodes = explorer.get_display_nodes();
2962 let scroll_offset = explorer.get_scroll_offset();
2963 let clicked_index = (relative_row as usize) + scroll_offset;
2964 let mut clicked_is_root = false;
2965 if clicked_index < display_nodes.len() {
2966 let (node_id, _) = display_nodes[clicked_index];
2967 explorer.set_selected(Some(node_id));
2968 clicked_is_root = node_id == explorer.tree().root_id();
2969 }
2970 (explorer.has_multi_selection(), clicked_is_root)
2971 } else {
2972 (false, false)
2973 };
2974 self.active_window_mut().key_context =
2975 crate::input::keybindings::KeyContext::FileExplorer;
2976 self.active_window_mut().tab_context_menu = None;
2977 self.active_window_mut().file_explorer_context_menu =
2978 Some(super::types::FileExplorerContextMenu::new(
2979 col,
2980 row + 1,
2981 is_multi,
2982 is_root_selected,
2983 ));
2984 return Ok(());
2985 }
2986 }
2987
2988 self.active_window_mut().file_explorer_context_menu = None;
2989
2990 let tab_hit = self
2992 .active_layout()
2993 .tab_layouts
2994 .iter()
2995 .find_map(
2996 |(split_id, tab_layout)| match tab_layout.hit_test(col, row) {
2997 Some(TabHit::TabName(target) | TabHit::CloseButton(target)) => {
2998 target.as_buffer().map(|bid| (*split_id, bid))
3001 }
3002 _ => None,
3003 },
3004 );
3005
3006 if let Some((split_id, buffer_id)) = tab_hit {
3007 self.active_window_mut().tab_context_menu =
3009 Some(TabContextMenu::new(buffer_id, split_id, col, row + 1));
3010 } else {
3011 self.active_window_mut().tab_context_menu = None;
3013 }
3014
3015 Ok(())
3016 }
3017
3018 pub(super) fn handle_tab_context_menu_click(
3020 &mut self,
3021 col: u16,
3022 row: u16,
3023 ) -> Option<AnyhowResult<()>> {
3024 let menu = self.active_window_mut().tab_context_menu.as_ref()?;
3025 let menu_x = menu.position.0;
3026 let menu_y = menu.position.1;
3027 let menu_width = 22u16;
3028 let items = super::types::TabContextMenuItem::all();
3029 let menu_height = items.len() as u16 + 2; if col < menu_x || col >= menu_x + menu_width || row < menu_y || row >= menu_y + menu_height
3033 {
3034 self.active_window_mut().tab_context_menu = None;
3036 return Some(Ok(()));
3037 }
3038
3039 if row == menu_y || row == menu_y + menu_height - 1 {
3041 return Some(Ok(()));
3042 }
3043
3044 let item_idx = (row - menu_y - 1) as usize;
3046 if item_idx >= items.len() {
3047 return Some(Ok(()));
3048 }
3049
3050 let buffer_id = menu.buffer_id;
3052 let split_id = menu.split_id;
3053 let item = items[item_idx];
3054
3055 self.active_window_mut().tab_context_menu = None;
3057
3058 Some(self.execute_tab_context_menu_action(item, buffer_id, split_id))
3060 }
3061
3062 fn execute_tab_context_menu_action(
3064 &mut self,
3065 item: super::types::TabContextMenuItem,
3066 buffer_id: BufferId,
3067 leaf_id: LeafId,
3068 ) -> AnyhowResult<()> {
3069 use super::types::TabContextMenuItem;
3070 match item {
3071 TabContextMenuItem::Close => {
3072 self.close_tab_in_split(buffer_id, leaf_id);
3073 }
3074 TabContextMenuItem::CloseOthers => {
3075 self.close_other_tabs_in_split(buffer_id, leaf_id);
3076 }
3077 TabContextMenuItem::CloseToRight => {
3078 self.close_tabs_to_right_in_split(buffer_id, leaf_id);
3079 }
3080 TabContextMenuItem::CloseToLeft => {
3081 self.close_tabs_to_left_in_split(buffer_id, leaf_id);
3082 }
3083 TabContextMenuItem::CloseAll => {
3084 self.close_all_tabs_in_split(leaf_id);
3085 }
3086 TabContextMenuItem::CopyRelativePath => {
3087 self.copy_buffer_path(buffer_id, true);
3088 }
3089 TabContextMenuItem::CopyFullPath => {
3090 self.copy_buffer_path(buffer_id, false);
3091 }
3092 }
3093
3094 Ok(())
3095 }
3096
3097 pub(super) fn handle_file_explorer_context_menu_key(
3100 &mut self,
3101 code: crossterm::event::KeyCode,
3102 modifiers: crossterm::event::KeyModifiers,
3103 ) -> Option<AnyhowResult<()>> {
3104 use crossterm::event::KeyCode;
3105 use crossterm::event::KeyModifiers;
3106
3107 if modifiers != KeyModifiers::NONE {
3108 return None;
3109 }
3110
3111 match code {
3112 KeyCode::Up => {
3113 if let Some(ref mut menu) = self.active_window_mut().file_explorer_context_menu {
3114 menu.prev_item();
3115 }
3116 Some(Ok(()))
3117 }
3118 KeyCode::Down => {
3119 if let Some(ref mut menu) = self.active_window_mut().file_explorer_context_menu {
3120 menu.next_item();
3121 }
3122 Some(Ok(()))
3123 }
3124 KeyCode::Enter => {
3125 let item = {
3126 let menu = self
3127 .active_window_mut()
3128 .file_explorer_context_menu
3129 .as_ref()?;
3130 menu.items()[menu.highlighted]
3131 };
3132 self.active_window_mut().file_explorer_context_menu = None;
3133 self.execute_file_explorer_context_menu_action(item);
3134 Some(Ok(()))
3135 }
3136 KeyCode::Esc => {
3137 self.active_window_mut().file_explorer_context_menu = None;
3138 Some(Ok(()))
3139 }
3140 _ => None,
3141 }
3142 }
3143
3144 pub(super) fn handle_file_explorer_context_menu_click(
3146 &mut self,
3147 col: u16,
3148 row: u16,
3149 ) -> Option<AnyhowResult<()>> {
3150 let frame_w = self.active_chrome().last_frame_width;
3152 let frame_h = self.active_chrome().last_frame_height;
3153 let clicked_item: Option<super::types::FileExplorerContextMenuItem> = {
3154 let menu = self.active_window().file_explorer_context_menu.as_ref()?;
3155 let (menu_x, menu_y) = menu.clamped_position(frame_w, frame_h);
3156 let menu_width = super::types::FILE_EXPLORER_CONTEXT_MENU_WIDTH;
3157 let menu_height = menu.height();
3158
3159 if col < menu_x
3160 || col >= menu_x + menu_width
3161 || row < menu_y
3162 || row >= menu_y + menu_height
3163 {
3164 self.active_window_mut().file_explorer_context_menu = None;
3165 return Some(Ok(()));
3166 }
3167
3168 if row == menu_y || row == menu_y + menu_height - 1 {
3169 return Some(Ok(()));
3170 }
3171
3172 let item_idx = (row - menu_y - 1) as usize;
3173 menu.items().get(item_idx).copied()
3174 };
3175
3176 self.active_window_mut().file_explorer_context_menu = None;
3177 if let Some(item) = clicked_item {
3178 self.execute_file_explorer_context_menu_action(item);
3179 }
3180 Some(Ok(()))
3181 }
3182
3183 fn execute_file_explorer_context_menu_action(
3184 &mut self,
3185 item: super::types::FileExplorerContextMenuItem,
3186 ) {
3187 use super::types::FileExplorerContextMenuItem;
3188 match item {
3189 FileExplorerContextMenuItem::NewFile => self.file_explorer_new_file(),
3190 FileExplorerContextMenuItem::NewDirectory => self.file_explorer_new_directory(),
3191 FileExplorerContextMenuItem::Rename => self.file_explorer_rename(),
3192 FileExplorerContextMenuItem::Cut => self.active_window_mut().file_explorer_cut(),
3193 FileExplorerContextMenuItem::Copy => self.active_window_mut().file_explorer_copy(),
3194 FileExplorerContextMenuItem::Paste => self.file_explorer_paste(),
3195 FileExplorerContextMenuItem::Duplicate => self.file_explorer_duplicate(),
3196 FileExplorerContextMenuItem::Delete => self.file_explorer_delete(),
3197 FileExplorerContextMenuItem::CopyFullPath => self.file_explorer_copy_path(false),
3198 FileExplorerContextMenuItem::CopyRelativePath => self.file_explorer_copy_path(true),
3199 }
3200 }
3201
3202 fn show_file_explorer_status_tooltip(&mut self, path: std::path::PathBuf, col: u16, row: u16) {
3204 use crate::view::popup::{Popup, PopupPosition};
3205 use ratatui::style::Style;
3206
3207 let is_directory = path.is_dir();
3208
3209 let decoration = self
3211 .active_window()
3212 .file_explorer_decoration_cache
3213 .direct_for_path(&path)
3214 .cloned();
3215
3216 let bubbled_decoration = if is_directory && decoration.is_none() {
3218 self.active_window()
3219 .file_explorer_decoration_cache
3220 .bubbled_for_path(&path)
3221 .cloned()
3222 } else {
3223 None
3224 };
3225
3226 let has_unsaved_changes = if is_directory {
3228 self.windows
3230 .get(&self.active_window)
3231 .map(|w| &w.buffers)
3232 .expect("active window present")
3233 .iter()
3234 .any(|(buffer_id, state)| {
3235 if state.buffer.is_modified() {
3236 if let Some(metadata) = self.active_window().buffer_metadata.get(buffer_id)
3237 {
3238 if let Some(file_path) = metadata.file_path() {
3239 return file_path.starts_with(&path);
3240 }
3241 }
3242 }
3243 false
3244 })
3245 } else {
3246 self.windows
3247 .get(&self.active_window)
3248 .map(|w| &w.buffers)
3249 .expect("active window present")
3250 .iter()
3251 .any(|(buffer_id, state)| {
3252 if state.buffer.is_modified() {
3253 if let Some(metadata) = self.active_window().buffer_metadata.get(buffer_id)
3254 {
3255 return metadata.file_path() == Some(&path);
3256 }
3257 }
3258 false
3259 })
3260 };
3261
3262 let mut lines: Vec<String> = Vec::new();
3264
3265 if let Some(decoration) = &decoration {
3266 let symbol = &decoration.symbol;
3267 let explanation = match symbol.as_str() {
3268 "U" => "Untracked - File is not tracked by git",
3269 "M" => "Modified - File has unstaged changes",
3270 "A" => "Added - File is staged for commit",
3271 "D" => "Deleted - File is staged for deletion",
3272 "R" => "Renamed - File has been renamed",
3273 "C" => "Copied - File has been copied",
3274 "!" => "Conflicted - File has merge conflicts",
3275 "●" => "Has changes - Contains modified files",
3276 _ => "Unknown status",
3277 };
3278 lines.push(format!("{} - {}", symbol, explanation));
3279 } else if bubbled_decoration.is_some() {
3280 lines.push("● - Contains modified files".to_string());
3281 } else if has_unsaved_changes {
3282 if is_directory {
3283 lines.push("● - Contains unsaved changes".to_string());
3284 } else {
3285 lines.push("● - Unsaved changes in editor".to_string());
3286 }
3287 } else {
3288 return; }
3290
3291 if is_directory {
3293 if let Some(modified_files) = self.get_modified_files_in_directory(&path) {
3295 lines.push(String::new()); lines.push("Modified files:".to_string());
3297 let resolved_path = path.canonicalize().unwrap_or_else(|_| path.clone());
3299 const MAX_FILES: usize = 8;
3300 for (i, file) in modified_files.iter().take(MAX_FILES).enumerate() {
3301 let display_name = file
3303 .strip_prefix(&resolved_path)
3304 .unwrap_or(file)
3305 .to_string_lossy()
3306 .to_string();
3307 lines.push(format!(" {}", display_name));
3308 if i == MAX_FILES - 1 && modified_files.len() > MAX_FILES {
3309 lines.push(format!(
3310 " ... and {} more",
3311 modified_files.len() - MAX_FILES
3312 ));
3313 break;
3314 }
3315 }
3316 }
3317 } else {
3318 if let Some(stats) = self.get_git_diff_stats(&path) {
3320 lines.push(String::new()); lines.push(stats);
3322 }
3323 }
3324
3325 if lines.is_empty() {
3326 return;
3327 }
3328
3329 let mut popup = Popup::text(lines, &*self.theme.read().unwrap());
3331 popup.title = Some("Git Status".to_string());
3332 popup.transient = true;
3333 popup.position = PopupPosition::Fixed { x: col, y: row + 1 };
3334 popup.width = 50;
3335 popup.max_height = 15;
3336 popup.border_style = Style::default().fg(self.theme.read().unwrap().popup_border_fg);
3337 popup.background_style = Style::default().bg(self.theme.read().unwrap().popup_bg);
3338
3339 let __buffer_id = self.active_buffer();
3341 if let Some(state) = self
3342 .windows
3343 .get_mut(&self.active_window)
3344 .map(|w| &mut w.buffers)
3345 .expect("active window present")
3346 .get_mut(&__buffer_id)
3347 {
3348 state.popups.show(popup);
3349 }
3350 }
3351
3352 fn dismiss_file_explorer_status_tooltip(&mut self) {
3354 let __buffer_id = self.active_buffer();
3356 if let Some(state) = self
3357 .windows
3358 .get_mut(&self.active_window)
3359 .map(|w| &mut w.buffers)
3360 .expect("active window present")
3361 .get_mut(&__buffer_id)
3362 {
3363 state.popups.dismiss_transient();
3364 }
3365 }
3366
3367 fn get_git_diff_stats(&self, path: &std::path::Path) -> Option<String> {
3369 use crate::services::process_hidden::HideWindow;
3370 use std::process::Command;
3371
3372 let output = Command::new("git")
3374 .args(["diff", "--numstat", "--"])
3375 .arg(path)
3376 .current_dir(self.working_dir())
3377 .hide_window()
3378 .output()
3379 .ok()?;
3380
3381 if !output.status.success() {
3382 return None;
3383 }
3384
3385 let stdout = String::from_utf8_lossy(&output.stdout);
3386 let line = stdout.lines().next()?;
3387 let parts: Vec<&str> = line.split('\t').collect();
3388
3389 if parts.len() >= 2 {
3390 let insertions = parts[0];
3391 let deletions = parts[1];
3392
3393 if insertions == "-" && deletions == "-" {
3395 return Some("Binary file changed".to_string());
3396 }
3397
3398 let ins: i32 = insertions.parse().unwrap_or(0);
3399 let del: i32 = deletions.parse().unwrap_or(0);
3400
3401 if ins > 0 || del > 0 {
3402 return Some(format!("+{} -{} lines", ins, del));
3403 }
3404 }
3405
3406 let staged_output = Command::new("git")
3408 .args(["diff", "--numstat", "--cached", "--"])
3409 .arg(path)
3410 .current_dir(self.working_dir())
3411 .hide_window()
3412 .output()
3413 .ok()?;
3414
3415 if staged_output.status.success() {
3416 let staged_stdout = String::from_utf8_lossy(&staged_output.stdout);
3417 if let Some(line) = staged_stdout.lines().next() {
3418 let parts: Vec<&str> = line.split('\t').collect();
3419 if parts.len() >= 2 {
3420 let insertions = parts[0];
3421 let deletions = parts[1];
3422
3423 if insertions == "-" && deletions == "-" {
3424 return Some("Binary file staged".to_string());
3425 }
3426
3427 let ins: i32 = insertions.parse().unwrap_or(0);
3428 let del: i32 = deletions.parse().unwrap_or(0);
3429
3430 if ins > 0 || del > 0 {
3431 return Some(format!("+{} -{} lines (staged)", ins, del));
3432 }
3433 }
3434 }
3435 }
3436
3437 None
3438 }
3439
3440 fn get_modified_files_in_directory(
3442 &self,
3443 dir_path: &std::path::Path,
3444 ) -> Option<Vec<std::path::PathBuf>> {
3445 use crate::services::process_hidden::HideWindow;
3446 use std::process::Command;
3447
3448 let resolved_path = dir_path
3450 .canonicalize()
3451 .unwrap_or_else(|_| dir_path.to_path_buf());
3452
3453 let output = Command::new("git")
3455 .args(["status", "--porcelain", "--"])
3456 .arg(&resolved_path)
3457 .current_dir(self.working_dir())
3458 .hide_window()
3459 .output()
3460 .ok()?;
3461
3462 if !output.status.success() {
3463 return None;
3464 }
3465
3466 let stdout = String::from_utf8_lossy(&output.stdout);
3467 let modified_files: Vec<std::path::PathBuf> = stdout
3468 .lines()
3469 .filter_map(|line| {
3470 if line.len() > 3 {
3473 let file_part = &line[3..];
3474 let file_name = if file_part.contains(" -> ") {
3476 file_part.split(" -> ").last().unwrap_or(file_part)
3477 } else {
3478 file_part
3479 };
3480 Some(self.working_dir().join(file_name))
3481 } else {
3482 None
3483 }
3484 })
3485 .collect();
3486
3487 if modified_files.is_empty() {
3488 None
3489 } else {
3490 Some(modified_files)
3491 }
3492 }
3493
3494 fn handle_floating_widget_panel_wheel(&mut self, col: u16, row: u16, delta: i32) -> bool {
3506 let inner = match self.floating_widget_panel.as_ref() {
3507 Some(fwp) => match fwp.last_inner_rect {
3508 Some(rect) => rect,
3509 None => return false,
3510 },
3511 None => return false,
3512 };
3513 if col < inner.x || col >= inner.x + inner.width {
3514 return false;
3515 }
3516 if row < inner.y || row >= inner.y + inner.height {
3517 return false;
3518 }
3519 self.handle_widget_panel_wheel(super::FLOATING_PANEL_BUFFER_ID, delta)
3520 }
3521
3522 fn try_widget_scrollbar_press(&mut self, col: u16, row: u16) -> bool {
3527 use crate::view::ui::scrollbar::ScrollbarState;
3528 let (panel_id, tracks) = match self.floating_widget_panel.as_ref() {
3529 Some(fwp) => (fwp.panel_id, fwp.scrollbar_tracks.clone()),
3530 None => return false,
3531 };
3532 for t in &tracks {
3533 let state = ScrollbarState::new(t.total, t.visible, t.scroll);
3534 let pressed = self
3535 .floating_widget_panel
3536 .as_mut()
3537 .and_then(|fwp| fwp.scrollbar_mouse.press(state, t.rect, col, row));
3538 if let Some(new_offset) = pressed {
3539 if let Some(fwp) = self.floating_widget_panel.as_mut() {
3540 fwp.scrollbar_drag_key = Some(t.list_key.clone());
3541 }
3542 self.apply_widget_scroll(panel_id, &t.list_key, new_offset, t.visible);
3543 return true;
3544 }
3545 }
3546 false
3547 }
3548
3549 fn try_widget_scrollbar_drag(&mut self, row: u16) -> bool {
3552 use crate::view::ui::scrollbar::ScrollbarState;
3553 let (panel_id, key) = match self.floating_widget_panel.as_ref() {
3554 Some(fwp) => match &fwp.scrollbar_drag_key {
3555 Some(k) => (fwp.panel_id, k.clone()),
3556 None => return false,
3557 },
3558 None => return false,
3559 };
3560 let track = self.floating_widget_panel.as_ref().and_then(|fwp| {
3563 fwp.scrollbar_tracks
3564 .iter()
3565 .find(|t| t.list_key == key)
3566 .cloned()
3567 });
3568 let Some(t) = track else {
3569 return false;
3570 };
3571 let state = ScrollbarState::new(t.total, t.visible, t.scroll);
3572 let new_offset = self
3573 .floating_widget_panel
3574 .as_mut()
3575 .and_then(|fwp| fwp.scrollbar_mouse.drag(state, t.rect, row));
3576 if let Some(off) = new_offset {
3577 self.apply_widget_scroll(panel_id, &key, off, t.visible);
3578 }
3579 true
3580 }
3581
3582 pub(super) fn release_widget_scrollbar(&mut self) {
3584 if let Some(fwp) = self.floating_widget_panel.as_mut() {
3585 fwp.scrollbar_mouse.release();
3586 fwp.scrollbar_drag_key = None;
3587 }
3588 }
3589
3590 fn apply_widget_scroll(
3596 &mut self,
3597 panel_id: u64,
3598 list_key: &str,
3599 new_offset: usize,
3600 visible: usize,
3601 ) {
3602 let moved_sel = self.widget_registry.set_list_scroll(
3603 panel_id,
3604 list_key,
3605 new_offset as u32,
3606 visible as u32,
3607 );
3608 self.rerender_widget_panel(panel_id);
3609 if let Some(sel) = moved_sel {
3610 if self
3611 .plugin_manager
3612 .read()
3613 .unwrap()
3614 .has_hook_handlers("widget_event")
3615 {
3616 self.plugin_manager.read().unwrap().run_hook(
3617 "widget_event",
3618 crate::services::plugins::hooks::HookArgs::WidgetEvent {
3619 panel_id,
3620 widget_key: list_key.to_string(),
3621 event_type: "select".to_string(),
3622 payload: serde_json::json!({ "index": sel as i64 }),
3623 },
3624 );
3625 }
3626 }
3627 }
3628
3629 fn handle_floating_widget_click(&mut self, col: u16, row: u16) {
3632 if self.try_widget_scrollbar_press(col, row) {
3635 return;
3636 }
3637 let (panel_id, inner) = match self.floating_widget_panel.as_ref() {
3638 Some(fwp) => match fwp.last_inner_rect {
3639 Some(rect) => (fwp.panel_id, rect),
3640 None => return,
3641 },
3642 None => return,
3643 };
3644 if col < inner.x || col >= inner.x + inner.width {
3645 return;
3646 }
3647 if row < inner.y || row >= inner.y + inner.height {
3648 return;
3649 }
3650 let brow = (row - inner.y) as u32;
3651 let entries = self
3652 .floating_widget_panel
3653 .as_ref()
3654 .map(|f| f.entries.clone())
3655 .unwrap_or_default();
3656 let local_screen_col = (col - inner.x) as usize;
3657 let bcol = match entries.get(brow as usize) {
3658 Some(entry) => screen_col_to_byte(&entry.text, local_screen_col),
3659 None => return,
3660 };
3661 let (hit_payload, hit_event, hit_key, hit_kind) =
3662 match self
3663 .widget_registry
3664 .hit_test(super::FLOATING_PANEL_BUFFER_ID, brow, bcol as u32)
3665 {
3666 Some((_, hit)) => (
3667 hit.payload.clone(),
3668 hit.event_type.to_string(),
3669 hit.widget_key.clone(),
3670 hit.widget_kind,
3671 ),
3672 None => return,
3673 };
3674 if !hit_key.is_empty() {
3675 let tabbable = self
3676 .widget_registry
3677 .get(panel_id)
3678 .map(|p| p.tabbable.iter().any(|k| k == &hit_key))
3679 .unwrap_or(false);
3680 if tabbable {
3681 self.set_panel_focus_and_notify(panel_id, hit_key.clone());
3682 }
3683 self.rerender_widget_panel(panel_id);
3684 }
3685 let handled_specially = if hit_kind == "tree" && hit_event == "expand" {
3686 if let Some(item_key) = hit_payload.get("key").and_then(|v| v.as_str()) {
3687 self.handle_widget_tree_expand_toggle(panel_id, &hit_key, item_key);
3688 true
3689 } else {
3690 false
3691 }
3692 } else {
3693 false
3694 };
3695 if !handled_specially
3696 && self
3697 .plugin_manager
3698 .read()
3699 .unwrap()
3700 .has_hook_handlers("widget_event")
3701 {
3702 self.plugin_manager.read().unwrap().run_hook(
3703 "widget_event",
3704 crate::services::plugins::hooks::HookArgs::WidgetEvent {
3705 panel_id,
3706 widget_key: hit_key,
3707 event_type: hit_event,
3708 payload: hit_payload,
3709 },
3710 );
3711 }
3712 }
3713}
3714
3715fn screen_col_to_byte(text: &str, target_col: usize) -> usize {
3720 use unicode_width::UnicodeWidthChar;
3721 let mut byte = 0;
3722 let mut col = 0usize;
3723 for ch in text.chars() {
3724 let w = UnicodeWidthChar::width(ch).unwrap_or(0);
3725 if col + w > target_col {
3726 return byte;
3727 }
3728 col += w;
3729 byte += ch.len_utf8();
3730 }
3731 byte
3732}