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 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, is_triple_click) =
34 if matches!(mouse_event.kind, MouseEventKind::Down(MouseButton::Left)) {
35 let now = self.time_source.now();
36 let is_consecutive = if let (Some(previous_time), Some(previous_pos)) =
37 (self.previous_click_time, self.previous_click_position)
38 {
39 let threshold =
40 std::time::Duration::from_millis(self.config.editor.double_click_time_ms);
41 let within_time = now.duration_since(previous_time) < threshold;
42 let same_position = previous_pos == (col, row);
43 within_time && same_position
44 } else {
45 false
46 };
47
48 if is_consecutive {
50 self.click_count += 1;
51 } else {
52 self.click_count = 1;
53 }
54 self.previous_click_time = Some(now);
55 self.previous_click_position = Some((col, row));
56
57 let is_triple = self.click_count >= 3;
58 let is_double = self.click_count == 2;
59
60 if is_triple {
61 self.click_count = 0;
63 self.previous_click_time = None;
64 self.previous_click_position = None;
65 }
66
67 (is_double, is_triple)
68 } else {
69 (false, false)
70 };
71
72 if self.keybinding_editor.is_some() {
74 return self.handle_keybinding_editor_mouse(mouse_event);
75 }
76
77 if self.settings_state.as_ref().is_some_and(|s| s.visible) {
79 return self.handle_settings_mouse(mouse_event, is_double_click);
80 }
81
82 if self.calibration_wizard.is_some() {
84 return Ok(false);
85 }
86
87 let mut needs_render = false;
89 if let Some(ref prompt) = self.prompt {
90 if matches!(prompt.prompt_type, PromptType::LspRename { .. }) {
91 self.cancel_prompt();
92 needs_render = true;
93 }
94 }
95
96 let cursor_moved = self.mouse_cursor_position != Some((col, row));
99 self.mouse_cursor_position = Some((col, row));
100 if self.gpm_active && cursor_moved {
101 needs_render = true;
102 }
103
104 tracing::trace!(
105 "handle_mouse: kind={:?}, col={}, row={}",
106 mouse_event.kind,
107 col,
108 row
109 );
110
111 if let Some(result) = self.try_forward_mouse_to_terminal(col, row, mouse_event) {
114 return result;
115 }
116
117 if self.theme_info_popup.is_some() {
119 if let MouseEventKind::Down(MouseButton::Left) = mouse_event.kind {
120 if let Some((popup_rect, button_row_offset)) = self.theme_info_popup_rect() {
121 let popup_x = popup_rect.x;
122 let popup_y = popup_rect.y;
123 let popup_w = popup_rect.width;
124 let popup_h = popup_rect.height;
125 let in_popup = col >= popup_x
126 && col < popup_x + popup_w
127 && row >= popup_y
128 && row < popup_y + popup_h;
129
130 if in_popup {
131 let actual_button_row = popup_y + button_row_offset;
133 if row == actual_button_row {
134 let fg_key = self
135 .theme_info_popup
136 .as_ref()
137 .and_then(|p| p.info.fg_key.clone());
138 self.theme_info_popup = None;
139 if let Some(key) = fg_key {
140 self.fire_theme_inspect_hook(key);
141 }
142 return Ok(true);
143 }
144 return Ok(true);
146 }
147 }
148 self.theme_info_popup = None;
150 needs_render = true;
151 }
152 }
153
154 match mouse_event.kind {
155 MouseEventKind::Down(MouseButton::Left) => {
156 if is_double_click || is_triple_click {
157 if let Some((buffer_id, byte_pos)) =
158 self.fold_toggle_line_at_screen_position(col, row)
159 {
160 self.toggle_fold_at_byte(buffer_id, byte_pos);
161 needs_render = true;
162 return Ok(needs_render);
163 }
164 }
165 if is_triple_click {
166 self.handle_mouse_triple_click(col, row)?;
168 needs_render = true;
169 return Ok(needs_render);
170 }
171 if is_double_click {
172 self.handle_mouse_double_click(col, row)?;
174 needs_render = true;
175 return Ok(needs_render);
176 }
177 self.handle_mouse_click(col, row, mouse_event.modifiers)?;
178 needs_render = true;
179 }
180 MouseEventKind::Drag(MouseButton::Left) => {
181 self.handle_mouse_drag(col, row)?;
182 needs_render = true;
183 }
184 MouseEventKind::Up(MouseButton::Left) => {
185 let was_dragging_separator = self.mouse_state.dragging_separator.is_some();
187
188 if let Some(drag_state) = self.mouse_state.dragging_tab.take() {
190 if drag_state.is_dragging() {
191 if let Some(drop_zone) = drag_state.drop_zone {
192 self.execute_tab_drop(
193 drag_state.buffer_id,
194 drag_state.source_split_id,
195 drop_zone,
196 );
197 }
198 }
199 }
200
201 self.mouse_state.dragging_scrollbar = None;
203 self.mouse_state.drag_start_row = None;
204 self.mouse_state.drag_start_top_byte = None;
205 self.mouse_state.dragging_horizontal_scrollbar = None;
206 self.mouse_state.drag_start_hcol = None;
207 self.mouse_state.drag_start_left_column = None;
208 self.mouse_state.dragging_separator = None;
209 self.mouse_state.drag_start_position = None;
210 self.mouse_state.drag_start_ratio = None;
211 self.mouse_state.dragging_file_explorer = false;
212 self.mouse_state.drag_start_explorer_width = None;
213 self.mouse_state.dragging_text_selection = false;
215 self.mouse_state.drag_selection_split = None;
216 self.mouse_state.drag_selection_anchor = None;
217 self.mouse_state.drag_selection_by_words = false;
218 self.mouse_state.drag_selection_word_end = None;
219 self.mouse_state.dragging_popup_scrollbar = None;
221 self.mouse_state.drag_start_popup_scroll = None;
222 self.mouse_state.selecting_in_popup = None;
224
225 if was_dragging_separator {
227 self.resize_visible_terminals();
228 }
229
230 needs_render = true;
231 }
232 MouseEventKind::Moved => {
233 {
235 let content_rect = self
237 .cached_layout
238 .split_areas
239 .iter()
240 .find(|(_, _, content_rect, _, _, _)| {
241 col >= content_rect.x
242 && col < content_rect.x + content_rect.width
243 && row >= content_rect.y
244 && row < content_rect.y + content_rect.height
245 })
246 .map(|(_, _, rect, _, _, _)| *rect);
247
248 let (content_x, content_y) = content_rect.map(|r| (r.x, r.y)).unwrap_or((0, 0));
249
250 self.plugin_manager.run_hook(
251 "mouse_move",
252 HookArgs::MouseMove {
253 column: col,
254 row,
255 content_x,
256 content_y,
257 },
258 );
259 }
260
261 let hover_changed = self.update_hover_target(col, row);
264 needs_render = needs_render || hover_changed;
265
266 if let Some((popup_rect, button_row_offset)) = self.theme_info_popup_rect() {
268 let button_row = popup_rect.y + button_row_offset;
269 let new_highlighted = row == button_row
270 && col >= popup_rect.x
271 && col < popup_rect.x + popup_rect.width;
272 if let Some(ref mut popup) = self.theme_info_popup {
273 if popup.button_highlighted != new_highlighted {
274 popup.button_highlighted = new_highlighted;
275 needs_render = true;
276 }
277 }
278 }
279
280 self.update_lsp_hover_state(col, row);
282 }
283 MouseEventKind::ScrollUp => {
284 if mouse_event
286 .modifiers
287 .contains(crossterm::event::KeyModifiers::SHIFT)
288 {
289 self.handle_horizontal_scroll(col, row, -3)?;
290 needs_render = true;
291 } else if self.handle_prompt_scroll(-3) {
292 needs_render = true;
294 } else if self.is_file_open_active()
295 && self.is_mouse_over_file_browser(col, row)
296 && self.handle_file_open_scroll(-3)
297 {
298 needs_render = true;
300 } else if self.is_mouse_over_any_popup(col, row) {
301 self.scroll_popup(-3);
303 needs_render = true;
304 } else {
305 if self.terminal_mode && self.is_terminal_buffer(self.active_buffer()) {
307 self.sync_terminal_to_buffer(self.active_buffer());
308 self.terminal_mode = false;
309 self.key_context = crate::input::keybindings::KeyContext::Normal;
310 }
311 self.dismiss_transient_popups();
313 self.handle_mouse_scroll(col, row, -3)?;
314 needs_render = true;
316 }
317 }
318 MouseEventKind::ScrollDown => {
319 if mouse_event
321 .modifiers
322 .contains(crossterm::event::KeyModifiers::SHIFT)
323 {
324 self.handle_horizontal_scroll(col, row, 3)?;
325 needs_render = true;
326 } else if self.handle_prompt_scroll(3) {
327 needs_render = true;
329 } else if self.is_file_open_active()
330 && self.is_mouse_over_file_browser(col, row)
331 && self.handle_file_open_scroll(3)
332 {
333 needs_render = true;
334 } else if self.is_mouse_over_any_popup(col, row) {
335 self.scroll_popup(3);
337 needs_render = true;
338 } else {
339 if self.terminal_mode && self.is_terminal_buffer(self.active_buffer()) {
341 self.sync_terminal_to_buffer(self.active_buffer());
342 self.terminal_mode = false;
343 self.key_context = crate::input::keybindings::KeyContext::Normal;
344 }
345 self.dismiss_transient_popups();
347 self.handle_mouse_scroll(col, row, 3)?;
348 needs_render = true;
350 }
351 }
352 MouseEventKind::ScrollLeft => {
353 self.handle_horizontal_scroll(col, row, -3)?;
355 needs_render = true;
356 }
357 MouseEventKind::ScrollRight => {
358 self.handle_horizontal_scroll(col, row, 3)?;
360 needs_render = true;
361 }
362 MouseEventKind::Down(MouseButton::Right) => {
363 if mouse_event
364 .modifiers
365 .contains(crossterm::event::KeyModifiers::CONTROL)
366 {
367 self.show_theme_info_popup(col, row)?;
369 } else {
370 self.handle_right_click(col, row)?;
372 }
373 needs_render = true;
374 }
375 _ => {
376 }
378 }
379
380 self.mouse_state.last_position = Some((col, row));
381 Ok(needs_render)
382 }
383
384 pub(super) fn update_hover_target(&mut self, col: u16, row: u16) -> bool {
387 let old_target = self.mouse_state.hover_target.clone();
388 let new_target = self.compute_hover_target(col, row);
389 let changed = old_target != new_target;
390 self.mouse_state.hover_target = new_target.clone();
391
392 if let Some(active_menu_idx) = self.menu_state.active_menu {
395 if let Some(HoverTarget::MenuBarItem(hovered_menu_idx)) = new_target.clone() {
396 if hovered_menu_idx != active_menu_idx {
397 self.menu_state.open_menu(hovered_menu_idx);
398 return true; }
400 }
401
402 if let Some(HoverTarget::MenuDropdownItem(_, item_idx)) = new_target.clone() {
404 let all_menus: Vec<crate::config::Menu> = self
405 .menus
406 .menus
407 .iter()
408 .chain(self.menu_state.plugin_menus.iter())
409 .cloned()
410 .collect();
411
412 if self.menu_state.submenu_path.first() == Some(&item_idx) {
415 tracing::trace!(
416 "menu hover: staying on submenu parent item_idx={}, submenu_path={:?}",
417 item_idx,
418 self.menu_state.submenu_path
419 );
420 return changed;
421 }
422
423 if !self.menu_state.submenu_path.is_empty() {
425 tracing::trace!(
426 "menu hover: clearing submenu_path={:?} for different item_idx={}",
427 self.menu_state.submenu_path,
428 item_idx
429 );
430 self.menu_state.submenu_path.clear();
431 self.menu_state.highlighted_item = Some(item_idx);
432 return true;
433 }
434
435 if let Some(menu) = all_menus.get(active_menu_idx) {
437 if let Some(crate::config::MenuItem::Submenu { items, .. }) =
438 menu.items.get(item_idx)
439 {
440 if !items.is_empty() {
441 tracing::trace!("menu hover: opening submenu at item_idx={}", item_idx);
442 self.menu_state.submenu_path.push(item_idx);
443 self.menu_state.highlighted_item = Some(0);
444 return true;
445 }
446 }
447 }
448 if self.menu_state.highlighted_item != Some(item_idx) {
450 self.menu_state.highlighted_item = Some(item_idx);
451 return true;
452 }
453 }
454
455 if let Some(HoverTarget::SubmenuItem(depth, item_idx)) = new_target {
457 if self.menu_state.submenu_path.len() > depth
461 && self.menu_state.submenu_path.get(depth) == Some(&item_idx)
462 {
463 tracing::trace!(
464 "menu hover: staying on nested submenu parent depth={}, item_idx={}, submenu_path={:?}",
465 depth,
466 item_idx,
467 self.menu_state.submenu_path
468 );
469 return changed;
470 }
471
472 if self.menu_state.submenu_path.len() > depth {
474 tracing::trace!(
475 "menu hover: truncating submenu_path={:?} to depth={} for item_idx={}",
476 self.menu_state.submenu_path,
477 depth,
478 item_idx
479 );
480 self.menu_state.submenu_path.truncate(depth);
481 }
482
483 let all_menus: Vec<crate::config::Menu> = self
484 .menus
485 .menus
486 .iter()
487 .chain(self.menu_state.plugin_menus.iter())
488 .cloned()
489 .collect();
490
491 if let Some(items) = self
493 .menu_state
494 .get_current_items(&all_menus, active_menu_idx)
495 {
496 if let Some(crate::config::MenuItem::Submenu {
498 items: sub_items, ..
499 }) = items.get(item_idx)
500 {
501 if !sub_items.is_empty()
502 && !self.menu_state.submenu_path.contains(&item_idx)
503 {
504 tracing::trace!(
505 "menu hover: opening nested submenu at depth={}, item_idx={}",
506 depth,
507 item_idx
508 );
509 self.menu_state.submenu_path.push(item_idx);
510 self.menu_state.highlighted_item = Some(0);
511 return true;
512 }
513 }
514 if self.menu_state.highlighted_item != Some(item_idx) {
516 self.menu_state.highlighted_item = Some(item_idx);
517 return true;
518 }
519 }
520 }
521 }
522
523 if let Some(HoverTarget::TabContextMenuItem(item_idx)) = new_target.clone() {
525 if let Some(ref mut menu) = self.tab_context_menu {
526 if menu.highlighted != item_idx {
527 menu.highlighted = item_idx;
528 return true;
529 }
530 }
531 }
532
533 if old_target != new_target
536 && matches!(
537 old_target,
538 Some(HoverTarget::FileExplorerStatusIndicator(_))
539 )
540 {
541 self.dismiss_file_explorer_status_tooltip();
542 }
543
544 if let Some(HoverTarget::FileExplorerStatusIndicator(ref path)) = new_target {
545 if old_target != new_target {
547 self.show_file_explorer_status_tooltip(path.clone(), col, row);
548 return true;
549 }
550 }
551
552 changed
553 }
554
555 fn update_lsp_hover_state(&mut self, col: u16, row: u16) {
564 tracing::trace!(col, row, "update_lsp_hover_state: raw mouse position");
565
566 if self.theme_info_popup.is_some() || self.tab_context_menu.is_some() {
569 if self.mouse_state.lsp_hover_state.is_some() {
570 self.mouse_state.lsp_hover_state = None;
571 self.mouse_state.lsp_hover_request_sent = false;
572 self.dismiss_transient_popups();
573 }
574 return;
575 }
576
577 if self.is_mouse_over_transient_popup(col, row) {
579 return;
580 }
581
582 let split_info = self
584 .cached_layout
585 .split_areas
586 .iter()
587 .find(|(_, _, content_rect, _, _, _)| {
588 col >= content_rect.x
589 && col < content_rect.x + content_rect.width
590 && row >= content_rect.y
591 && row < content_rect.y + content_rect.height
592 })
593 .map(|(split_id, buffer_id, content_rect, _, _, _)| {
594 (*split_id, *buffer_id, *content_rect)
595 });
596
597 let Some((split_id, buffer_id, content_rect)) = split_info else {
598 if self.mouse_state.lsp_hover_state.is_some() {
600 self.mouse_state.lsp_hover_state = None;
601 self.mouse_state.lsp_hover_request_sent = false;
602 self.dismiss_transient_popups();
603 }
604 return;
605 };
606
607 let cached_mappings = self
609 .cached_layout
610 .view_line_mappings
611 .get(&split_id)
612 .cloned();
613 let gutter_width = self
614 .buffers
615 .get(&buffer_id)
616 .map(|s| s.margins.left_total_width() as u16)
617 .unwrap_or(0);
618 let fallback = self
619 .buffers
620 .get(&buffer_id)
621 .map(|s| s.buffer.len())
622 .unwrap_or(0);
623
624 let compose_width = self
626 .split_view_states
627 .get(&split_id)
628 .and_then(|vs| vs.compose_width);
629
630 let Some(byte_pos) = Self::screen_to_buffer_position(
632 col,
633 row,
634 content_rect,
635 gutter_width,
636 &cached_mappings,
637 fallback,
638 false, compose_width,
640 ) else {
641 if self.mouse_state.lsp_hover_state.is_some() {
643 self.mouse_state.lsp_hover_state = None;
644 self.mouse_state.lsp_hover_request_sent = false;
645 self.dismiss_transient_popups();
646 }
647 return;
648 };
649
650 let content_col = col.saturating_sub(content_rect.x);
652 let text_col = content_col.saturating_sub(gutter_width) as usize;
653 let visual_row = row.saturating_sub(content_rect.y) as usize;
654
655 let line_info = cached_mappings
656 .as_ref()
657 .and_then(|mappings| mappings.get(visual_row))
658 .map(|line_mapping| {
659 (
660 line_mapping.visual_to_char.len(),
661 line_mapping.line_end_byte,
662 )
663 });
664
665 let is_past_line_end_or_empty = line_info
666 .map(|(line_len, _)| {
667 if line_len <= 1 {
669 return true;
670 }
671 text_col >= line_len
672 })
673 .unwrap_or(true);
675
676 tracing::trace!(
677 col,
678 row,
679 content_col,
680 text_col,
681 visual_row,
682 gutter_width,
683 byte_pos,
684 ?line_info,
685 is_past_line_end_or_empty,
686 "update_lsp_hover_state: position check"
687 );
688
689 if is_past_line_end_or_empty {
690 tracing::trace!(
691 "update_lsp_hover_state: mouse past line end or empty line, clearing hover"
692 );
693 if self.mouse_state.lsp_hover_state.is_some() {
695 self.mouse_state.lsp_hover_state = None;
696 self.mouse_state.lsp_hover_request_sent = false;
697 self.dismiss_transient_popups();
698 }
699 return;
700 }
701
702 if let Some((start, end)) = self.hover_symbol_range {
704 if byte_pos >= start && byte_pos < end {
705 return;
707 }
708 }
709
710 if let Some((old_pos, _, _, _)) = self.mouse_state.lsp_hover_state {
712 if old_pos == byte_pos {
713 return;
715 }
716 self.dismiss_transient_popups();
718 }
719
720 self.mouse_state.lsp_hover_state = Some((byte_pos, std::time::Instant::now(), col, row));
722 self.mouse_state.lsp_hover_request_sent = false;
723 }
724
725 fn is_mouse_over_transient_popup(&self, col: u16, row: u16) -> bool {
727 let layouts = popup_areas_to_layout_info(&self.cached_layout.popup_areas);
728 let hit_tester = PopupHitTester::new(&layouts, &self.active_state().popups);
729 hit_tester.is_over_transient_popup(col, row)
730 }
731
732 fn is_mouse_over_any_popup(&self, col: u16, row: u16) -> bool {
734 let layouts = popup_areas_to_layout_info(&self.cached_layout.popup_areas);
735 let hit_tester = PopupHitTester::new(&layouts, &self.active_state().popups);
736 hit_tester.is_over_popup(col, row)
737 }
738
739 fn is_mouse_over_file_browser(&self, col: u16, row: u16) -> bool {
741 self.file_browser_layout
742 .as_ref()
743 .is_some_and(|layout| layout.contains(col, row))
744 }
745
746 pub(super) fn split_at_position(&self, col: u16, row: u16) -> Option<(LeafId, BufferId)> {
749 for &(split_id, buffer_id, content_rect, scrollbar_rect, _, _) in
750 &self.cached_layout.split_areas
751 {
752 let in_content = col >= content_rect.x
753 && col < content_rect.x + content_rect.width
754 && row >= content_rect.y
755 && row < content_rect.y + content_rect.height;
756 let in_scrollbar = scrollbar_rect.width > 0
757 && scrollbar_rect.height > 0
758 && col >= scrollbar_rect.x
759 && col < scrollbar_rect.x + scrollbar_rect.width
760 && row >= scrollbar_rect.y
761 && row < scrollbar_rect.y + scrollbar_rect.height;
762 if in_content || in_scrollbar {
763 return Some((split_id, buffer_id));
764 }
765 }
766 None
767 }
768
769 fn compute_hover_target(&self, col: u16, row: u16) -> Option<HoverTarget> {
771 if let Some(ref menu) = self.tab_context_menu {
773 let menu_x = menu.position.0;
774 let menu_y = menu.position.1;
775 let menu_width = 22u16;
776 let items = super::types::TabContextMenuItem::all();
777 let menu_height = items.len() as u16 + 2;
778
779 if col >= menu_x
780 && col < menu_x + menu_width
781 && row > menu_y
782 && row < menu_y + menu_height - 1
783 {
784 let item_idx = (row - menu_y - 1) as usize;
785 if item_idx < items.len() {
786 return Some(HoverTarget::TabContextMenuItem(item_idx));
787 }
788 }
789 }
790
791 if let Some((inner_rect, start_idx, _visible_count, total_count)) =
793 &self.cached_layout.suggestions_area
794 {
795 if col >= inner_rect.x
796 && col < inner_rect.x + inner_rect.width
797 && row >= inner_rect.y
798 && row < inner_rect.y + inner_rect.height
799 {
800 let relative_row = (row - inner_rect.y) as usize;
801 let item_idx = start_idx + relative_row;
802
803 if item_idx < *total_count {
804 return Some(HoverTarget::SuggestionItem(item_idx));
805 }
806 }
807 }
808
809 for (popup_idx, _popup_rect, inner_rect, scroll_offset, num_items, _, _) in
812 self.cached_layout.popup_areas.iter().rev()
813 {
814 if col >= inner_rect.x
815 && col < inner_rect.x + inner_rect.width
816 && row >= inner_rect.y
817 && row < inner_rect.y + inner_rect.height
818 && *num_items > 0
819 {
820 let relative_row = (row - inner_rect.y) as usize;
822 let item_idx = scroll_offset + relative_row;
823
824 if item_idx < *num_items {
825 return Some(HoverTarget::PopupListItem(*popup_idx, item_idx));
826 }
827 }
828 }
829
830 if self.is_file_open_active() {
832 if let Some(hover) = self.compute_file_browser_hover(col, row) {
833 return Some(hover);
834 }
835 }
836
837 if self.menu_bar_visible {
840 if let Some(ref menu_layout) = self.cached_layout.menu_layout {
841 if let Some(menu_idx) = menu_layout.menu_at(col, row) {
842 return Some(HoverTarget::MenuBarItem(menu_idx));
843 }
844 }
845 }
846
847 if let Some(active_idx) = self.menu_state.active_menu {
849 if let Some(hover) = self.compute_menu_dropdown_hover(col, row, active_idx) {
850 return Some(hover);
851 }
852 }
853
854 if let Some(explorer_area) = self.cached_layout.file_explorer_area {
856 let close_button_x = explorer_area.x + explorer_area.width.saturating_sub(3);
858 if row == explorer_area.y
859 && col >= close_button_x
860 && col < explorer_area.x + explorer_area.width
861 {
862 return Some(HoverTarget::FileExplorerCloseButton);
863 }
864
865 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
872 && row < content_end_y
873 && col >= status_indicator_x
874 && col < explorer_area.x + explorer_area.width.saturating_sub(1)
875 {
876 if let Some(ref explorer) = self.file_explorer {
878 let relative_row = row.saturating_sub(content_start_y) as usize;
879 let scroll_offset = explorer.get_scroll_offset();
880 let item_index = relative_row + scroll_offset;
881 let display_nodes = explorer.get_display_nodes();
882
883 if item_index < display_nodes.len() {
884 let (node_id, _indent) = display_nodes[item_index];
885 if let Some(node) = explorer.tree().get_node(node_id) {
886 return Some(HoverTarget::FileExplorerStatusIndicator(
887 node.entry.path.clone(),
888 ));
889 }
890 }
891 }
892 }
893
894 let border_x = explorer_area.x + explorer_area.width.saturating_sub(1);
897 if col == border_x
898 && row >= explorer_area.y
899 && row < explorer_area.y + explorer_area.height
900 {
901 return Some(HoverTarget::FileExplorerBorder);
902 }
903 }
904
905 for (split_id, direction, sep_x, sep_y, sep_length) in &self.cached_layout.separator_areas {
907 let is_on_separator = match direction {
908 SplitDirection::Horizontal => {
909 row == *sep_y && col >= *sep_x && col < sep_x + sep_length
910 }
911 SplitDirection::Vertical => {
912 col == *sep_x && row >= *sep_y && row < sep_y + sep_length
913 }
914 };
915
916 if is_on_separator {
917 return Some(HoverTarget::SplitSeparator(*split_id, *direction));
918 }
919 }
920
921 for (split_id, btn_row, start_col, end_col) in &self.cached_layout.close_split_areas {
924 if row == *btn_row && col >= *start_col && col < *end_col {
925 return Some(HoverTarget::CloseSplitButton(*split_id));
926 }
927 }
928
929 for (split_id, btn_row, start_col, end_col) in &self.cached_layout.maximize_split_areas {
930 if row == *btn_row && col >= *start_col && col < *end_col {
931 return Some(HoverTarget::MaximizeSplitButton(*split_id));
932 }
933 }
934
935 for (split_id, tab_layout) in &self.cached_layout.tab_layouts {
936 match tab_layout.hit_test(col, row) {
937 Some(TabHit::CloseButton(buffer_id)) => {
938 return Some(HoverTarget::TabCloseButton(buffer_id, *split_id));
939 }
940 Some(TabHit::TabName(buffer_id)) => {
941 return Some(HoverTarget::TabName(buffer_id, *split_id));
942 }
943 Some(TabHit::ScrollLeft)
944 | Some(TabHit::ScrollRight)
945 | Some(TabHit::BarBackground)
946 | None => {}
947 }
948 }
949
950 for (split_id, _buffer_id, _content_rect, scrollbar_rect, thumb_start, thumb_end) in
952 &self.cached_layout.split_areas
953 {
954 if col >= scrollbar_rect.x
955 && col < scrollbar_rect.x + scrollbar_rect.width
956 && row >= scrollbar_rect.y
957 && row < scrollbar_rect.y + scrollbar_rect.height
958 {
959 let relative_row = row.saturating_sub(scrollbar_rect.y) as usize;
960 let is_on_thumb = relative_row >= *thumb_start && relative_row < *thumb_end;
961
962 if is_on_thumb {
963 return Some(HoverTarget::ScrollbarThumb(*split_id));
964 } else {
965 return Some(HoverTarget::ScrollbarTrack(*split_id));
966 }
967 }
968 }
969
970 if let Some((status_row, _status_x, _status_width)) = self.cached_layout.status_bar_area {
972 if row == status_row {
973 if let Some((le_row, le_start, le_end)) =
975 self.cached_layout.status_bar_line_ending_area
976 {
977 if row == le_row && col >= le_start && col < le_end {
978 return Some(HoverTarget::StatusBarLineEndingIndicator);
979 }
980 }
981
982 if let Some((enc_row, enc_start, enc_end)) =
984 self.cached_layout.status_bar_encoding_area
985 {
986 if row == enc_row && col >= enc_start && col < enc_end {
987 return Some(HoverTarget::StatusBarEncodingIndicator);
988 }
989 }
990
991 if let Some((lang_row, lang_start, lang_end)) =
993 self.cached_layout.status_bar_language_area
994 {
995 if row == lang_row && col >= lang_start && col < lang_end {
996 return Some(HoverTarget::StatusBarLanguageIndicator);
997 }
998 }
999
1000 if let Some((lsp_row, lsp_start, lsp_end)) = self.cached_layout.status_bar_lsp_area
1002 {
1003 if row == lsp_row && col >= lsp_start && col < lsp_end {
1004 return Some(HoverTarget::StatusBarLspIndicator);
1005 }
1006 }
1007
1008 if let Some((warn_row, warn_start, warn_end)) =
1010 self.cached_layout.status_bar_warning_area
1011 {
1012 if row == warn_row && col >= warn_start && col < warn_end {
1013 return Some(HoverTarget::StatusBarWarningBadge);
1014 }
1015 }
1016 }
1017 }
1018
1019 if let Some(ref layout) = self.cached_layout.search_options_layout {
1021 use crate::view::ui::status_bar::SearchOptionsHover;
1022 if let Some(hover) = layout.checkbox_at(col, row) {
1023 return Some(match hover {
1024 SearchOptionsHover::CaseSensitive => HoverTarget::SearchOptionCaseSensitive,
1025 SearchOptionsHover::WholeWord => HoverTarget::SearchOptionWholeWord,
1026 SearchOptionsHover::Regex => HoverTarget::SearchOptionRegex,
1027 SearchOptionsHover::ConfirmEach => HoverTarget::SearchOptionConfirmEach,
1028 SearchOptionsHover::None => return None,
1029 });
1030 }
1031 }
1032
1033 None
1035 }
1036
1037 pub(super) fn handle_mouse_double_click(&mut self, col: u16, row: u16) -> AnyhowResult<()> {
1040 tracing::debug!("handle_mouse_double_click at col={}, row={}", col, row);
1041
1042 if self.is_mouse_over_any_popup(col, row) {
1044 return Ok(());
1046 } else {
1047 self.dismiss_transient_popups();
1049 }
1050
1051 if self.handle_file_open_double_click(col, row) {
1053 return Ok(());
1054 }
1055
1056 if let Some(explorer_area) = self.cached_layout.file_explorer_area {
1058 if col >= explorer_area.x
1059 && col < explorer_area.x + explorer_area.width
1060 && row > explorer_area.y && row < explorer_area.y + explorer_area.height
1062 {
1063 self.file_explorer_open_file()?;
1065 return Ok(());
1066 }
1067 }
1068
1069 let split_areas = self.cached_layout.split_areas.clone();
1071 for (split_id, buffer_id, content_rect, _scrollbar_rect, _thumb_start, _thumb_end) in
1072 &split_areas
1073 {
1074 if col >= content_rect.x
1075 && col < content_rect.x + content_rect.width
1076 && row >= content_rect.y
1077 && row < content_rect.y + content_rect.height
1078 {
1079 if self.is_terminal_buffer(*buffer_id) {
1081 self.key_context = crate::input::keybindings::KeyContext::Terminal;
1082 return Ok(());
1084 }
1085
1086 self.key_context = crate::input::keybindings::KeyContext::Normal;
1087
1088 self.handle_editor_double_click(col, row, *split_id, *buffer_id, *content_rect)?;
1090 return Ok(());
1091 }
1092 }
1093
1094 Ok(())
1095 }
1096
1097 fn handle_editor_double_click(
1099 &mut self,
1100 col: u16,
1101 row: u16,
1102 split_id: LeafId,
1103 buffer_id: BufferId,
1104 content_rect: ratatui::layout::Rect,
1105 ) -> AnyhowResult<()> {
1106 use crate::model::event::Event;
1107
1108 self.focus_split(split_id, buffer_id);
1110
1111 let cached_mappings = self
1113 .cached_layout
1114 .view_line_mappings
1115 .get(&split_id)
1116 .cloned();
1117
1118 let leaf_id = split_id;
1120 let fallback = self
1121 .split_view_states
1122 .get(&leaf_id)
1123 .map(|vs| vs.viewport.top_byte)
1124 .unwrap_or(0);
1125
1126 let compose_width = self
1128 .split_view_states
1129 .get(&leaf_id)
1130 .and_then(|vs| vs.compose_width);
1131
1132 if let Some(state) = self.buffers.get_mut(&buffer_id) {
1134 let gutter_width = state.margins.left_total_width() as u16;
1135
1136 let Some(target_position) = Self::screen_to_buffer_position(
1137 col,
1138 row,
1139 content_rect,
1140 gutter_width,
1141 &cached_mappings,
1142 fallback,
1143 true, compose_width,
1145 ) else {
1146 return Ok(());
1147 };
1148
1149 let primary_cursor_id = self
1151 .split_view_states
1152 .get(&leaf_id)
1153 .map(|vs| vs.cursors.primary_id())
1154 .unwrap_or(CursorId(0));
1155 let event = Event::MoveCursor {
1156 cursor_id: primary_cursor_id,
1157 old_position: 0,
1158 new_position: target_position,
1159 old_anchor: None,
1160 new_anchor: None,
1161 old_sticky_column: 0,
1162 new_sticky_column: 0,
1163 };
1164
1165 if let Some(event_log) = self.event_logs.get_mut(&buffer_id) {
1166 event_log.append(event.clone());
1167 }
1168 if let Some(cursors) = self
1169 .split_view_states
1170 .get_mut(&leaf_id)
1171 .map(|vs| &mut vs.cursors)
1172 {
1173 state.apply(cursors, &event);
1174 }
1175 }
1176
1177 self.handle_action(Action::SelectWord)?;
1179
1180 if let Some(cursor) = self
1182 .split_view_states
1183 .get(&leaf_id)
1184 .map(|vs| vs.cursors.primary())
1185 {
1186 let sel_start = cursor.selection_start();
1189 let sel_end = cursor.selection_end();
1190 self.mouse_state.dragging_text_selection = true;
1191 self.mouse_state.drag_selection_split = Some(split_id);
1192 self.mouse_state.drag_selection_anchor = Some(sel_start);
1193 self.mouse_state.drag_selection_by_words = true;
1194 self.mouse_state.drag_selection_word_end = Some(sel_end);
1195 }
1196
1197 Ok(())
1198 }
1199 pub(super) fn handle_mouse_triple_click(&mut self, col: u16, row: u16) -> AnyhowResult<()> {
1202 tracing::debug!("handle_mouse_triple_click at col={}, row={}", col, row);
1203
1204 if self.is_mouse_over_any_popup(col, row) {
1206 return Ok(());
1207 } else {
1208 self.dismiss_transient_popups();
1209 }
1210
1211 let split_areas = self.cached_layout.split_areas.clone();
1213 for (split_id, buffer_id, content_rect, _scrollbar_rect, _thumb_start, _thumb_end) in
1214 &split_areas
1215 {
1216 if col >= content_rect.x
1217 && col < content_rect.x + content_rect.width
1218 && row >= content_rect.y
1219 && row < content_rect.y + content_rect.height
1220 {
1221 if self.is_terminal_buffer(*buffer_id) {
1222 return Ok(());
1223 }
1224
1225 self.key_context = crate::input::keybindings::KeyContext::Normal;
1226
1227 self.handle_editor_triple_click(col, row, *split_id, *buffer_id, *content_rect)?;
1230 return Ok(());
1231 }
1232 }
1233
1234 Ok(())
1235 }
1236
1237 fn handle_editor_triple_click(
1239 &mut self,
1240 col: u16,
1241 row: u16,
1242 split_id: LeafId,
1243 buffer_id: BufferId,
1244 content_rect: ratatui::layout::Rect,
1245 ) -> AnyhowResult<()> {
1246 use crate::model::event::Event;
1247
1248 self.focus_split(split_id, buffer_id);
1250
1251 let cached_mappings = self
1253 .cached_layout
1254 .view_line_mappings
1255 .get(&split_id)
1256 .cloned();
1257
1258 let leaf_id = split_id;
1259 let fallback = self
1260 .split_view_states
1261 .get(&leaf_id)
1262 .map(|vs| vs.viewport.top_byte)
1263 .unwrap_or(0);
1264
1265 let compose_width = self
1267 .split_view_states
1268 .get(&leaf_id)
1269 .and_then(|vs| vs.compose_width);
1270
1271 if let Some(state) = self.buffers.get_mut(&buffer_id) {
1273 let gutter_width = state.margins.left_total_width() as u16;
1274
1275 let Some(target_position) = Self::screen_to_buffer_position(
1276 col,
1277 row,
1278 content_rect,
1279 gutter_width,
1280 &cached_mappings,
1281 fallback,
1282 true,
1283 compose_width,
1284 ) else {
1285 return Ok(());
1286 };
1287
1288 let primary_cursor_id = self
1290 .split_view_states
1291 .get(&leaf_id)
1292 .map(|vs| vs.cursors.primary_id())
1293 .unwrap_or(CursorId(0));
1294 let event = Event::MoveCursor {
1295 cursor_id: primary_cursor_id,
1296 old_position: 0,
1297 new_position: target_position,
1298 old_anchor: None,
1299 new_anchor: None,
1300 old_sticky_column: 0,
1301 new_sticky_column: 0,
1302 };
1303
1304 if let Some(event_log) = self.event_logs.get_mut(&buffer_id) {
1305 event_log.append(event.clone());
1306 }
1307 if let Some(cursors) = self
1308 .split_view_states
1309 .get_mut(&leaf_id)
1310 .map(|vs| &mut vs.cursors)
1311 {
1312 state.apply(cursors, &event);
1313 }
1314 }
1315
1316 self.handle_action(Action::SelectLine)?;
1318
1319 Ok(())
1320 }
1321
1322 pub(super) fn handle_mouse_click(
1324 &mut self,
1325 col: u16,
1326 row: u16,
1327 modifiers: crossterm::event::KeyModifiers,
1328 ) -> AnyhowResult<()> {
1329 if self.tab_context_menu.is_some() {
1331 if let Some(result) = self.handle_tab_context_menu_click(col, row) {
1332 return result;
1333 }
1334 }
1335
1336 if !self.is_mouse_over_any_popup(col, row) {
1339 self.dismiss_transient_popups();
1340 }
1341
1342 if let Some((inner_rect, start_idx, _visible_count, total_count)) =
1344 &self.cached_layout.suggestions_area.clone()
1345 {
1346 if col >= inner_rect.x
1347 && col < inner_rect.x + inner_rect.width
1348 && row >= inner_rect.y
1349 && row < inner_rect.y + inner_rect.height
1350 {
1351 let relative_row = (row - inner_rect.y) as usize;
1352 let item_idx = start_idx + relative_row;
1353
1354 if item_idx < *total_count {
1355 if let Some(prompt) = &mut self.prompt {
1357 prompt.selected_suggestion = Some(item_idx);
1358 }
1359 return self.handle_action(Action::PromptConfirm);
1361 }
1362 }
1363 }
1364
1365 let scrollbar_scroll_info: Option<(usize, i32)> =
1368 self.cached_layout.popup_areas.iter().rev().find_map(
1369 |(
1370 popup_idx,
1371 _popup_rect,
1372 inner_rect,
1373 _scroll_offset,
1374 _num_items,
1375 scrollbar_rect,
1376 total_lines,
1377 )| {
1378 let sb_rect = scrollbar_rect.as_ref()?;
1379 if col >= sb_rect.x
1380 && col < sb_rect.x + sb_rect.width
1381 && row >= sb_rect.y
1382 && row < sb_rect.y + sb_rect.height
1383 {
1384 let relative_row = (row - sb_rect.y) as usize;
1385 let track_height = sb_rect.height as usize;
1386 let visible_lines = inner_rect.height as usize;
1387
1388 if track_height > 0 && *total_lines > visible_lines {
1389 let max_scroll = total_lines.saturating_sub(visible_lines);
1390 let target_scroll = if track_height > 1 {
1391 (relative_row * max_scroll) / (track_height.saturating_sub(1))
1392 } else {
1393 0
1394 };
1395 Some((*popup_idx, target_scroll as i32))
1396 } else {
1397 Some((*popup_idx, 0))
1398 }
1399 } else {
1400 None
1401 }
1402 },
1403 );
1404
1405 if let Some((popup_idx, target_scroll)) = scrollbar_scroll_info {
1406 self.mouse_state.dragging_popup_scrollbar = Some(popup_idx);
1408 self.mouse_state.drag_start_row = Some(row);
1409 let current_scroll = self
1411 .active_state()
1412 .popups
1413 .get(popup_idx)
1414 .map(|p| p.scroll_offset)
1415 .unwrap_or(0);
1416 self.mouse_state.drag_start_popup_scroll = Some(current_scroll);
1417 let state = self.active_state_mut();
1419 if let Some(popup) = state.popups.get_mut(popup_idx) {
1420 let delta = target_scroll - current_scroll as i32;
1421 popup.scroll_by(delta);
1422 }
1423 return Ok(());
1424 }
1425
1426 for (popup_idx, _popup_rect, inner_rect, scroll_offset, num_items, _, _) in
1428 self.cached_layout.popup_areas.iter().rev()
1429 {
1430 if col >= inner_rect.x
1431 && col < inner_rect.x + inner_rect.width
1432 && row >= inner_rect.y
1433 && row < inner_rect.y + inner_rect.height
1434 {
1435 let relative_col = (col - inner_rect.x) as usize;
1437 let relative_row = (row - inner_rect.y) as usize;
1438
1439 let link_url = {
1441 let state = self.active_state();
1442 state
1443 .popups
1444 .top()
1445 .and_then(|popup| popup.link_at_position(relative_col, relative_row))
1446 };
1447
1448 if let Some(url) = link_url {
1449 #[cfg(feature = "runtime")]
1451 if let Err(e) = open::that(&url) {
1452 self.set_status_message(format!("Failed to open URL: {}", e));
1453 } else {
1454 self.set_status_message(format!("Opening: {}", url));
1455 }
1456 return Ok(());
1457 }
1458
1459 if *num_items > 0 {
1461 let item_idx = scroll_offset + relative_row;
1462
1463 if item_idx < *num_items {
1464 let state = self.active_state_mut();
1466 if let Some(popup) = state.popups.top_mut() {
1467 if let crate::view::popup::PopupContent::List { items: _, selected } =
1468 &mut popup.content
1469 {
1470 *selected = item_idx;
1471 }
1472 }
1473 return self.handle_action(Action::PopupConfirm);
1475 }
1476 }
1477
1478 let is_text_popup = {
1480 let state = self.active_state();
1481 state.popups.top().is_some_and(|p| {
1482 matches!(
1483 p.content,
1484 crate::view::popup::PopupContent::Text(_)
1485 | crate::view::popup::PopupContent::Markdown(_)
1486 )
1487 })
1488 };
1489
1490 if is_text_popup {
1491 let line = scroll_offset + relative_row;
1492 let popup_idx_copy = *popup_idx; let state = self.active_state_mut();
1494 if let Some(popup) = state.popups.top_mut() {
1495 popup.start_selection(line, relative_col);
1496 }
1497 self.mouse_state.selecting_in_popup = Some(popup_idx_copy);
1499 return Ok(());
1500 }
1501 }
1502 }
1503
1504 if self.is_mouse_over_any_popup(col, row) {
1507 return Ok(());
1508 }
1509
1510 if self.is_file_open_active() && self.handle_file_open_click(col, row) {
1512 return Ok(());
1513 }
1514
1515 if self.menu_bar_visible {
1517 if let Some(ref menu_layout) = self.cached_layout.menu_layout {
1518 if let Some(menu_idx) = menu_layout.menu_at(col, row) {
1519 if self.menu_state.active_menu == Some(menu_idx) {
1521 self.close_menu_with_auto_hide();
1522 } else {
1523 self.on_editor_focus_lost();
1525 self.menu_state.open_menu(menu_idx);
1526 }
1527 return Ok(());
1528 } else if row == 0 {
1529 self.close_menu_with_auto_hide();
1531 return Ok(());
1532 }
1533 }
1534 }
1535
1536 if let Some(active_idx) = self.menu_state.active_menu {
1538 let all_menus: Vec<crate::config::Menu> = self
1539 .menus
1540 .menus
1541 .iter()
1542 .chain(self.menu_state.plugin_menus.iter())
1543 .cloned()
1544 .collect();
1545
1546 if let Some(menu) = all_menus.get(active_idx) {
1547 if let Some(click_result) = self.handle_menu_dropdown_click(col, row, menu)? {
1549 return click_result;
1550 }
1551 }
1552
1553 self.close_menu_with_auto_hide();
1555 return Ok(());
1556 }
1557
1558 if let Some(explorer_area) = self.cached_layout.file_explorer_area {
1562 let border_x = explorer_area.x + explorer_area.width.saturating_sub(1);
1563 if col == border_x
1564 && row >= explorer_area.y
1565 && row < explorer_area.y + explorer_area.height
1566 {
1567 self.mouse_state.dragging_file_explorer = true;
1568 self.mouse_state.drag_start_position = Some((col, row));
1569 self.mouse_state.drag_start_explorer_width = Some(self.file_explorer_width_percent);
1570 return Ok(());
1571 }
1572 }
1573
1574 if let Some(explorer_area) = self.cached_layout.file_explorer_area {
1576 if col >= explorer_area.x
1577 && col < explorer_area.x + explorer_area.width
1578 && row >= explorer_area.y
1579 && row < explorer_area.y + explorer_area.height
1580 {
1581 self.handle_file_explorer_click(col, row, explorer_area)?;
1582 return Ok(());
1583 }
1584 }
1585
1586 let scrollbar_hit = self.cached_layout.split_areas.iter().find_map(
1588 |(split_id, buffer_id, _content_rect, scrollbar_rect, thumb_start, thumb_end)| {
1589 if col >= scrollbar_rect.x
1590 && col < scrollbar_rect.x + scrollbar_rect.width
1591 && row >= scrollbar_rect.y
1592 && row < scrollbar_rect.y + scrollbar_rect.height
1593 {
1594 let relative_row = row.saturating_sub(scrollbar_rect.y) as usize;
1595 let is_on_thumb = relative_row >= *thumb_start && relative_row < *thumb_end;
1596 Some((*split_id, *buffer_id, *scrollbar_rect, is_on_thumb))
1597 } else {
1598 None
1599 }
1600 },
1601 );
1602
1603 if let Some((split_id, buffer_id, scrollbar_rect, is_on_thumb)) = scrollbar_hit {
1604 self.focus_split(split_id, buffer_id);
1605
1606 if is_on_thumb {
1607 self.mouse_state.dragging_scrollbar = Some(split_id);
1609 self.mouse_state.drag_start_row = Some(row);
1610 if self.is_composite_buffer(buffer_id) {
1612 if let Some(view_state) = self.composite_view_states.get(&(split_id, buffer_id))
1614 {
1615 self.mouse_state.drag_start_composite_scroll_row =
1616 Some(view_state.scroll_row);
1617 }
1618 } else if let Some(view_state) = self.split_view_states.get(&split_id) {
1619 self.mouse_state.drag_start_top_byte = Some(view_state.viewport.top_byte);
1620 self.mouse_state.drag_start_view_line_offset =
1621 Some(view_state.viewport.top_view_line_offset);
1622 }
1623 } else {
1624 self.mouse_state.dragging_scrollbar = Some(split_id);
1626 self.handle_scrollbar_jump(col, row, split_id, buffer_id, scrollbar_rect)?;
1627 }
1628 return Ok(());
1629 }
1630
1631 let hscrollbar_hit = self
1633 .cached_layout
1634 .horizontal_scrollbar_areas
1635 .iter()
1636 .find_map(
1637 |(
1638 split_id,
1639 buffer_id,
1640 hscrollbar_rect,
1641 max_content_width,
1642 thumb_start,
1643 thumb_end,
1644 )| {
1645 if col >= hscrollbar_rect.x
1646 && col < hscrollbar_rect.x + hscrollbar_rect.width
1647 && row >= hscrollbar_rect.y
1648 && row < hscrollbar_rect.y + hscrollbar_rect.height
1649 {
1650 let relative_col = col.saturating_sub(hscrollbar_rect.x) as usize;
1651 let is_on_thumb = relative_col >= *thumb_start && relative_col < *thumb_end;
1652 Some((
1653 *split_id,
1654 *buffer_id,
1655 *hscrollbar_rect,
1656 *max_content_width,
1657 is_on_thumb,
1658 ))
1659 } else {
1660 None
1661 }
1662 },
1663 );
1664
1665 if let Some((split_id, buffer_id, hscrollbar_rect, max_content_width, is_on_thumb)) =
1666 hscrollbar_hit
1667 {
1668 self.focus_split(split_id, buffer_id);
1669 self.mouse_state.dragging_horizontal_scrollbar = Some(split_id);
1670
1671 if is_on_thumb {
1672 self.mouse_state.drag_start_hcol = Some(col);
1674 if let Some(view_state) = self.split_view_states.get(&split_id) {
1675 self.mouse_state.drag_start_left_column = Some(view_state.viewport.left_column);
1676 }
1677 } else {
1678 self.mouse_state.drag_start_hcol = None;
1680 self.mouse_state.drag_start_left_column = None;
1681
1682 let relative_col = col.saturating_sub(hscrollbar_rect.x) as f64;
1683 let track_width = hscrollbar_rect.width as f64;
1684 let ratio = if track_width > 1.0 {
1685 (relative_col / (track_width - 1.0)).clamp(0.0, 1.0)
1686 } else {
1687 0.0
1688 };
1689
1690 if let Some(view_state) = self.split_view_states.get_mut(&split_id) {
1691 let visible_width = view_state.viewport.width as usize;
1692 let max_scroll = max_content_width.saturating_sub(visible_width);
1693 let target_col = (ratio * max_scroll as f64).round() as usize;
1694 view_state.viewport.left_column = target_col.min(max_scroll);
1695 view_state.viewport.set_skip_ensure_visible();
1696 }
1697 }
1698
1699 return Ok(());
1700 }
1701
1702 if let Some((status_row, _status_x, _status_width)) = self.cached_layout.status_bar_area {
1704 if row == status_row {
1705 if let Some((le_row, le_start, le_end)) =
1707 self.cached_layout.status_bar_line_ending_area
1708 {
1709 if row == le_row && col >= le_start && col < le_end {
1710 return self.handle_action(Action::SetLineEnding);
1711 }
1712 }
1713
1714 if let Some((enc_row, enc_start, enc_end)) =
1716 self.cached_layout.status_bar_encoding_area
1717 {
1718 if row == enc_row && col >= enc_start && col < enc_end {
1719 return self.handle_action(Action::SetEncoding);
1720 }
1721 }
1722
1723 if let Some((lang_row, lang_start, lang_end)) =
1725 self.cached_layout.status_bar_language_area
1726 {
1727 if row == lang_row && col >= lang_start && col < lang_end {
1728 return self.handle_action(Action::SetLanguage);
1729 }
1730 }
1731
1732 if let Some((lsp_row, lsp_start, lsp_end)) = self.cached_layout.status_bar_lsp_area
1734 {
1735 if row == lsp_row && col >= lsp_start && col < lsp_end {
1736 return self.handle_action(Action::ShowLspStatus);
1737 }
1738 }
1739
1740 if let Some((warn_row, warn_start, warn_end)) =
1742 self.cached_layout.status_bar_warning_area
1743 {
1744 if row == warn_row && col >= warn_start && col < warn_end {
1745 return self.handle_action(Action::ShowWarnings);
1746 }
1747 }
1748
1749 if let Some((msg_row, msg_start, msg_end)) =
1751 self.cached_layout.status_bar_message_area
1752 {
1753 if row == msg_row && col >= msg_start && col < msg_end {
1754 return self.handle_action(Action::ShowStatusLog);
1755 }
1756 }
1757 }
1758 }
1759
1760 if let Some(ref layout) = self.cached_layout.search_options_layout.clone() {
1762 use crate::view::ui::status_bar::SearchOptionsHover;
1763 if let Some(hover) = layout.checkbox_at(col, row) {
1764 match hover {
1765 SearchOptionsHover::CaseSensitive => {
1766 return self.handle_action(Action::ToggleSearchCaseSensitive);
1767 }
1768 SearchOptionsHover::WholeWord => {
1769 return self.handle_action(Action::ToggleSearchWholeWord);
1770 }
1771 SearchOptionsHover::Regex => {
1772 return self.handle_action(Action::ToggleSearchRegex);
1773 }
1774 SearchOptionsHover::ConfirmEach => {
1775 return self.handle_action(Action::ToggleSearchConfirmEach);
1776 }
1777 SearchOptionsHover::None => {}
1778 }
1779 }
1780 }
1781
1782 for (split_id, direction, sep_x, sep_y, sep_length) in &self.cached_layout.separator_areas {
1784 let is_on_separator = match direction {
1785 SplitDirection::Horizontal => {
1786 row == *sep_y && col >= *sep_x && col < sep_x + sep_length
1788 }
1789 SplitDirection::Vertical => {
1790 col == *sep_x && row >= *sep_y && row < sep_y + sep_length
1792 }
1793 };
1794
1795 if is_on_separator {
1796 self.mouse_state.dragging_separator = Some((*split_id, *direction));
1798 self.mouse_state.drag_start_position = Some((col, row));
1799 if let Some(ratio) = self.split_manager.get_ratio((*split_id).into()) {
1801 self.mouse_state.drag_start_ratio = Some(ratio);
1802 }
1803 return Ok(());
1804 }
1805 }
1806
1807 let close_split_click = self
1809 .cached_layout
1810 .close_split_areas
1811 .iter()
1812 .find(|(_, btn_row, start_col, end_col)| {
1813 row == *btn_row && col >= *start_col && col < *end_col
1814 })
1815 .map(|(split_id, _, _, _)| *split_id);
1816
1817 if let Some(split_id) = close_split_click {
1818 if let Err(e) = self.split_manager.close_split(split_id) {
1819 self.set_status_message(
1820 t!("error.cannot_close_split", error = e.to_string()).to_string(),
1821 );
1822 } else {
1823 let new_active_split = self.split_manager.active_split();
1825 if let Some(buffer_id) = self.split_manager.buffer_for_split(new_active_split) {
1826 self.set_active_buffer(buffer_id);
1827 }
1828 self.set_status_message(t!("split.closed").to_string());
1829 }
1830 return Ok(());
1831 }
1832
1833 let maximize_split_click = self
1835 .cached_layout
1836 .maximize_split_areas
1837 .iter()
1838 .find(|(_, btn_row, start_col, end_col)| {
1839 row == *btn_row && col >= *start_col && col < *end_col
1840 })
1841 .map(|(split_id, _, _, _)| *split_id);
1842
1843 if let Some(_split_id) = maximize_split_click {
1844 match self.split_manager.toggle_maximize() {
1846 Ok(maximized) => {
1847 if maximized {
1848 self.set_status_message(t!("split.maximized").to_string());
1849 } else {
1850 self.set_status_message(t!("split.restored").to_string());
1851 }
1852 }
1853 Err(e) => self.set_status_message(e),
1854 }
1855 return Ok(());
1856 }
1857
1858 for (split_id, tab_layout) in &self.cached_layout.tab_layouts {
1861 tracing::debug!(
1862 "Tab layout for split {:?}: bar_area={:?}, left_scroll={:?}, right_scroll={:?}",
1863 split_id,
1864 tab_layout.bar_area,
1865 tab_layout.left_scroll_area,
1866 tab_layout.right_scroll_area
1867 );
1868 }
1869
1870 let tab_hit = self
1871 .cached_layout
1872 .tab_layouts
1873 .iter()
1874 .find_map(|(split_id, tab_layout)| {
1875 let hit = tab_layout.hit_test(col, row);
1876 tracing::debug!(
1877 "Tab hit_test at ({}, {}) for split {:?} returned {:?}",
1878 col,
1879 row,
1880 split_id,
1881 hit
1882 );
1883 hit.map(|h| (*split_id, h))
1884 });
1885
1886 if let Some((split_id, hit)) = tab_hit {
1887 match hit {
1888 TabHit::CloseButton(buffer_id) => {
1889 self.focus_split(split_id, buffer_id);
1890 self.close_tab_in_split(buffer_id, split_id);
1891 return Ok(());
1892 }
1893 TabHit::TabName(buffer_id) => {
1894 self.focus_split(split_id, buffer_id);
1895 self.mouse_state.dragging_tab = Some(super::types::TabDragState::new(
1897 buffer_id,
1898 split_id,
1899 (col, row),
1900 ));
1901 return Ok(());
1902 }
1903 TabHit::ScrollLeft => {
1904 self.set_status_message("ScrollLeft clicked!".to_string());
1906 if let Some(view_state) = self.split_view_states.get_mut(&split_id) {
1907 view_state.tab_scroll_offset =
1908 view_state.tab_scroll_offset.saturating_sub(10);
1909 }
1910 return Ok(());
1911 }
1912 TabHit::ScrollRight => {
1913 self.set_status_message("ScrollRight clicked!".to_string());
1915 if let Some(view_state) = self.split_view_states.get_mut(&split_id) {
1916 view_state.tab_scroll_offset =
1917 view_state.tab_scroll_offset.saturating_add(10);
1918 }
1919 return Ok(());
1920 }
1921 TabHit::BarBackground => {}
1922 }
1923 }
1924
1925 tracing::debug!(
1927 "handle_mouse_click: checking {} split_areas for click at ({}, {})",
1928 self.cached_layout.split_areas.len(),
1929 col,
1930 row
1931 );
1932 for (split_id, buffer_id, content_rect, _scrollbar_rect, _thumb_start, _thumb_end) in
1933 &self.cached_layout.split_areas
1934 {
1935 tracing::debug!(
1936 " split_id={:?}, content_rect=({}, {}, {}x{})",
1937 split_id,
1938 content_rect.x,
1939 content_rect.y,
1940 content_rect.width,
1941 content_rect.height
1942 );
1943 if col >= content_rect.x
1944 && col < content_rect.x + content_rect.width
1945 && row >= content_rect.y
1946 && row < content_rect.y + content_rect.height
1947 {
1948 tracing::debug!(" -> HIT! calling handle_editor_click");
1950 self.handle_editor_click(
1951 col,
1952 row,
1953 *split_id,
1954 *buffer_id,
1955 *content_rect,
1956 modifiers,
1957 )?;
1958 return Ok(());
1959 }
1960 }
1961 tracing::debug!(" -> No split area hit");
1962
1963 Ok(())
1964 }
1965
1966 pub(super) fn handle_mouse_drag(&mut self, col: u16, row: u16) -> AnyhowResult<()> {
1968 if let Some(dragging_split_id) = self.mouse_state.dragging_scrollbar {
1970 for (split_id, buffer_id, _content_rect, scrollbar_rect, _thumb_start, _thumb_end) in
1972 &self.cached_layout.split_areas
1973 {
1974 if *split_id == dragging_split_id {
1975 if self.mouse_state.drag_start_row.is_some() {
1977 self.handle_scrollbar_drag_relative(
1979 row,
1980 *split_id,
1981 *buffer_id,
1982 *scrollbar_rect,
1983 )?;
1984 } else {
1985 self.handle_scrollbar_jump(
1987 col,
1988 row,
1989 *split_id,
1990 *buffer_id,
1991 *scrollbar_rect,
1992 )?;
1993 }
1994 return Ok(());
1995 }
1996 }
1997 }
1998
1999 if let Some(dragging_split_id) = self.mouse_state.dragging_horizontal_scrollbar {
2001 for (
2002 split_id,
2003 _buffer_id,
2004 hscrollbar_rect,
2005 max_content_width,
2006 thumb_start,
2007 thumb_end,
2008 ) in &self.cached_layout.horizontal_scrollbar_areas
2009 {
2010 if *split_id == dragging_split_id {
2011 let track_width = hscrollbar_rect.width as f64;
2012 if track_width <= 1.0 {
2013 break;
2014 }
2015
2016 if let (Some(drag_start_hcol), Some(drag_start_left_column)) = (
2017 self.mouse_state.drag_start_hcol,
2018 self.mouse_state.drag_start_left_column,
2019 ) {
2020 let col_offset = (col as i32) - (drag_start_hcol as i32);
2023 if let Some(view_state) = self.split_view_states.get_mut(&dragging_split_id)
2024 {
2025 let visible_width = view_state.viewport.width as usize;
2026 let max_scroll = max_content_width.saturating_sub(visible_width);
2027 if max_scroll > 0 {
2028 let thumb_size = thumb_end.saturating_sub(*thumb_start).max(1);
2029 let track_travel = (track_width - thumb_size as f64).max(1.0);
2030 let scroll_per_pixel = max_scroll as f64 / track_travel;
2031 let scroll_offset =
2032 (col_offset as f64 * scroll_per_pixel).round() as i64;
2033 let new_left =
2034 (drag_start_left_column as i64 + scroll_offset).max(0) as usize;
2035 view_state.viewport.left_column = new_left.min(max_scroll);
2036 view_state.viewport.set_skip_ensure_visible();
2037 }
2038 }
2039 } else {
2040 let relative_col = col.saturating_sub(hscrollbar_rect.x) as f64;
2042 let ratio = (relative_col / (track_width - 1.0)).clamp(0.0, 1.0);
2043
2044 if let Some(view_state) = self.split_view_states.get_mut(&dragging_split_id)
2045 {
2046 let visible_width = view_state.viewport.width as usize;
2047 let max_scroll = max_content_width.saturating_sub(visible_width);
2048 let target_col = (ratio * max_scroll as f64).round() as usize;
2049 view_state.viewport.left_column = target_col.min(max_scroll);
2050 view_state.viewport.set_skip_ensure_visible();
2051 }
2052 }
2053
2054 return Ok(());
2055 }
2056 }
2057 }
2058
2059 if let Some(popup_idx) = self.mouse_state.selecting_in_popup {
2061 if let Some((_, _, inner_rect, scroll_offset, _, _, _)) = self
2063 .cached_layout
2064 .popup_areas
2065 .iter()
2066 .find(|(idx, _, _, _, _, _, _)| *idx == popup_idx)
2067 {
2068 if col >= inner_rect.x
2070 && col < inner_rect.x + inner_rect.width
2071 && row >= inner_rect.y
2072 && row < inner_rect.y + inner_rect.height
2073 {
2074 let relative_col = (col - inner_rect.x) as usize;
2075 let relative_row = (row - inner_rect.y) as usize;
2076 let line = scroll_offset + relative_row;
2077
2078 let state = self.active_state_mut();
2079 if let Some(popup) = state.popups.get_mut(popup_idx) {
2080 popup.extend_selection(line, relative_col);
2081 }
2082 }
2083 }
2084 return Ok(());
2085 }
2086
2087 if let Some(popup_idx) = self.mouse_state.dragging_popup_scrollbar {
2089 if let Some((_, _, inner_rect, _, _, Some(sb_rect), total_lines)) = self
2091 .cached_layout
2092 .popup_areas
2093 .iter()
2094 .find(|(idx, _, _, _, _, _, _)| *idx == popup_idx)
2095 {
2096 let track_height = sb_rect.height as usize;
2097 let visible_lines = inner_rect.height as usize;
2098
2099 if track_height > 0 && *total_lines > visible_lines {
2100 let relative_row = row.saturating_sub(sb_rect.y) as usize;
2101 let max_scroll = total_lines.saturating_sub(visible_lines);
2102 let target_scroll = if track_height > 1 {
2103 (relative_row * max_scroll) / (track_height.saturating_sub(1))
2104 } else {
2105 0
2106 };
2107
2108 let state = self.active_state_mut();
2109 if let Some(popup) = state.popups.get_mut(popup_idx) {
2110 let current_scroll = popup.scroll_offset as i32;
2111 let delta = target_scroll as i32 - current_scroll;
2112 popup.scroll_by(delta);
2113 }
2114 }
2115 }
2116 return Ok(());
2117 }
2118
2119 if let Some((split_id, direction)) = self.mouse_state.dragging_separator {
2121 self.handle_separator_drag(col, row, split_id, direction)?;
2122 return Ok(());
2123 }
2124
2125 if self.mouse_state.dragging_file_explorer {
2127 self.handle_file_explorer_border_drag(col)?;
2128 return Ok(());
2129 }
2130
2131 if self.mouse_state.dragging_text_selection {
2133 self.handle_text_selection_drag(col, row)?;
2134 return Ok(());
2135 }
2136
2137 if self.mouse_state.dragging_tab.is_some() {
2139 self.handle_tab_drag(col, row)?;
2140 return Ok(());
2141 }
2142
2143 Ok(())
2144 }
2145
2146 fn handle_text_selection_drag(&mut self, col: u16, row: u16) -> AnyhowResult<()> {
2148 use crate::model::event::Event;
2149 use crate::primitives::word_navigation::{find_word_end, find_word_start};
2150
2151 let Some(split_id) = self.mouse_state.drag_selection_split else {
2152 return Ok(());
2153 };
2154 let Some(anchor_position) = self.mouse_state.drag_selection_anchor else {
2155 return Ok(());
2156 };
2157
2158 let buffer_id = self
2160 .cached_layout
2161 .split_areas
2162 .iter()
2163 .find(|(sid, _, _, _, _, _)| *sid == split_id)
2164 .map(|(_, bid, _, _, _, _)| *bid);
2165
2166 let Some(buffer_id) = buffer_id else {
2167 return Ok(());
2168 };
2169
2170 let content_rect = self
2172 .cached_layout
2173 .split_areas
2174 .iter()
2175 .find(|(sid, _, _, _, _, _)| *sid == split_id)
2176 .map(|(_, _, rect, _, _, _)| *rect);
2177
2178 let Some(content_rect) = content_rect else {
2179 return Ok(());
2180 };
2181
2182 let cached_mappings = self
2184 .cached_layout
2185 .view_line_mappings
2186 .get(&split_id)
2187 .cloned();
2188
2189 let leaf_id = split_id;
2190
2191 let fallback = self
2193 .split_view_states
2194 .get(&leaf_id)
2195 .map(|vs| vs.viewport.top_byte)
2196 .unwrap_or(0);
2197
2198 let compose_width = self
2200 .split_view_states
2201 .get(&leaf_id)
2202 .and_then(|vs| vs.compose_width);
2203
2204 if let Some(state) = self.buffers.get_mut(&buffer_id) {
2206 let gutter_width = state.margins.left_total_width() as u16;
2207
2208 let Some(target_position) = Self::screen_to_buffer_position(
2209 col,
2210 row,
2211 content_rect,
2212 gutter_width,
2213 &cached_mappings,
2214 fallback,
2215 true, compose_width,
2217 ) else {
2218 return Ok(());
2219 };
2220
2221 let (new_position, anchor_position) = if self.mouse_state.drag_selection_by_words {
2226 if target_position >= anchor_position {
2227 (
2228 find_word_end(&state.buffer, target_position),
2229 anchor_position,
2230 )
2231 } else {
2232 let word_end = self
2233 .mouse_state
2234 .drag_selection_word_end
2235 .unwrap_or(anchor_position);
2236 (find_word_start(&state.buffer, target_position), word_end)
2237 }
2238 } else {
2239 (target_position, anchor_position)
2240 };
2241
2242 let (primary_cursor_id, old_position, old_anchor, old_sticky_column) = self
2243 .split_view_states
2244 .get(&leaf_id)
2245 .map(|vs| {
2246 let cursor = vs.cursors.primary();
2247 (
2248 vs.cursors.primary_id(),
2249 cursor.position,
2250 cursor.anchor,
2251 cursor.sticky_column,
2252 )
2253 })
2254 .unwrap_or((CursorId(0), 0, None, 0));
2255
2256 let new_sticky_column = state
2257 .buffer
2258 .offset_to_position(new_position)
2259 .map(|pos| pos.column)
2260 .unwrap_or(old_sticky_column);
2261 let event = Event::MoveCursor {
2262 cursor_id: primary_cursor_id,
2263 old_position,
2264 new_position,
2265 old_anchor,
2266 new_anchor: Some(anchor_position), old_sticky_column,
2268 new_sticky_column,
2269 };
2270
2271 if let Some(event_log) = self.event_logs.get_mut(&buffer_id) {
2272 event_log.append(event.clone());
2273 }
2274 if let Some(cursors) = self
2275 .split_view_states
2276 .get_mut(&leaf_id)
2277 .map(|vs| &mut vs.cursors)
2278 {
2279 state.apply(cursors, &event);
2280 }
2281 }
2282
2283 Ok(())
2284 }
2285
2286 pub(super) fn handle_file_explorer_border_drag(&mut self, col: u16) -> AnyhowResult<()> {
2288 let Some((start_col, _start_row)) = self.mouse_state.drag_start_position else {
2289 return Ok(());
2290 };
2291 let Some(start_width) = self.mouse_state.drag_start_explorer_width else {
2292 return Ok(());
2293 };
2294
2295 let delta = col as i32 - start_col as i32;
2297 let total_width = self.terminal_width as i32;
2298
2299 if total_width > 0 {
2300 let percent_delta = delta as f32 / total_width as f32;
2302 let new_width = (start_width + percent_delta).clamp(0.1, 0.5);
2304 self.file_explorer_width_percent = new_width;
2305 }
2306
2307 Ok(())
2308 }
2309
2310 pub(super) fn handle_separator_drag(
2312 &mut self,
2313 col: u16,
2314 row: u16,
2315 split_id: ContainerId,
2316 direction: SplitDirection,
2317 ) -> AnyhowResult<()> {
2318 let Some((start_col, start_row)) = self.mouse_state.drag_start_position else {
2319 return Ok(());
2320 };
2321 let Some(start_ratio) = self.mouse_state.drag_start_ratio else {
2322 return Ok(());
2323 };
2324 let Some(editor_area) = self.cached_layout.editor_content_area else {
2325 return Ok(());
2326 };
2327
2328 let (delta, total_size) = match direction {
2330 SplitDirection::Horizontal => {
2331 let delta = row as i32 - start_row as i32;
2333 let total = editor_area.height as i32;
2334 (delta, total)
2335 }
2336 SplitDirection::Vertical => {
2337 let delta = col as i32 - start_col as i32;
2339 let total = editor_area.width as i32;
2340 (delta, total)
2341 }
2342 };
2343
2344 if total_size > 0 {
2347 let ratio_delta = delta as f32 / total_size as f32;
2348 let new_ratio = (start_ratio + ratio_delta).clamp(0.1, 0.9);
2349
2350 self.split_manager.set_ratio(split_id, new_ratio);
2352 }
2353
2354 Ok(())
2355 }
2356
2357 pub(super) fn handle_right_click(&mut self, col: u16, row: u16) -> AnyhowResult<()> {
2359 if let Some(ref menu) = self.tab_context_menu {
2361 let menu_x = menu.position.0;
2362 let menu_y = menu.position.1;
2363 let menu_width = 22u16; let menu_height = super::types::TabContextMenuItem::all().len() as u16 + 2; if col >= menu_x
2368 && col < menu_x + menu_width
2369 && row >= menu_y
2370 && row < menu_y + menu_height
2371 {
2372 return Ok(());
2374 }
2375 }
2376
2377 let tab_hit =
2379 self.cached_layout.tab_layouts.iter().find_map(
2380 |(split_id, tab_layout)| match tab_layout.hit_test(col, row) {
2381 Some(TabHit::TabName(buffer_id) | TabHit::CloseButton(buffer_id)) => {
2382 Some((*split_id, buffer_id))
2383 }
2384 _ => None,
2385 },
2386 );
2387
2388 if let Some((split_id, buffer_id)) = tab_hit {
2389 self.tab_context_menu = Some(TabContextMenu::new(buffer_id, split_id, col, row + 1));
2391 } else {
2392 self.tab_context_menu = None;
2394 }
2395
2396 Ok(())
2397 }
2398
2399 pub(super) fn handle_tab_context_menu_click(
2401 &mut self,
2402 col: u16,
2403 row: u16,
2404 ) -> Option<AnyhowResult<()>> {
2405 let menu = self.tab_context_menu.as_ref()?;
2406 let menu_x = menu.position.0;
2407 let menu_y = menu.position.1;
2408 let menu_width = 22u16;
2409 let items = super::types::TabContextMenuItem::all();
2410 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
2414 {
2415 self.tab_context_menu = None;
2417 return Some(Ok(()));
2418 }
2419
2420 if row == menu_y || row == menu_y + menu_height - 1 {
2422 return Some(Ok(()));
2423 }
2424
2425 let item_idx = (row - menu_y - 1) as usize;
2427 if item_idx >= items.len() {
2428 return Some(Ok(()));
2429 }
2430
2431 let buffer_id = menu.buffer_id;
2433 let split_id = menu.split_id;
2434 let item = items[item_idx];
2435
2436 self.tab_context_menu = None;
2438
2439 Some(self.execute_tab_context_menu_action(item, buffer_id, split_id))
2441 }
2442
2443 fn execute_tab_context_menu_action(
2445 &mut self,
2446 item: super::types::TabContextMenuItem,
2447 buffer_id: BufferId,
2448 leaf_id: LeafId,
2449 ) -> AnyhowResult<()> {
2450 use super::types::TabContextMenuItem;
2451 match item {
2452 TabContextMenuItem::Close => {
2453 self.close_tab_in_split(buffer_id, leaf_id);
2454 }
2455 TabContextMenuItem::CloseOthers => {
2456 self.close_other_tabs_in_split(buffer_id, leaf_id);
2457 }
2458 TabContextMenuItem::CloseToRight => {
2459 self.close_tabs_to_right_in_split(buffer_id, leaf_id);
2460 }
2461 TabContextMenuItem::CloseToLeft => {
2462 self.close_tabs_to_left_in_split(buffer_id, leaf_id);
2463 }
2464 TabContextMenuItem::CloseAll => {
2465 self.close_all_tabs_in_split(leaf_id);
2466 }
2467 }
2468
2469 Ok(())
2470 }
2471
2472 fn show_file_explorer_status_tooltip(&mut self, path: std::path::PathBuf, col: u16, row: u16) {
2474 use crate::view::popup::{Popup, PopupPosition};
2475 use ratatui::style::Style;
2476
2477 let is_directory = path.is_dir();
2478
2479 let decoration = self
2481 .file_explorer_decoration_cache
2482 .direct_for_path(&path)
2483 .cloned();
2484
2485 let bubbled_decoration = if is_directory && decoration.is_none() {
2487 self.file_explorer_decoration_cache
2488 .bubbled_for_path(&path)
2489 .cloned()
2490 } else {
2491 None
2492 };
2493
2494 let has_unsaved_changes = if is_directory {
2496 self.buffers.iter().any(|(buffer_id, state)| {
2498 if state.buffer.is_modified() {
2499 if let Some(metadata) = self.buffer_metadata.get(buffer_id) {
2500 if let Some(file_path) = metadata.file_path() {
2501 return file_path.starts_with(&path);
2502 }
2503 }
2504 }
2505 false
2506 })
2507 } else {
2508 self.buffers.iter().any(|(buffer_id, state)| {
2509 if state.buffer.is_modified() {
2510 if let Some(metadata) = self.buffer_metadata.get(buffer_id) {
2511 return metadata.file_path() == Some(&path);
2512 }
2513 }
2514 false
2515 })
2516 };
2517
2518 let mut lines: Vec<String> = Vec::new();
2520
2521 if let Some(decoration) = &decoration {
2522 let symbol = &decoration.symbol;
2523 let explanation = match symbol.as_str() {
2524 "U" => "Untracked - File is not tracked by git",
2525 "M" => "Modified - File has unstaged changes",
2526 "A" => "Added - File is staged for commit",
2527 "D" => "Deleted - File is staged for deletion",
2528 "R" => "Renamed - File has been renamed",
2529 "C" => "Copied - File has been copied",
2530 "!" => "Conflicted - File has merge conflicts",
2531 "●" => "Has changes - Contains modified files",
2532 _ => "Unknown status",
2533 };
2534 lines.push(format!("{} - {}", symbol, explanation));
2535 } else if bubbled_decoration.is_some() {
2536 lines.push("● - Contains modified files".to_string());
2537 } else if has_unsaved_changes {
2538 if is_directory {
2539 lines.push("● - Contains unsaved changes".to_string());
2540 } else {
2541 lines.push("● - Unsaved changes in editor".to_string());
2542 }
2543 } else {
2544 return; }
2546
2547 if is_directory {
2549 if let Some(modified_files) = self.get_modified_files_in_directory(&path) {
2551 lines.push(String::new()); lines.push("Modified files:".to_string());
2553 let resolved_path = path.canonicalize().unwrap_or_else(|_| path.clone());
2555 const MAX_FILES: usize = 8;
2556 for (i, file) in modified_files.iter().take(MAX_FILES).enumerate() {
2557 let display_name = file
2559 .strip_prefix(&resolved_path)
2560 .unwrap_or(file)
2561 .to_string_lossy()
2562 .to_string();
2563 lines.push(format!(" {}", display_name));
2564 if i == MAX_FILES - 1 && modified_files.len() > MAX_FILES {
2565 lines.push(format!(
2566 " ... and {} more",
2567 modified_files.len() - MAX_FILES
2568 ));
2569 break;
2570 }
2571 }
2572 }
2573 } else {
2574 if let Some(stats) = self.get_git_diff_stats(&path) {
2576 lines.push(String::new()); lines.push(stats);
2578 }
2579 }
2580
2581 if lines.is_empty() {
2582 return;
2583 }
2584
2585 let mut popup = Popup::text(lines, &self.theme);
2587 popup.title = Some("Git Status".to_string());
2588 popup.transient = true;
2589 popup.position = PopupPosition::Fixed { x: col, y: row + 1 };
2590 popup.width = 50;
2591 popup.max_height = 15;
2592 popup.border_style = Style::default().fg(self.theme.popup_border_fg);
2593 popup.background_style = Style::default().bg(self.theme.popup_bg);
2594
2595 if let Some(state) = self.buffers.get_mut(&self.active_buffer()) {
2597 state.popups.show(popup);
2598 }
2599 }
2600
2601 fn dismiss_file_explorer_status_tooltip(&mut self) {
2603 if let Some(state) = self.buffers.get_mut(&self.active_buffer()) {
2605 state.popups.dismiss_transient();
2606 }
2607 }
2608
2609 fn get_git_diff_stats(&self, path: &std::path::Path) -> Option<String> {
2611 use std::process::Command;
2612
2613 let output = Command::new("git")
2615 .args(["diff", "--numstat", "--"])
2616 .arg(path)
2617 .current_dir(&self.working_dir)
2618 .output()
2619 .ok()?;
2620
2621 if !output.status.success() {
2622 return None;
2623 }
2624
2625 let stdout = String::from_utf8_lossy(&output.stdout);
2626 let line = stdout.lines().next()?;
2627 let parts: Vec<&str> = line.split('\t').collect();
2628
2629 if parts.len() >= 2 {
2630 let insertions = parts[0];
2631 let deletions = parts[1];
2632
2633 if insertions == "-" && deletions == "-" {
2635 return Some("Binary file changed".to_string());
2636 }
2637
2638 let ins: i32 = insertions.parse().unwrap_or(0);
2639 let del: i32 = deletions.parse().unwrap_or(0);
2640
2641 if ins > 0 || del > 0 {
2642 return Some(format!("+{} -{} lines", ins, del));
2643 }
2644 }
2645
2646 let staged_output = Command::new("git")
2648 .args(["diff", "--numstat", "--cached", "--"])
2649 .arg(path)
2650 .current_dir(&self.working_dir)
2651 .output()
2652 .ok()?;
2653
2654 if staged_output.status.success() {
2655 let staged_stdout = String::from_utf8_lossy(&staged_output.stdout);
2656 if let Some(line) = staged_stdout.lines().next() {
2657 let parts: Vec<&str> = line.split('\t').collect();
2658 if parts.len() >= 2 {
2659 let insertions = parts[0];
2660 let deletions = parts[1];
2661
2662 if insertions == "-" && deletions == "-" {
2663 return Some("Binary file staged".to_string());
2664 }
2665
2666 let ins: i32 = insertions.parse().unwrap_or(0);
2667 let del: i32 = deletions.parse().unwrap_or(0);
2668
2669 if ins > 0 || del > 0 {
2670 return Some(format!("+{} -{} lines (staged)", ins, del));
2671 }
2672 }
2673 }
2674 }
2675
2676 None
2677 }
2678
2679 fn get_modified_files_in_directory(
2681 &self,
2682 dir_path: &std::path::Path,
2683 ) -> Option<Vec<std::path::PathBuf>> {
2684 use std::process::Command;
2685
2686 let resolved_path = dir_path
2688 .canonicalize()
2689 .unwrap_or_else(|_| dir_path.to_path_buf());
2690
2691 let output = Command::new("git")
2693 .args(["status", "--porcelain", "--"])
2694 .arg(&resolved_path)
2695 .current_dir(&self.working_dir)
2696 .output()
2697 .ok()?;
2698
2699 if !output.status.success() {
2700 return None;
2701 }
2702
2703 let stdout = String::from_utf8_lossy(&output.stdout);
2704 let modified_files: Vec<std::path::PathBuf> = stdout
2705 .lines()
2706 .filter_map(|line| {
2707 if line.len() > 3 {
2710 let file_part = &line[3..];
2711 let file_name = if file_part.contains(" -> ") {
2713 file_part.split(" -> ").last().unwrap_or(file_part)
2714 } else {
2715 file_part
2716 };
2717 Some(self.working_dir.join(file_name))
2718 } else {
2719 None
2720 }
2721 })
2722 .collect();
2723
2724 if modified_files.is_empty() {
2725 None
2726 } else {
2727 Some(modified_files)
2728 }
2729 }
2730}