1use super::*;
11use crate::input::keybindings::Action;
12use crate::model::event::{SplitDirection, SplitId};
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 rust_i18n::t;
19
20impl Editor {
21 pub fn handle_mouse(
24 &mut self,
25 mouse_event: crossterm::event::MouseEvent,
26 ) -> AnyhowResult<bool> {
27 use crossterm::event::{MouseButton, MouseEventKind};
28
29 let col = mouse_event.column;
30 let row = mouse_event.row;
31
32 let is_double_click = if matches!(mouse_event.kind, MouseEventKind::Down(MouseButton::Left))
34 {
35 let now = self.time_source.now();
36 let is_double = if let (Some(previous_time), Some(previous_pos)) =
37 (self.previous_click_time, self.previous_click_position)
38 {
39 let double_click_threshold =
40 std::time::Duration::from_millis(self.config.editor.double_click_time_ms);
41 let within_time = now.duration_since(previous_time) < double_click_threshold;
42 let same_position = previous_pos == (col, row);
43 within_time && same_position
44 } else {
45 false
46 };
47
48 if is_double {
50 self.previous_click_time = None;
51 self.previous_click_position = None;
52 } else {
53 self.previous_click_time = Some(now);
54 self.previous_click_position = Some((col, row));
55 }
56 is_double
57 } else {
58 false
59 };
60
61 if self.settings_state.as_ref().is_some_and(|s| s.visible) {
63 return self.handle_settings_mouse(mouse_event, is_double_click);
64 }
65
66 if self.calibration_wizard.is_some() {
68 return Ok(false);
69 }
70
71 let mut needs_render = false;
73 if let Some(ref prompt) = self.prompt {
74 if matches!(prompt.prompt_type, PromptType::LspRename { .. }) {
75 self.cancel_prompt();
76 needs_render = true;
77 }
78 }
79
80 let cursor_moved = self.mouse_cursor_position != Some((col, row));
83 self.mouse_cursor_position = Some((col, row));
84 if self.gpm_active && cursor_moved {
85 needs_render = true;
86 }
87
88 tracing::trace!(
89 "handle_mouse: kind={:?}, col={}, row={}",
90 mouse_event.kind,
91 col,
92 row
93 );
94
95 if let Some(result) = self.try_forward_mouse_to_terminal(col, row, mouse_event) {
98 return result;
99 }
100
101 match mouse_event.kind {
102 MouseEventKind::Down(MouseButton::Left) => {
103 if is_double_click {
104 self.handle_mouse_double_click(col, row)?;
106 needs_render = true;
107 return Ok(needs_render);
108 }
109 self.handle_mouse_click(col, row, mouse_event.modifiers)?;
110 needs_render = true;
111 }
112 MouseEventKind::Drag(MouseButton::Left) => {
113 self.handle_mouse_drag(col, row)?;
114 needs_render = true;
115 }
116 MouseEventKind::Up(MouseButton::Left) => {
117 let was_dragging_separator = self.mouse_state.dragging_separator.is_some();
119
120 if let Some(drag_state) = self.mouse_state.dragging_tab.take() {
122 if drag_state.is_dragging() {
123 if let Some(drop_zone) = drag_state.drop_zone {
124 self.execute_tab_drop(
125 drag_state.buffer_id,
126 drag_state.source_split_id,
127 drop_zone,
128 );
129 }
130 }
131 }
132
133 self.mouse_state.dragging_scrollbar = None;
135 self.mouse_state.drag_start_row = None;
136 self.mouse_state.drag_start_top_byte = None;
137 self.mouse_state.dragging_separator = None;
138 self.mouse_state.drag_start_position = None;
139 self.mouse_state.drag_start_ratio = None;
140 self.mouse_state.dragging_file_explorer = false;
141 self.mouse_state.drag_start_explorer_width = None;
142 self.mouse_state.dragging_text_selection = false;
144 self.mouse_state.drag_selection_split = None;
145 self.mouse_state.drag_selection_anchor = None;
146 self.mouse_state.dragging_popup_scrollbar = None;
148 self.mouse_state.drag_start_popup_scroll = None;
149 self.mouse_state.selecting_in_popup = None;
151
152 if was_dragging_separator {
154 self.resize_visible_terminals();
155 }
156
157 needs_render = true;
158 }
159 MouseEventKind::Moved => {
160 {
162 let content_rect = self
164 .cached_layout
165 .split_areas
166 .iter()
167 .find(|(_, _, content_rect, _, _, _)| {
168 col >= content_rect.x
169 && col < content_rect.x + content_rect.width
170 && row >= content_rect.y
171 && row < content_rect.y + content_rect.height
172 })
173 .map(|(_, _, rect, _, _, _)| *rect);
174
175 let (content_x, content_y) = content_rect.map(|r| (r.x, r.y)).unwrap_or((0, 0));
176
177 self.plugin_manager.run_hook(
178 "mouse_move",
179 HookArgs::MouseMove {
180 column: col,
181 row,
182 content_x,
183 content_y,
184 },
185 );
186 }
187
188 let hover_changed = self.update_hover_target(col, row);
191 needs_render = needs_render || hover_changed;
192
193 self.update_lsp_hover_state(col, row);
195 }
196 MouseEventKind::ScrollUp => {
197 if self.handle_prompt_scroll(-3) {
199 needs_render = true;
200 } else if self.is_file_open_active() && self.handle_file_open_scroll(-3) {
201 needs_render = true;
203 } else if self.is_mouse_over_any_popup(col, row) {
204 self.scroll_popup(-3);
206 needs_render = true;
207 } else {
208 if self.terminal_mode && self.is_terminal_buffer(self.active_buffer()) {
210 self.sync_terminal_to_buffer(self.active_buffer());
211 self.terminal_mode = false;
212 self.key_context = crate::input::keybindings::KeyContext::Normal;
213 }
214 self.dismiss_transient_popups();
216 self.handle_mouse_scroll(col, row, -3)?;
217 self.sync_split_view_state_to_editor_state();
219 needs_render = true;
220 }
221 }
222 MouseEventKind::ScrollDown => {
223 if self.handle_prompt_scroll(3) {
225 needs_render = true;
226 } else if self.is_file_open_active() && self.handle_file_open_scroll(3) {
227 needs_render = true;
228 } else if self.is_mouse_over_any_popup(col, row) {
229 self.scroll_popup(3);
231 needs_render = true;
232 } else {
233 if self.terminal_mode && self.is_terminal_buffer(self.active_buffer()) {
235 self.sync_terminal_to_buffer(self.active_buffer());
236 self.terminal_mode = false;
237 self.key_context = crate::input::keybindings::KeyContext::Normal;
238 }
239 self.dismiss_transient_popups();
241 self.handle_mouse_scroll(col, row, 3)?;
242 self.sync_split_view_state_to_editor_state();
244 needs_render = true;
245 }
246 }
247 MouseEventKind::Down(MouseButton::Right) => {
248 self.handle_right_click(col, row)?;
250 needs_render = true;
251 }
252 _ => {
253 }
255 }
256
257 self.mouse_state.last_position = Some((col, row));
258 Ok(needs_render)
259 }
260
261 pub(super) fn update_hover_target(&mut self, col: u16, row: u16) -> bool {
264 let old_target = self.mouse_state.hover_target.clone();
265 let new_target = self.compute_hover_target(col, row);
266 let changed = old_target != new_target;
267 self.mouse_state.hover_target = new_target.clone();
268
269 if let Some(active_menu_idx) = self.menu_state.active_menu {
272 if let Some(HoverTarget::MenuBarItem(hovered_menu_idx)) = new_target.clone() {
273 if hovered_menu_idx != active_menu_idx {
274 self.menu_state.open_menu(hovered_menu_idx);
275 return true; }
277 }
278
279 if let Some(HoverTarget::MenuDropdownItem(_, item_idx)) = new_target.clone() {
281 let all_menus: Vec<crate::config::Menu> = self
282 .menus
283 .menus
284 .iter()
285 .chain(self.menu_state.plugin_menus.iter())
286 .cloned()
287 .collect();
288
289 if self.menu_state.submenu_path.first() == Some(&item_idx) {
292 tracing::trace!(
293 "menu hover: staying on submenu parent item_idx={}, submenu_path={:?}",
294 item_idx,
295 self.menu_state.submenu_path
296 );
297 return changed;
298 }
299
300 if !self.menu_state.submenu_path.is_empty() {
302 tracing::trace!(
303 "menu hover: clearing submenu_path={:?} for different item_idx={}",
304 self.menu_state.submenu_path,
305 item_idx
306 );
307 self.menu_state.submenu_path.clear();
308 self.menu_state.highlighted_item = Some(item_idx);
309 return true;
310 }
311
312 if let Some(menu) = all_menus.get(active_menu_idx) {
314 if let Some(crate::config::MenuItem::Submenu { items, .. }) =
315 menu.items.get(item_idx)
316 {
317 if !items.is_empty() {
318 tracing::trace!("menu hover: opening submenu at item_idx={}", item_idx);
319 self.menu_state.submenu_path.push(item_idx);
320 self.menu_state.highlighted_item = Some(0);
321 return true;
322 }
323 }
324 }
325 if self.menu_state.highlighted_item != Some(item_idx) {
327 self.menu_state.highlighted_item = Some(item_idx);
328 return true;
329 }
330 }
331
332 if let Some(HoverTarget::SubmenuItem(depth, item_idx)) = new_target {
334 if self.menu_state.submenu_path.len() > depth
338 && self.menu_state.submenu_path.get(depth) == Some(&item_idx)
339 {
340 tracing::trace!(
341 "menu hover: staying on nested submenu parent depth={}, item_idx={}, submenu_path={:?}",
342 depth,
343 item_idx,
344 self.menu_state.submenu_path
345 );
346 return changed;
347 }
348
349 if self.menu_state.submenu_path.len() > depth {
351 tracing::trace!(
352 "menu hover: truncating submenu_path={:?} to depth={} for item_idx={}",
353 self.menu_state.submenu_path,
354 depth,
355 item_idx
356 );
357 self.menu_state.submenu_path.truncate(depth);
358 }
359
360 let all_menus: Vec<crate::config::Menu> = self
361 .menus
362 .menus
363 .iter()
364 .chain(self.menu_state.plugin_menus.iter())
365 .cloned()
366 .collect();
367
368 if let Some(items) = self
370 .menu_state
371 .get_current_items(&all_menus, active_menu_idx)
372 {
373 if let Some(crate::config::MenuItem::Submenu {
375 items: sub_items, ..
376 }) = items.get(item_idx)
377 {
378 if !sub_items.is_empty()
379 && !self.menu_state.submenu_path.contains(&item_idx)
380 {
381 tracing::trace!(
382 "menu hover: opening nested submenu at depth={}, item_idx={}",
383 depth,
384 item_idx
385 );
386 self.menu_state.submenu_path.push(item_idx);
387 self.menu_state.highlighted_item = Some(0);
388 return true;
389 }
390 }
391 if self.menu_state.highlighted_item != Some(item_idx) {
393 self.menu_state.highlighted_item = Some(item_idx);
394 return true;
395 }
396 }
397 }
398 }
399
400 if let Some(HoverTarget::TabContextMenuItem(item_idx)) = new_target.clone() {
402 if let Some(ref mut menu) = self.tab_context_menu {
403 if menu.highlighted != item_idx {
404 menu.highlighted = item_idx;
405 return true;
406 }
407 }
408 }
409
410 if old_target != new_target
413 && matches!(
414 old_target,
415 Some(HoverTarget::FileExplorerStatusIndicator(_))
416 )
417 {
418 self.dismiss_file_explorer_status_tooltip();
419 }
420
421 if let Some(HoverTarget::FileExplorerStatusIndicator(ref path)) = new_target {
422 if old_target != new_target {
424 self.show_file_explorer_status_tooltip(path.clone(), col, row);
425 return true;
426 }
427 }
428
429 changed
430 }
431
432 fn update_lsp_hover_state(&mut self, col: u16, row: u16) {
441 tracing::trace!(col, row, "update_lsp_hover_state: raw mouse position");
442
443 if self.is_mouse_over_transient_popup(col, row) {
445 return;
446 }
447
448 let split_info = self
450 .cached_layout
451 .split_areas
452 .iter()
453 .find(|(_, _, content_rect, _, _, _)| {
454 col >= content_rect.x
455 && col < content_rect.x + content_rect.width
456 && row >= content_rect.y
457 && row < content_rect.y + content_rect.height
458 })
459 .map(|(split_id, buffer_id, content_rect, _, _, _)| {
460 (*split_id, *buffer_id, *content_rect)
461 });
462
463 let Some((split_id, buffer_id, content_rect)) = split_info else {
464 if self.mouse_state.lsp_hover_state.is_some() {
466 self.mouse_state.lsp_hover_state = None;
467 self.mouse_state.lsp_hover_request_sent = false;
468 self.dismiss_transient_popups();
469 }
470 return;
471 };
472
473 let cached_mappings = self
475 .cached_layout
476 .view_line_mappings
477 .get(&split_id)
478 .cloned();
479 let gutter_width = self
480 .buffers
481 .get(&buffer_id)
482 .map(|s| s.margins.left_total_width() as u16)
483 .unwrap_or(0);
484 let fallback = self
485 .buffers
486 .get(&buffer_id)
487 .map(|s| s.buffer.len())
488 .unwrap_or(0);
489
490 let Some(byte_pos) = Self::screen_to_buffer_position(
492 col,
493 row,
494 content_rect,
495 gutter_width,
496 &cached_mappings,
497 fallback,
498 false, ) else {
500 if self.mouse_state.lsp_hover_state.is_some() {
502 self.mouse_state.lsp_hover_state = None;
503 self.mouse_state.lsp_hover_request_sent = false;
504 self.dismiss_transient_popups();
505 }
506 return;
507 };
508
509 let content_col = col.saturating_sub(content_rect.x);
511 let text_col = content_col.saturating_sub(gutter_width) as usize;
512 let visual_row = row.saturating_sub(content_rect.y) as usize;
513
514 let line_info = cached_mappings
515 .as_ref()
516 .and_then(|mappings| mappings.get(visual_row))
517 .map(|line_mapping| {
518 (
519 line_mapping.visual_to_char.len(),
520 line_mapping.line_end_byte,
521 )
522 });
523
524 let is_past_line_end_or_empty = line_info
525 .map(|(line_len, _)| {
526 if line_len <= 1 {
528 return true;
529 }
530 text_col >= line_len
531 })
532 .unwrap_or(true);
534
535 tracing::trace!(
536 col,
537 row,
538 content_col,
539 text_col,
540 visual_row,
541 gutter_width,
542 byte_pos,
543 ?line_info,
544 is_past_line_end_or_empty,
545 "update_lsp_hover_state: position check"
546 );
547
548 if is_past_line_end_or_empty {
549 tracing::trace!(
550 "update_lsp_hover_state: mouse past line end or empty line, clearing hover"
551 );
552 if self.mouse_state.lsp_hover_state.is_some() {
554 self.mouse_state.lsp_hover_state = None;
555 self.mouse_state.lsp_hover_request_sent = false;
556 self.dismiss_transient_popups();
557 }
558 return;
559 }
560
561 if let Some((start, end)) = self.hover_symbol_range {
563 if byte_pos >= start && byte_pos < end {
564 return;
566 }
567 }
568
569 if let Some((old_pos, _, _, _)) = self.mouse_state.lsp_hover_state {
571 if old_pos == byte_pos {
572 return;
574 }
575 self.dismiss_transient_popups();
577 }
578
579 self.mouse_state.lsp_hover_state = Some((byte_pos, std::time::Instant::now(), col, row));
581 self.mouse_state.lsp_hover_request_sent = false;
582 }
583
584 fn is_mouse_over_transient_popup(&self, col: u16, row: u16) -> bool {
586 let layouts = popup_areas_to_layout_info(&self.cached_layout.popup_areas);
587 let hit_tester = PopupHitTester::new(&layouts, &self.active_state().popups);
588 hit_tester.is_over_transient_popup(col, row)
589 }
590
591 fn is_mouse_over_any_popup(&self, col: u16, row: u16) -> bool {
593 let layouts = popup_areas_to_layout_info(&self.cached_layout.popup_areas);
594 let hit_tester = PopupHitTester::new(&layouts, &self.active_state().popups);
595 hit_tester.is_over_popup(col, row)
596 }
597
598 fn compute_hover_target(&self, col: u16, row: u16) -> Option<HoverTarget> {
600 if let Some(ref menu) = self.tab_context_menu {
602 let menu_x = menu.position.0;
603 let menu_y = menu.position.1;
604 let menu_width = 22u16;
605 let items = super::types::TabContextMenuItem::all();
606 let menu_height = items.len() as u16 + 2;
607
608 if col >= menu_x
609 && col < menu_x + menu_width
610 && row > menu_y
611 && row < menu_y + menu_height - 1
612 {
613 let item_idx = (row - menu_y - 1) as usize;
614 if item_idx < items.len() {
615 return Some(HoverTarget::TabContextMenuItem(item_idx));
616 }
617 }
618 }
619
620 if let Some((inner_rect, start_idx, _visible_count, total_count)) =
622 &self.cached_layout.suggestions_area
623 {
624 if col >= inner_rect.x
625 && col < inner_rect.x + inner_rect.width
626 && row >= inner_rect.y
627 && row < inner_rect.y + inner_rect.height
628 {
629 let relative_row = (row - inner_rect.y) as usize;
630 let item_idx = start_idx + relative_row;
631
632 if item_idx < *total_count {
633 return Some(HoverTarget::SuggestionItem(item_idx));
634 }
635 }
636 }
637
638 for (popup_idx, _popup_rect, inner_rect, scroll_offset, num_items, _, _) in
641 self.cached_layout.popup_areas.iter().rev()
642 {
643 if col >= inner_rect.x
644 && col < inner_rect.x + inner_rect.width
645 && row >= inner_rect.y
646 && row < inner_rect.y + inner_rect.height
647 && *num_items > 0
648 {
649 let relative_row = (row - inner_rect.y) as usize;
651 let item_idx = scroll_offset + relative_row;
652
653 if item_idx < *num_items {
654 return Some(HoverTarget::PopupListItem(*popup_idx, item_idx));
655 }
656 }
657 }
658
659 if self.is_file_open_active() {
661 if let Some(hover) = self.compute_file_browser_hover(col, row) {
662 return Some(hover);
663 }
664 }
665
666 if self.menu_bar_visible {
669 if let Some(ref menu_layout) = self.cached_layout.menu_layout {
670 if let Some(menu_idx) = menu_layout.menu_at(col, row) {
671 return Some(HoverTarget::MenuBarItem(menu_idx));
672 }
673 }
674 }
675
676 if let Some(active_idx) = self.menu_state.active_menu {
678 if let Some(hover) = self.compute_menu_dropdown_hover(col, row, active_idx) {
679 return Some(hover);
680 }
681 }
682
683 if let Some(explorer_area) = self.cached_layout.file_explorer_area {
685 let close_button_x = explorer_area.x + explorer_area.width.saturating_sub(3);
687 if row == explorer_area.y
688 && col >= close_button_x
689 && col < explorer_area.x + explorer_area.width
690 {
691 return Some(HoverTarget::FileExplorerCloseButton);
692 }
693
694 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
701 && row < content_end_y
702 && col >= status_indicator_x
703 && col < explorer_area.x + explorer_area.width.saturating_sub(1)
704 {
705 if let Some(ref explorer) = self.file_explorer {
707 let relative_row = row.saturating_sub(content_start_y) as usize;
708 let scroll_offset = explorer.get_scroll_offset();
709 let item_index = relative_row + scroll_offset;
710 let display_nodes = explorer.get_display_nodes();
711
712 if item_index < display_nodes.len() {
713 let (node_id, _indent) = display_nodes[item_index];
714 if let Some(node) = explorer.tree().get_node(node_id) {
715 return Some(HoverTarget::FileExplorerStatusIndicator(
716 node.entry.path.clone(),
717 ));
718 }
719 }
720 }
721 }
722
723 let border_x = explorer_area.x + explorer_area.width;
725 if col == border_x
726 && row >= explorer_area.y
727 && row < explorer_area.y + explorer_area.height
728 {
729 return Some(HoverTarget::FileExplorerBorder);
730 }
731 }
732
733 for (split_id, direction, sep_x, sep_y, sep_length) in &self.cached_layout.separator_areas {
735 let is_on_separator = match direction {
736 SplitDirection::Horizontal => {
737 row == *sep_y && col >= *sep_x && col < sep_x + sep_length
738 }
739 SplitDirection::Vertical => {
740 col == *sep_x && row >= *sep_y && row < sep_y + sep_length
741 }
742 };
743
744 if is_on_separator {
745 return Some(HoverTarget::SplitSeparator(*split_id, *direction));
746 }
747 }
748
749 for (split_id, btn_row, start_col, end_col) in &self.cached_layout.close_split_areas {
752 if row == *btn_row && col >= *start_col && col < *end_col {
753 return Some(HoverTarget::CloseSplitButton(*split_id));
754 }
755 }
756
757 for (split_id, btn_row, start_col, end_col) in &self.cached_layout.maximize_split_areas {
758 if row == *btn_row && col >= *start_col && col < *end_col {
759 return Some(HoverTarget::MaximizeSplitButton(*split_id));
760 }
761 }
762
763 for (split_id, tab_layout) in &self.cached_layout.tab_layouts {
764 match tab_layout.hit_test(col, row) {
765 Some(TabHit::CloseButton(buffer_id)) => {
766 return Some(HoverTarget::TabCloseButton(buffer_id, *split_id));
767 }
768 Some(TabHit::TabName(buffer_id)) => {
769 return Some(HoverTarget::TabName(buffer_id, *split_id));
770 }
771 Some(TabHit::ScrollLeft)
772 | Some(TabHit::ScrollRight)
773 | Some(TabHit::BarBackground)
774 | None => {}
775 }
776 }
777
778 for (split_id, _buffer_id, _content_rect, scrollbar_rect, thumb_start, thumb_end) in
780 &self.cached_layout.split_areas
781 {
782 if col >= scrollbar_rect.x
783 && col < scrollbar_rect.x + scrollbar_rect.width
784 && row >= scrollbar_rect.y
785 && row < scrollbar_rect.y + scrollbar_rect.height
786 {
787 let relative_row = row.saturating_sub(scrollbar_rect.y) as usize;
788 let is_on_thumb = relative_row >= *thumb_start && relative_row < *thumb_end;
789
790 if is_on_thumb {
791 return Some(HoverTarget::ScrollbarThumb(*split_id));
792 } else {
793 return Some(HoverTarget::ScrollbarTrack(*split_id));
794 }
795 }
796 }
797
798 if let Some((status_row, _status_x, _status_width)) = self.cached_layout.status_bar_area {
800 if row == status_row {
801 if let Some((le_row, le_start, le_end)) =
803 self.cached_layout.status_bar_line_ending_area
804 {
805 if row == le_row && col >= le_start && col < le_end {
806 return Some(HoverTarget::StatusBarLineEndingIndicator);
807 }
808 }
809
810 if let Some((lang_row, lang_start, lang_end)) =
812 self.cached_layout.status_bar_language_area
813 {
814 if row == lang_row && col >= lang_start && col < lang_end {
815 return Some(HoverTarget::StatusBarLanguageIndicator);
816 }
817 }
818
819 if let Some((lsp_row, lsp_start, lsp_end)) = self.cached_layout.status_bar_lsp_area
821 {
822 if row == lsp_row && col >= lsp_start && col < lsp_end {
823 return Some(HoverTarget::StatusBarLspIndicator);
824 }
825 }
826
827 if let Some((warn_row, warn_start, warn_end)) =
829 self.cached_layout.status_bar_warning_area
830 {
831 if row == warn_row && col >= warn_start && col < warn_end {
832 return Some(HoverTarget::StatusBarWarningBadge);
833 }
834 }
835 }
836 }
837
838 if let Some(ref layout) = self.cached_layout.search_options_layout {
840 use crate::view::ui::status_bar::SearchOptionsHover;
841 if let Some(hover) = layout.checkbox_at(col, row) {
842 return Some(match hover {
843 SearchOptionsHover::CaseSensitive => HoverTarget::SearchOptionCaseSensitive,
844 SearchOptionsHover::WholeWord => HoverTarget::SearchOptionWholeWord,
845 SearchOptionsHover::Regex => HoverTarget::SearchOptionRegex,
846 SearchOptionsHover::ConfirmEach => HoverTarget::SearchOptionConfirmEach,
847 SearchOptionsHover::None => return None,
848 });
849 }
850 }
851
852 None
854 }
855
856 pub(super) fn handle_mouse_double_click(&mut self, col: u16, row: u16) -> AnyhowResult<()> {
859 tracing::debug!("handle_mouse_double_click at col={}, row={}", col, row);
860
861 if self.is_mouse_over_any_popup(col, row) {
863 return Ok(());
865 } else {
866 self.dismiss_transient_popups();
868 }
869
870 if self.handle_file_open_double_click(col, row) {
872 return Ok(());
873 }
874
875 if let Some(explorer_area) = self.cached_layout.file_explorer_area {
877 if col >= explorer_area.x
878 && col < explorer_area.x + explorer_area.width
879 && row > explorer_area.y && row < explorer_area.y + explorer_area.height
881 {
882 self.file_explorer_open_file()?;
884 return Ok(());
885 }
886 }
887
888 let split_areas = self.cached_layout.split_areas.clone();
890 for (split_id, buffer_id, content_rect, _scrollbar_rect, _thumb_start, _thumb_end) in
891 &split_areas
892 {
893 if col >= content_rect.x
894 && col < content_rect.x + content_rect.width
895 && row >= content_rect.y
896 && row < content_rect.y + content_rect.height
897 {
898 if self.is_terminal_buffer(*buffer_id) {
900 self.key_context = crate::input::keybindings::KeyContext::Terminal;
901 return Ok(());
903 }
904
905 self.key_context = crate::input::keybindings::KeyContext::Normal;
906
907 self.handle_editor_double_click(col, row, *split_id, *buffer_id, *content_rect)?;
909 return Ok(());
910 }
911 }
912
913 Ok(())
914 }
915
916 fn handle_editor_double_click(
918 &mut self,
919 col: u16,
920 row: u16,
921 split_id: crate::model::event::SplitId,
922 buffer_id: BufferId,
923 content_rect: ratatui::layout::Rect,
924 ) -> AnyhowResult<()> {
925 use crate::model::event::Event;
926
927 self.focus_split(split_id, buffer_id);
929
930 let cached_mappings = self
932 .cached_layout
933 .view_line_mappings
934 .get(&split_id)
935 .cloned();
936
937 let fallback = self
939 .split_view_states
940 .get(&split_id)
941 .map(|vs| vs.viewport.top_byte)
942 .unwrap_or(0);
943
944 if let Some(state) = self.buffers.get_mut(&buffer_id) {
946 let gutter_width = state.margins.left_total_width() as u16;
947
948 let Some(target_position) = Self::screen_to_buffer_position(
949 col,
950 row,
951 content_rect,
952 gutter_width,
953 &cached_mappings,
954 fallback,
955 true, ) else {
957 return Ok(());
958 };
959
960 let primary_cursor_id = state.cursors.primary_id();
962 let event = Event::MoveCursor {
963 cursor_id: primary_cursor_id,
964 old_position: 0,
965 new_position: target_position,
966 old_anchor: None,
967 new_anchor: None,
968 old_sticky_column: 0,
969 new_sticky_column: 0,
970 };
971
972 if let Some(event_log) = self.event_logs.get_mut(&buffer_id) {
973 event_log.append(event.clone());
974 }
975 state.apply(&event);
976 }
977
978 self.handle_action(Action::SelectWord)?;
980
981 Ok(())
982 }
983 pub(super) fn handle_mouse_click(
985 &mut self,
986 col: u16,
987 row: u16,
988 modifiers: crossterm::event::KeyModifiers,
989 ) -> AnyhowResult<()> {
990 if self.tab_context_menu.is_some() {
992 if let Some(result) = self.handle_tab_context_menu_click(col, row) {
993 return result;
994 }
995 }
996
997 if !self.is_mouse_over_any_popup(col, row) {
1000 self.dismiss_transient_popups();
1001 }
1002
1003 if let Some((inner_rect, start_idx, _visible_count, total_count)) =
1005 &self.cached_layout.suggestions_area.clone()
1006 {
1007 if col >= inner_rect.x
1008 && col < inner_rect.x + inner_rect.width
1009 && row >= inner_rect.y
1010 && row < inner_rect.y + inner_rect.height
1011 {
1012 let relative_row = (row - inner_rect.y) as usize;
1013 let item_idx = start_idx + relative_row;
1014
1015 if item_idx < *total_count {
1016 if let Some(prompt) = &mut self.prompt {
1018 prompt.selected_suggestion = Some(item_idx);
1019 }
1020 return self.handle_action(Action::PromptConfirm);
1022 }
1023 }
1024 }
1025
1026 let scrollbar_scroll_info: Option<(usize, i32)> =
1029 self.cached_layout.popup_areas.iter().rev().find_map(
1030 |(
1031 popup_idx,
1032 _popup_rect,
1033 inner_rect,
1034 _scroll_offset,
1035 _num_items,
1036 scrollbar_rect,
1037 total_lines,
1038 )| {
1039 let sb_rect = scrollbar_rect.as_ref()?;
1040 if col >= sb_rect.x
1041 && col < sb_rect.x + sb_rect.width
1042 && row >= sb_rect.y
1043 && row < sb_rect.y + sb_rect.height
1044 {
1045 let relative_row = (row - sb_rect.y) as usize;
1046 let track_height = sb_rect.height as usize;
1047 let visible_lines = inner_rect.height as usize;
1048
1049 if track_height > 0 && *total_lines > visible_lines {
1050 let max_scroll = total_lines.saturating_sub(visible_lines);
1051 let target_scroll = if track_height > 1 {
1052 (relative_row * max_scroll) / (track_height.saturating_sub(1))
1053 } else {
1054 0
1055 };
1056 Some((*popup_idx, target_scroll as i32))
1057 } else {
1058 Some((*popup_idx, 0))
1059 }
1060 } else {
1061 None
1062 }
1063 },
1064 );
1065
1066 if let Some((popup_idx, target_scroll)) = scrollbar_scroll_info {
1067 self.mouse_state.dragging_popup_scrollbar = Some(popup_idx);
1069 self.mouse_state.drag_start_row = Some(row);
1070 let current_scroll = self
1072 .active_state()
1073 .popups
1074 .get(popup_idx)
1075 .map(|p| p.scroll_offset)
1076 .unwrap_or(0);
1077 self.mouse_state.drag_start_popup_scroll = Some(current_scroll);
1078 let state = self.active_state_mut();
1080 if let Some(popup) = state.popups.get_mut(popup_idx) {
1081 let delta = target_scroll - current_scroll as i32;
1082 popup.scroll_by(delta);
1083 }
1084 return Ok(());
1085 }
1086
1087 for (popup_idx, _popup_rect, inner_rect, scroll_offset, num_items, _, _) in
1089 self.cached_layout.popup_areas.iter().rev()
1090 {
1091 if col >= inner_rect.x
1092 && col < inner_rect.x + inner_rect.width
1093 && row >= inner_rect.y
1094 && row < inner_rect.y + inner_rect.height
1095 {
1096 let relative_col = (col - inner_rect.x) as usize;
1098 let relative_row = (row - inner_rect.y) as usize;
1099
1100 let link_url = {
1102 let state = self.active_state();
1103 state
1104 .popups
1105 .top()
1106 .and_then(|popup| popup.link_at_position(relative_col, relative_row))
1107 };
1108
1109 if let Some(url) = link_url {
1110 #[cfg(feature = "runtime")]
1112 if let Err(e) = open::that(&url) {
1113 self.set_status_message(format!("Failed to open URL: {}", e));
1114 } else {
1115 self.set_status_message(format!("Opening: {}", url));
1116 }
1117 return Ok(());
1118 }
1119
1120 if *num_items > 0 {
1122 let item_idx = scroll_offset + relative_row;
1123
1124 if item_idx < *num_items {
1125 let state = self.active_state_mut();
1127 if let Some(popup) = state.popups.top_mut() {
1128 if let crate::view::popup::PopupContent::List { items: _, selected } =
1129 &mut popup.content
1130 {
1131 *selected = item_idx;
1132 }
1133 }
1134 return self.handle_action(Action::PopupConfirm);
1136 }
1137 }
1138
1139 let is_text_popup = {
1141 let state = self.active_state();
1142 state.popups.top().is_some_and(|p| {
1143 matches!(
1144 p.content,
1145 crate::view::popup::PopupContent::Text(_)
1146 | crate::view::popup::PopupContent::Markdown(_)
1147 )
1148 })
1149 };
1150
1151 if is_text_popup {
1152 let line = scroll_offset + relative_row;
1153 let popup_idx_copy = *popup_idx; let state = self.active_state_mut();
1155 if let Some(popup) = state.popups.top_mut() {
1156 popup.start_selection(line, relative_col);
1157 }
1158 self.mouse_state.selecting_in_popup = Some(popup_idx_copy);
1160 return Ok(());
1161 }
1162 }
1163 }
1164
1165 if self.is_mouse_over_any_popup(col, row) {
1168 return Ok(());
1169 }
1170
1171 if self.is_file_open_active() && self.handle_file_open_click(col, row) {
1173 return Ok(());
1174 }
1175
1176 if self.menu_bar_visible {
1178 if let Some(ref menu_layout) = self.cached_layout.menu_layout {
1179 if let Some(menu_idx) = menu_layout.menu_at(col, row) {
1180 if self.menu_state.active_menu == Some(menu_idx) {
1182 self.close_menu_with_auto_hide();
1183 } else {
1184 self.on_editor_focus_lost();
1186 self.menu_state.open_menu(menu_idx);
1187 }
1188 return Ok(());
1189 } else if row == 0 {
1190 self.close_menu_with_auto_hide();
1192 return Ok(());
1193 }
1194 }
1195 }
1196
1197 if let Some(active_idx) = self.menu_state.active_menu {
1199 let all_menus: Vec<crate::config::Menu> = self
1200 .menus
1201 .menus
1202 .iter()
1203 .chain(self.menu_state.plugin_menus.iter())
1204 .cloned()
1205 .collect();
1206
1207 if let Some(menu) = all_menus.get(active_idx) {
1208 if let Some(click_result) = self.handle_menu_dropdown_click(col, row, menu)? {
1210 return click_result;
1211 }
1212 }
1213
1214 self.close_menu_with_auto_hide();
1216 return Ok(());
1217 }
1218
1219 if let Some(explorer_area) = self.cached_layout.file_explorer_area {
1221 if col >= explorer_area.x
1222 && col < explorer_area.x + explorer_area.width
1223 && row >= explorer_area.y
1224 && row < explorer_area.y + explorer_area.height
1225 {
1226 self.handle_file_explorer_click(col, row, explorer_area)?;
1227 return Ok(());
1228 }
1229 }
1230
1231 let scrollbar_hit = self.cached_layout.split_areas.iter().find_map(
1233 |(split_id, buffer_id, _content_rect, scrollbar_rect, thumb_start, thumb_end)| {
1234 if col >= scrollbar_rect.x
1235 && col < scrollbar_rect.x + scrollbar_rect.width
1236 && row >= scrollbar_rect.y
1237 && row < scrollbar_rect.y + scrollbar_rect.height
1238 {
1239 let relative_row = row.saturating_sub(scrollbar_rect.y) as usize;
1240 let is_on_thumb = relative_row >= *thumb_start && relative_row < *thumb_end;
1241 Some((*split_id, *buffer_id, *scrollbar_rect, is_on_thumb))
1242 } else {
1243 None
1244 }
1245 },
1246 );
1247
1248 if let Some((split_id, buffer_id, scrollbar_rect, is_on_thumb)) = scrollbar_hit {
1249 self.focus_split(split_id, buffer_id);
1250
1251 if is_on_thumb {
1252 self.mouse_state.dragging_scrollbar = Some(split_id);
1254 self.mouse_state.drag_start_row = Some(row);
1255 if let Some(view_state) = self.split_view_states.get(&split_id) {
1257 self.mouse_state.drag_start_top_byte = Some(view_state.viewport.top_byte);
1258 }
1259 } else {
1260 self.mouse_state.dragging_scrollbar = Some(split_id);
1262 self.handle_scrollbar_jump(col, row, split_id, buffer_id, scrollbar_rect)?;
1263 }
1264 return Ok(());
1265 }
1266
1267 if let Some((status_row, _status_x, _status_width)) = self.cached_layout.status_bar_area {
1269 if row == status_row {
1270 if let Some((le_row, le_start, le_end)) =
1272 self.cached_layout.status_bar_line_ending_area
1273 {
1274 if row == le_row && col >= le_start && col < le_end {
1275 return self.handle_action(Action::SetLineEnding);
1276 }
1277 }
1278
1279 if let Some((lang_row, lang_start, lang_end)) =
1281 self.cached_layout.status_bar_language_area
1282 {
1283 if row == lang_row && col >= lang_start && col < lang_end {
1284 return self.handle_action(Action::SetLanguage);
1285 }
1286 }
1287
1288 if let Some((lsp_row, lsp_start, lsp_end)) = self.cached_layout.status_bar_lsp_area
1290 {
1291 if row == lsp_row && col >= lsp_start && col < lsp_end {
1292 return self.handle_action(Action::ShowLspStatus);
1293 }
1294 }
1295
1296 if let Some((warn_row, warn_start, warn_end)) =
1298 self.cached_layout.status_bar_warning_area
1299 {
1300 if row == warn_row && col >= warn_start && col < warn_end {
1301 return self.handle_action(Action::ShowWarnings);
1302 }
1303 }
1304
1305 if let Some((msg_row, msg_start, msg_end)) =
1307 self.cached_layout.status_bar_message_area
1308 {
1309 if row == msg_row && col >= msg_start && col < msg_end {
1310 return self.handle_action(Action::ShowStatusLog);
1311 }
1312 }
1313 }
1314 }
1315
1316 if let Some(ref layout) = self.cached_layout.search_options_layout.clone() {
1318 use crate::view::ui::status_bar::SearchOptionsHover;
1319 if let Some(hover) = layout.checkbox_at(col, row) {
1320 match hover {
1321 SearchOptionsHover::CaseSensitive => {
1322 return self.handle_action(Action::ToggleSearchCaseSensitive);
1323 }
1324 SearchOptionsHover::WholeWord => {
1325 return self.handle_action(Action::ToggleSearchWholeWord);
1326 }
1327 SearchOptionsHover::Regex => {
1328 return self.handle_action(Action::ToggleSearchRegex);
1329 }
1330 SearchOptionsHover::ConfirmEach => {
1331 return self.handle_action(Action::ToggleSearchConfirmEach);
1332 }
1333 SearchOptionsHover::None => {}
1334 }
1335 }
1336 }
1337
1338 if let Some(explorer_area) = self.cached_layout.file_explorer_area {
1340 let border_x = explorer_area.x + explorer_area.width;
1341 if col == border_x
1342 && row >= explorer_area.y
1343 && row < explorer_area.y + explorer_area.height
1344 {
1345 self.mouse_state.dragging_file_explorer = true;
1347 self.mouse_state.drag_start_position = Some((col, row));
1348 self.mouse_state.drag_start_explorer_width = Some(self.file_explorer_width_percent);
1349 return Ok(());
1350 }
1351 }
1352
1353 for (split_id, direction, sep_x, sep_y, sep_length) in &self.cached_layout.separator_areas {
1355 let is_on_separator = match direction {
1356 SplitDirection::Horizontal => {
1357 row == *sep_y && col >= *sep_x && col < sep_x + sep_length
1359 }
1360 SplitDirection::Vertical => {
1361 col == *sep_x && row >= *sep_y && row < sep_y + sep_length
1363 }
1364 };
1365
1366 if is_on_separator {
1367 self.mouse_state.dragging_separator = Some((*split_id, *direction));
1369 self.mouse_state.drag_start_position = Some((col, row));
1370 if let Some(ratio) = self.split_manager.get_ratio(*split_id) {
1372 self.mouse_state.drag_start_ratio = Some(ratio);
1373 }
1374 return Ok(());
1375 }
1376 }
1377
1378 let close_split_click = self
1380 .cached_layout
1381 .close_split_areas
1382 .iter()
1383 .find(|(_, btn_row, start_col, end_col)| {
1384 row == *btn_row && col >= *start_col && col < *end_col
1385 })
1386 .map(|(split_id, _, _, _)| *split_id);
1387
1388 if let Some(split_id) = close_split_click {
1389 if let Err(e) = self.split_manager.close_split(split_id) {
1390 self.set_status_message(
1391 t!("error.cannot_close_split", error = e.to_string()).to_string(),
1392 );
1393 } else {
1394 let new_active_split = self.split_manager.active_split();
1396 if let Some(buffer_id) = self.split_manager.buffer_for_split(new_active_split) {
1397 self.set_active_buffer(buffer_id);
1398 }
1399 self.set_status_message(t!("split.closed").to_string());
1400 }
1401 return Ok(());
1402 }
1403
1404 let maximize_split_click = self
1406 .cached_layout
1407 .maximize_split_areas
1408 .iter()
1409 .find(|(_, btn_row, start_col, end_col)| {
1410 row == *btn_row && col >= *start_col && col < *end_col
1411 })
1412 .map(|(split_id, _, _, _)| *split_id);
1413
1414 if let Some(_split_id) = maximize_split_click {
1415 match self.split_manager.toggle_maximize() {
1417 Ok(maximized) => {
1418 if maximized {
1419 self.set_status_message(t!("split.maximized").to_string());
1420 } else {
1421 self.set_status_message(t!("split.restored").to_string());
1422 }
1423 }
1424 Err(e) => self.set_status_message(e),
1425 }
1426 return Ok(());
1427 }
1428
1429 for (split_id, tab_layout) in &self.cached_layout.tab_layouts {
1432 tracing::debug!(
1433 "Tab layout for split {:?}: bar_area={:?}, left_scroll={:?}, right_scroll={:?}",
1434 split_id,
1435 tab_layout.bar_area,
1436 tab_layout.left_scroll_area,
1437 tab_layout.right_scroll_area
1438 );
1439 }
1440
1441 let tab_hit = self
1442 .cached_layout
1443 .tab_layouts
1444 .iter()
1445 .find_map(|(split_id, tab_layout)| {
1446 let hit = tab_layout.hit_test(col, row);
1447 tracing::debug!(
1448 "Tab hit_test at ({}, {}) for split {:?} returned {:?}",
1449 col,
1450 row,
1451 split_id,
1452 hit
1453 );
1454 hit.map(|h| (*split_id, h))
1455 });
1456
1457 if let Some((split_id, hit)) = tab_hit {
1458 match hit {
1459 TabHit::CloseButton(buffer_id) => {
1460 self.focus_split(split_id, buffer_id);
1461 self.close_tab_in_split(buffer_id, split_id);
1462 return Ok(());
1463 }
1464 TabHit::TabName(buffer_id) => {
1465 self.focus_split(split_id, buffer_id);
1466 self.mouse_state.dragging_tab = Some(super::types::TabDragState::new(
1468 buffer_id,
1469 split_id,
1470 (col, row),
1471 ));
1472 return Ok(());
1473 }
1474 TabHit::ScrollLeft => {
1475 self.set_status_message("ScrollLeft clicked!".to_string());
1477 if let Some(view_state) = self.split_view_states.get_mut(&split_id) {
1478 view_state.tab_scroll_offset =
1479 view_state.tab_scroll_offset.saturating_sub(10);
1480 }
1481 return Ok(());
1482 }
1483 TabHit::ScrollRight => {
1484 self.set_status_message("ScrollRight clicked!".to_string());
1486 if let Some(view_state) = self.split_view_states.get_mut(&split_id) {
1487 view_state.tab_scroll_offset =
1488 view_state.tab_scroll_offset.saturating_add(10);
1489 }
1490 return Ok(());
1491 }
1492 TabHit::BarBackground => {}
1493 }
1494 }
1495
1496 tracing::debug!(
1498 "handle_mouse_click: checking {} split_areas for click at ({}, {})",
1499 self.cached_layout.split_areas.len(),
1500 col,
1501 row
1502 );
1503 for (split_id, buffer_id, content_rect, _scrollbar_rect, _thumb_start, _thumb_end) in
1504 &self.cached_layout.split_areas
1505 {
1506 tracing::debug!(
1507 " split_id={:?}, content_rect=({}, {}, {}x{})",
1508 split_id,
1509 content_rect.x,
1510 content_rect.y,
1511 content_rect.width,
1512 content_rect.height
1513 );
1514 if col >= content_rect.x
1515 && col < content_rect.x + content_rect.width
1516 && row >= content_rect.y
1517 && row < content_rect.y + content_rect.height
1518 {
1519 tracing::debug!(" -> HIT! calling handle_editor_click");
1521 self.handle_editor_click(
1522 col,
1523 row,
1524 *split_id,
1525 *buffer_id,
1526 *content_rect,
1527 modifiers,
1528 )?;
1529 return Ok(());
1530 }
1531 }
1532 tracing::debug!(" -> No split area hit");
1533
1534 Ok(())
1535 }
1536
1537 pub(super) fn handle_mouse_drag(&mut self, col: u16, row: u16) -> AnyhowResult<()> {
1539 if let Some(dragging_split_id) = self.mouse_state.dragging_scrollbar {
1541 for (split_id, buffer_id, _content_rect, scrollbar_rect, _thumb_start, _thumb_end) in
1543 &self.cached_layout.split_areas
1544 {
1545 if *split_id == dragging_split_id {
1546 if self.mouse_state.drag_start_row.is_some() {
1548 self.handle_scrollbar_drag_relative(
1550 row,
1551 *split_id,
1552 *buffer_id,
1553 *scrollbar_rect,
1554 )?;
1555 } else {
1556 self.handle_scrollbar_jump(
1558 col,
1559 row,
1560 *split_id,
1561 *buffer_id,
1562 *scrollbar_rect,
1563 )?;
1564 }
1565 return Ok(());
1566 }
1567 }
1568 }
1569
1570 if let Some(popup_idx) = self.mouse_state.selecting_in_popup {
1572 if let Some((_, _, inner_rect, scroll_offset, _, _, _)) = self
1574 .cached_layout
1575 .popup_areas
1576 .iter()
1577 .find(|(idx, _, _, _, _, _, _)| *idx == popup_idx)
1578 {
1579 if col >= inner_rect.x
1581 && col < inner_rect.x + inner_rect.width
1582 && row >= inner_rect.y
1583 && row < inner_rect.y + inner_rect.height
1584 {
1585 let relative_col = (col - inner_rect.x) as usize;
1586 let relative_row = (row - inner_rect.y) as usize;
1587 let line = scroll_offset + relative_row;
1588
1589 let state = self.active_state_mut();
1590 if let Some(popup) = state.popups.get_mut(popup_idx) {
1591 popup.extend_selection(line, relative_col);
1592 }
1593 }
1594 }
1595 return Ok(());
1596 }
1597
1598 if let Some(popup_idx) = self.mouse_state.dragging_popup_scrollbar {
1600 if let Some((_, _, inner_rect, _, _, Some(sb_rect), total_lines)) = self
1602 .cached_layout
1603 .popup_areas
1604 .iter()
1605 .find(|(idx, _, _, _, _, _, _)| *idx == popup_idx)
1606 {
1607 let track_height = sb_rect.height as usize;
1608 let visible_lines = inner_rect.height as usize;
1609
1610 if track_height > 0 && *total_lines > visible_lines {
1611 let relative_row = row.saturating_sub(sb_rect.y) as usize;
1612 let max_scroll = total_lines.saturating_sub(visible_lines);
1613 let target_scroll = if track_height > 1 {
1614 (relative_row * max_scroll) / (track_height.saturating_sub(1))
1615 } else {
1616 0
1617 };
1618
1619 let state = self.active_state_mut();
1620 if let Some(popup) = state.popups.get_mut(popup_idx) {
1621 let current_scroll = popup.scroll_offset as i32;
1622 let delta = target_scroll as i32 - current_scroll;
1623 popup.scroll_by(delta);
1624 }
1625 }
1626 }
1627 return Ok(());
1628 }
1629
1630 if let Some((split_id, direction)) = self.mouse_state.dragging_separator {
1632 self.handle_separator_drag(col, row, split_id, direction)?;
1633 return Ok(());
1634 }
1635
1636 if self.mouse_state.dragging_file_explorer {
1638 self.handle_file_explorer_border_drag(col)?;
1639 return Ok(());
1640 }
1641
1642 if self.mouse_state.dragging_text_selection {
1644 self.handle_text_selection_drag(col, row)?;
1645 return Ok(());
1646 }
1647
1648 if self.mouse_state.dragging_tab.is_some() {
1650 self.handle_tab_drag(col, row)?;
1651 return Ok(());
1652 }
1653
1654 Ok(())
1655 }
1656
1657 fn handle_text_selection_drag(&mut self, col: u16, row: u16) -> AnyhowResult<()> {
1659 use crate::model::event::Event;
1660
1661 let Some(split_id) = self.mouse_state.drag_selection_split else {
1662 return Ok(());
1663 };
1664 let Some(anchor_position) = self.mouse_state.drag_selection_anchor else {
1665 return Ok(());
1666 };
1667
1668 let buffer_id = self
1670 .cached_layout
1671 .split_areas
1672 .iter()
1673 .find(|(sid, _, _, _, _, _)| *sid == split_id)
1674 .map(|(_, bid, _, _, _, _)| *bid);
1675
1676 let Some(buffer_id) = buffer_id else {
1677 return Ok(());
1678 };
1679
1680 let content_rect = self
1682 .cached_layout
1683 .split_areas
1684 .iter()
1685 .find(|(sid, _, _, _, _, _)| *sid == split_id)
1686 .map(|(_, _, rect, _, _, _)| *rect);
1687
1688 let Some(content_rect) = content_rect else {
1689 return Ok(());
1690 };
1691
1692 let cached_mappings = self
1694 .cached_layout
1695 .view_line_mappings
1696 .get(&split_id)
1697 .cloned();
1698
1699 let fallback = self
1701 .split_view_states
1702 .get(&split_id)
1703 .map(|vs| vs.viewport.top_byte)
1704 .unwrap_or(0);
1705
1706 if let Some(state) = self.buffers.get_mut(&buffer_id) {
1708 let gutter_width = state.margins.left_total_width() as u16;
1709
1710 let Some(target_position) = Self::screen_to_buffer_position(
1711 col,
1712 row,
1713 content_rect,
1714 gutter_width,
1715 &cached_mappings,
1716 fallback,
1717 true, ) else {
1719 return Ok(());
1720 };
1721
1722 let primary_cursor_id = state.cursors.primary_id();
1724 let event = Event::MoveCursor {
1725 cursor_id: primary_cursor_id,
1726 old_position: 0,
1727 new_position: target_position,
1728 old_anchor: None,
1729 new_anchor: Some(anchor_position), old_sticky_column: 0,
1731 new_sticky_column: 0,
1732 };
1733
1734 if let Some(event_log) = self.event_logs.get_mut(&buffer_id) {
1735 event_log.append(event.clone());
1736 }
1737 state.apply(&event);
1738 }
1739
1740 Ok(())
1741 }
1742
1743 pub(super) fn handle_file_explorer_border_drag(&mut self, col: u16) -> AnyhowResult<()> {
1745 let Some((start_col, _start_row)) = self.mouse_state.drag_start_position else {
1746 return Ok(());
1747 };
1748 let Some(start_width) = self.mouse_state.drag_start_explorer_width else {
1749 return Ok(());
1750 };
1751
1752 let delta = col as i32 - start_col as i32;
1754 let total_width = self.terminal_width as i32;
1755
1756 if total_width > 0 {
1757 let percent_delta = delta as f32 / total_width as f32;
1759 let new_width = (start_width + percent_delta).clamp(0.1, 0.5);
1761 self.file_explorer_width_percent = new_width;
1762 }
1763
1764 Ok(())
1765 }
1766
1767 pub(super) fn handle_separator_drag(
1769 &mut self,
1770 col: u16,
1771 row: u16,
1772 split_id: SplitId,
1773 direction: SplitDirection,
1774 ) -> AnyhowResult<()> {
1775 let Some((start_col, start_row)) = self.mouse_state.drag_start_position else {
1776 return Ok(());
1777 };
1778 let Some(start_ratio) = self.mouse_state.drag_start_ratio else {
1779 return Ok(());
1780 };
1781 let Some(editor_area) = self.cached_layout.editor_content_area else {
1782 return Ok(());
1783 };
1784
1785 let (delta, total_size) = match direction {
1787 SplitDirection::Horizontal => {
1788 let delta = row as i32 - start_row as i32;
1790 let total = editor_area.height as i32;
1791 (delta, total)
1792 }
1793 SplitDirection::Vertical => {
1794 let delta = col as i32 - start_col as i32;
1796 let total = editor_area.width as i32;
1797 (delta, total)
1798 }
1799 };
1800
1801 if total_size > 0 {
1804 let ratio_delta = delta as f32 / total_size as f32;
1805 let new_ratio = (start_ratio + ratio_delta).clamp(0.1, 0.9);
1806
1807 let _ = self.split_manager.set_ratio(split_id, new_ratio);
1809 }
1810
1811 Ok(())
1812 }
1813
1814 pub(super) fn handle_right_click(&mut self, col: u16, row: u16) -> AnyhowResult<()> {
1816 if let Some(ref menu) = self.tab_context_menu {
1818 let menu_x = menu.position.0;
1819 let menu_y = menu.position.1;
1820 let menu_width = 22u16; let menu_height = super::types::TabContextMenuItem::all().len() as u16 + 2; if col >= menu_x
1825 && col < menu_x + menu_width
1826 && row >= menu_y
1827 && row < menu_y + menu_height
1828 {
1829 return Ok(());
1831 }
1832 }
1833
1834 let tab_hit =
1836 self.cached_layout.tab_layouts.iter().find_map(
1837 |(split_id, tab_layout)| match tab_layout.hit_test(col, row) {
1838 Some(TabHit::TabName(buffer_id) | TabHit::CloseButton(buffer_id)) => {
1839 Some((*split_id, buffer_id))
1840 }
1841 _ => None,
1842 },
1843 );
1844
1845 if let Some((split_id, buffer_id)) = tab_hit {
1846 self.tab_context_menu = Some(TabContextMenu::new(buffer_id, split_id, col, row + 1));
1848 } else {
1849 self.tab_context_menu = None;
1851 }
1852
1853 Ok(())
1854 }
1855
1856 pub(super) fn handle_tab_context_menu_click(
1858 &mut self,
1859 col: u16,
1860 row: u16,
1861 ) -> Option<AnyhowResult<()>> {
1862 let menu = self.tab_context_menu.as_ref()?;
1863 let menu_x = menu.position.0;
1864 let menu_y = menu.position.1;
1865 let menu_width = 22u16;
1866 let items = super::types::TabContextMenuItem::all();
1867 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
1871 {
1872 self.tab_context_menu = None;
1874 return Some(Ok(()));
1875 }
1876
1877 if row == menu_y || row == menu_y + menu_height - 1 {
1879 return Some(Ok(()));
1880 }
1881
1882 let item_idx = (row - menu_y - 1) as usize;
1884 if item_idx >= items.len() {
1885 return Some(Ok(()));
1886 }
1887
1888 let buffer_id = menu.buffer_id;
1890 let split_id = menu.split_id;
1891 let item = items[item_idx];
1892
1893 self.tab_context_menu = None;
1895
1896 Some(self.execute_tab_context_menu_action(item, buffer_id, split_id))
1898 }
1899
1900 fn execute_tab_context_menu_action(
1902 &mut self,
1903 item: super::types::TabContextMenuItem,
1904 buffer_id: BufferId,
1905 split_id: SplitId,
1906 ) -> AnyhowResult<()> {
1907 use super::types::TabContextMenuItem;
1908
1909 match item {
1910 TabContextMenuItem::Close => {
1911 self.close_tab_in_split(buffer_id, split_id);
1912 }
1913 TabContextMenuItem::CloseOthers => {
1914 self.close_other_tabs_in_split(buffer_id, split_id);
1915 }
1916 TabContextMenuItem::CloseToRight => {
1917 self.close_tabs_to_right_in_split(buffer_id, split_id);
1918 }
1919 TabContextMenuItem::CloseToLeft => {
1920 self.close_tabs_to_left_in_split(buffer_id, split_id);
1921 }
1922 TabContextMenuItem::CloseAll => {
1923 self.close_all_tabs_in_split(split_id);
1924 }
1925 }
1926
1927 Ok(())
1928 }
1929
1930 fn show_file_explorer_status_tooltip(&mut self, path: std::path::PathBuf, col: u16, row: u16) {
1932 use crate::view::popup::{Popup, PopupPosition};
1933 use ratatui::style::Style;
1934
1935 let is_directory = path.is_dir();
1936
1937 let decoration = self
1939 .file_explorer_decoration_cache
1940 .direct_for_path(&path)
1941 .cloned();
1942
1943 let bubbled_decoration = if is_directory && decoration.is_none() {
1945 self.file_explorer_decoration_cache
1946 .bubbled_for_path(&path)
1947 .cloned()
1948 } else {
1949 None
1950 };
1951
1952 let has_unsaved_changes = if is_directory {
1954 self.buffers.iter().any(|(buffer_id, state)| {
1956 if state.buffer.is_modified() {
1957 if let Some(metadata) = self.buffer_metadata.get(buffer_id) {
1958 if let Some(file_path) = metadata.file_path() {
1959 return file_path.starts_with(&path);
1960 }
1961 }
1962 }
1963 false
1964 })
1965 } else {
1966 self.buffers.iter().any(|(buffer_id, state)| {
1967 if state.buffer.is_modified() {
1968 if let Some(metadata) = self.buffer_metadata.get(buffer_id) {
1969 return metadata.file_path() == Some(&path);
1970 }
1971 }
1972 false
1973 })
1974 };
1975
1976 let mut lines: Vec<String> = Vec::new();
1978
1979 if let Some(decoration) = &decoration {
1980 let symbol = &decoration.symbol;
1981 let explanation = match symbol.as_str() {
1982 "U" => "Untracked - File is not tracked by git",
1983 "M" => "Modified - File has unstaged changes",
1984 "A" => "Added - File is staged for commit",
1985 "D" => "Deleted - File is staged for deletion",
1986 "R" => "Renamed - File has been renamed",
1987 "C" => "Copied - File has been copied",
1988 "!" => "Conflicted - File has merge conflicts",
1989 "●" => "Has changes - Contains modified files",
1990 _ => "Unknown status",
1991 };
1992 lines.push(format!("{} - {}", symbol, explanation));
1993 } else if bubbled_decoration.is_some() {
1994 lines.push("● - Contains modified files".to_string());
1995 } else if has_unsaved_changes {
1996 if is_directory {
1997 lines.push("● - Contains unsaved changes".to_string());
1998 } else {
1999 lines.push("● - Unsaved changes in editor".to_string());
2000 }
2001 } else {
2002 return; }
2004
2005 if is_directory {
2007 if let Some(modified_files) = self.get_modified_files_in_directory(&path) {
2009 lines.push(String::new()); lines.push("Modified files:".to_string());
2011 let resolved_path = path.canonicalize().unwrap_or_else(|_| path.clone());
2013 const MAX_FILES: usize = 8;
2014 for (i, file) in modified_files.iter().take(MAX_FILES).enumerate() {
2015 let display_name = file
2017 .strip_prefix(&resolved_path)
2018 .unwrap_or(file)
2019 .to_string_lossy()
2020 .to_string();
2021 lines.push(format!(" {}", display_name));
2022 if i == MAX_FILES - 1 && modified_files.len() > MAX_FILES {
2023 lines.push(format!(
2024 " ... and {} more",
2025 modified_files.len() - MAX_FILES
2026 ));
2027 break;
2028 }
2029 }
2030 }
2031 } else {
2032 if let Some(stats) = self.get_git_diff_stats(&path) {
2034 lines.push(String::new()); lines.push(stats);
2036 }
2037 }
2038
2039 if lines.is_empty() {
2040 return;
2041 }
2042
2043 let mut popup = Popup::text(lines, &self.theme);
2045 popup.title = Some("Git Status".to_string());
2046 popup.transient = true;
2047 popup.position = PopupPosition::Fixed { x: col, y: row + 1 };
2048 popup.width = 50;
2049 popup.max_height = 15;
2050 popup.border_style = Style::default().fg(self.theme.popup_border_fg);
2051 popup.background_style = Style::default().bg(self.theme.popup_bg);
2052
2053 if let Some(state) = self.buffers.get_mut(&self.active_buffer()) {
2055 state.popups.show(popup);
2056 }
2057 }
2058
2059 fn dismiss_file_explorer_status_tooltip(&mut self) {
2061 if let Some(state) = self.buffers.get_mut(&self.active_buffer()) {
2063 state.popups.dismiss_transient();
2064 }
2065 }
2066
2067 fn get_git_diff_stats(&self, path: &std::path::Path) -> Option<String> {
2069 use std::process::Command;
2070
2071 let output = Command::new("git")
2073 .args(["diff", "--numstat", "--"])
2074 .arg(path)
2075 .current_dir(&self.working_dir)
2076 .output()
2077 .ok()?;
2078
2079 if !output.status.success() {
2080 return None;
2081 }
2082
2083 let stdout = String::from_utf8_lossy(&output.stdout);
2084 let line = stdout.lines().next()?;
2085 let parts: Vec<&str> = line.split('\t').collect();
2086
2087 if parts.len() >= 2 {
2088 let insertions = parts[0];
2089 let deletions = parts[1];
2090
2091 if insertions == "-" && deletions == "-" {
2093 return Some("Binary file changed".to_string());
2094 }
2095
2096 let ins: i32 = insertions.parse().unwrap_or(0);
2097 let del: i32 = deletions.parse().unwrap_or(0);
2098
2099 if ins > 0 || del > 0 {
2100 return Some(format!("+{} -{} lines", ins, del));
2101 }
2102 }
2103
2104 let staged_output = Command::new("git")
2106 .args(["diff", "--numstat", "--cached", "--"])
2107 .arg(path)
2108 .current_dir(&self.working_dir)
2109 .output()
2110 .ok()?;
2111
2112 if staged_output.status.success() {
2113 let staged_stdout = String::from_utf8_lossy(&staged_output.stdout);
2114 if let Some(line) = staged_stdout.lines().next() {
2115 let parts: Vec<&str> = line.split('\t').collect();
2116 if parts.len() >= 2 {
2117 let insertions = parts[0];
2118 let deletions = parts[1];
2119
2120 if insertions == "-" && deletions == "-" {
2121 return Some("Binary file staged".to_string());
2122 }
2123
2124 let ins: i32 = insertions.parse().unwrap_or(0);
2125 let del: i32 = deletions.parse().unwrap_or(0);
2126
2127 if ins > 0 || del > 0 {
2128 return Some(format!("+{} -{} lines (staged)", ins, del));
2129 }
2130 }
2131 }
2132 }
2133
2134 None
2135 }
2136
2137 fn get_modified_files_in_directory(
2139 &self,
2140 dir_path: &std::path::Path,
2141 ) -> Option<Vec<std::path::PathBuf>> {
2142 use std::process::Command;
2143
2144 let resolved_path = dir_path
2146 .canonicalize()
2147 .unwrap_or_else(|_| dir_path.to_path_buf());
2148
2149 let output = Command::new("git")
2151 .args(["status", "--porcelain", "--"])
2152 .arg(&resolved_path)
2153 .current_dir(&self.working_dir)
2154 .output()
2155 .ok()?;
2156
2157 if !output.status.success() {
2158 return None;
2159 }
2160
2161 let stdout = String::from_utf8_lossy(&output.stdout);
2162 let modified_files: Vec<std::path::PathBuf> = stdout
2163 .lines()
2164 .filter_map(|line| {
2165 if line.len() > 3 {
2168 let file_part = &line[3..];
2169 let file_name = if file_part.contains(" -> ") {
2171 file_part.split(" -> ").last().unwrap_or(file_part)
2172 } else {
2173 file_part
2174 };
2175 Some(self.working_dir.join(file_name))
2176 } else {
2177 None
2178 }
2179 })
2180 .collect();
2181
2182 if modified_files.is_empty() {
2183 None
2184 } else {
2185 Some(modified_files)
2186 }
2187 }
2188}