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) = super::click_geometry::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(target)) => {
938 return Some(HoverTarget::TabCloseButton(target, *split_id));
939 }
940 Some(TabHit::TabName(target)) => {
941 return Some(HoverTarget::TabName(target, *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, relative_row as u16));
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 if self.is_non_scrollable_buffer(buffer_id) {
1112 return Ok(());
1113 }
1114
1115 self.focus_split(split_id, buffer_id);
1117
1118 let cached_mappings = self
1120 .cached_layout
1121 .view_line_mappings
1122 .get(&split_id)
1123 .cloned();
1124
1125 let leaf_id = split_id;
1127 let fallback = self
1128 .split_view_states
1129 .get(&leaf_id)
1130 .map(|vs| vs.viewport.top_byte)
1131 .unwrap_or(0);
1132
1133 let compose_width = self
1135 .split_view_states
1136 .get(&leaf_id)
1137 .and_then(|vs| vs.compose_width);
1138
1139 if let Some(state) = self.buffers.get_mut(&buffer_id) {
1141 let gutter_width = state.margins.left_total_width() as u16;
1142
1143 let Some(target_position) = super::click_geometry::screen_to_buffer_position(
1144 col,
1145 row,
1146 content_rect,
1147 gutter_width,
1148 &cached_mappings,
1149 fallback,
1150 true, compose_width,
1152 ) else {
1153 return Ok(());
1154 };
1155
1156 let primary_cursor_id = self
1158 .split_view_states
1159 .get(&leaf_id)
1160 .map(|vs| vs.cursors.primary_id())
1161 .unwrap_or(CursorId(0));
1162 let event = Event::MoveCursor {
1163 cursor_id: primary_cursor_id,
1164 old_position: 0,
1165 new_position: target_position,
1166 old_anchor: None,
1167 new_anchor: None,
1168 old_sticky_column: 0,
1169 new_sticky_column: 0,
1170 };
1171
1172 if let Some(event_log) = self.event_logs.get_mut(&buffer_id) {
1173 event_log.append(event.clone());
1174 }
1175 if let Some(cursors) = self
1176 .split_view_states
1177 .get_mut(&leaf_id)
1178 .map(|vs| &mut vs.cursors)
1179 {
1180 state.apply(cursors, &event);
1181 }
1182 }
1183
1184 self.handle_action(Action::SelectWord)?;
1186
1187 if let Some(cursor) = self
1189 .split_view_states
1190 .get(&leaf_id)
1191 .map(|vs| vs.cursors.primary())
1192 {
1193 let sel_start = cursor.selection_start();
1196 let sel_end = cursor.selection_end();
1197 self.mouse_state.dragging_text_selection = true;
1198 self.mouse_state.drag_selection_split = Some(split_id);
1199 self.mouse_state.drag_selection_anchor = Some(sel_start);
1200 self.mouse_state.drag_selection_by_words = true;
1201 self.mouse_state.drag_selection_word_end = Some(sel_end);
1202 }
1203
1204 Ok(())
1205 }
1206 pub(super) fn handle_mouse_triple_click(&mut self, col: u16, row: u16) -> AnyhowResult<()> {
1209 tracing::debug!("handle_mouse_triple_click at col={}, row={}", col, row);
1210
1211 if self.is_mouse_over_any_popup(col, row) {
1213 return Ok(());
1214 } else {
1215 self.dismiss_transient_popups();
1216 }
1217
1218 let split_areas = self.cached_layout.split_areas.clone();
1220 for (split_id, buffer_id, content_rect, _scrollbar_rect, _thumb_start, _thumb_end) in
1221 &split_areas
1222 {
1223 if col >= content_rect.x
1224 && col < content_rect.x + content_rect.width
1225 && row >= content_rect.y
1226 && row < content_rect.y + content_rect.height
1227 {
1228 if self.is_terminal_buffer(*buffer_id) {
1229 return Ok(());
1230 }
1231
1232 self.key_context = crate::input::keybindings::KeyContext::Normal;
1233
1234 self.handle_editor_triple_click(col, row, *split_id, *buffer_id, *content_rect)?;
1237 return Ok(());
1238 }
1239 }
1240
1241 Ok(())
1242 }
1243
1244 fn handle_editor_triple_click(
1246 &mut self,
1247 col: u16,
1248 row: u16,
1249 split_id: LeafId,
1250 buffer_id: BufferId,
1251 content_rect: ratatui::layout::Rect,
1252 ) -> AnyhowResult<()> {
1253 use crate::model::event::Event;
1254
1255 if self.is_non_scrollable_buffer(buffer_id) {
1256 return Ok(());
1257 }
1258
1259 self.focus_split(split_id, buffer_id);
1261
1262 let cached_mappings = self
1264 .cached_layout
1265 .view_line_mappings
1266 .get(&split_id)
1267 .cloned();
1268
1269 let leaf_id = split_id;
1270 let fallback = self
1271 .split_view_states
1272 .get(&leaf_id)
1273 .map(|vs| vs.viewport.top_byte)
1274 .unwrap_or(0);
1275
1276 let compose_width = self
1278 .split_view_states
1279 .get(&leaf_id)
1280 .and_then(|vs| vs.compose_width);
1281
1282 if let Some(state) = self.buffers.get_mut(&buffer_id) {
1284 let gutter_width = state.margins.left_total_width() as u16;
1285
1286 let Some(target_position) = super::click_geometry::screen_to_buffer_position(
1287 col,
1288 row,
1289 content_rect,
1290 gutter_width,
1291 &cached_mappings,
1292 fallback,
1293 true,
1294 compose_width,
1295 ) else {
1296 return Ok(());
1297 };
1298
1299 let primary_cursor_id = self
1301 .split_view_states
1302 .get(&leaf_id)
1303 .map(|vs| vs.cursors.primary_id())
1304 .unwrap_or(CursorId(0));
1305 let event = Event::MoveCursor {
1306 cursor_id: primary_cursor_id,
1307 old_position: 0,
1308 new_position: target_position,
1309 old_anchor: None,
1310 new_anchor: None,
1311 old_sticky_column: 0,
1312 new_sticky_column: 0,
1313 };
1314
1315 if let Some(event_log) = self.event_logs.get_mut(&buffer_id) {
1316 event_log.append(event.clone());
1317 }
1318 if let Some(cursors) = self
1319 .split_view_states
1320 .get_mut(&leaf_id)
1321 .map(|vs| &mut vs.cursors)
1322 {
1323 state.apply(cursors, &event);
1324 }
1325 }
1326
1327 self.handle_action(Action::SelectLine)?;
1329
1330 Ok(())
1331 }
1332
1333 pub(super) fn handle_mouse_click(
1335 &mut self,
1336 col: u16,
1337 row: u16,
1338 modifiers: crossterm::event::KeyModifiers,
1339 ) -> AnyhowResult<()> {
1340 if self.tab_context_menu.is_some() {
1342 if let Some(result) = self.handle_tab_context_menu_click(col, row) {
1343 return result;
1344 }
1345 }
1346
1347 if !self.is_mouse_over_any_popup(col, row) {
1350 self.dismiss_transient_popups();
1351 }
1352
1353 if let Some((inner_rect, start_idx, _visible_count, total_count)) =
1355 &self.cached_layout.suggestions_area.clone()
1356 {
1357 if col >= inner_rect.x
1358 && col < inner_rect.x + inner_rect.width
1359 && row >= inner_rect.y
1360 && row < inner_rect.y + inner_rect.height
1361 {
1362 let relative_row = (row - inner_rect.y) as usize;
1363 let item_idx = start_idx + relative_row;
1364
1365 if item_idx < *total_count {
1366 if let Some(prompt) = &mut self.prompt {
1368 prompt.selected_suggestion = Some(item_idx);
1369 }
1370 return self.handle_action(Action::PromptConfirm);
1372 }
1373 }
1374 }
1375
1376 let scrollbar_scroll_info: Option<(usize, i32)> =
1379 self.cached_layout.popup_areas.iter().rev().find_map(
1380 |(
1381 popup_idx,
1382 _popup_rect,
1383 inner_rect,
1384 _scroll_offset,
1385 _num_items,
1386 scrollbar_rect,
1387 total_lines,
1388 )| {
1389 let sb_rect = scrollbar_rect.as_ref()?;
1390 if col >= sb_rect.x
1391 && col < sb_rect.x + sb_rect.width
1392 && row >= sb_rect.y
1393 && row < sb_rect.y + sb_rect.height
1394 {
1395 let relative_row = (row - sb_rect.y) as usize;
1396 let track_height = sb_rect.height as usize;
1397 let visible_lines = inner_rect.height as usize;
1398
1399 if track_height > 0 && *total_lines > visible_lines {
1400 let max_scroll = total_lines.saturating_sub(visible_lines);
1401 let target_scroll = if track_height > 1 {
1402 (relative_row * max_scroll) / (track_height.saturating_sub(1))
1403 } else {
1404 0
1405 };
1406 Some((*popup_idx, target_scroll as i32))
1407 } else {
1408 Some((*popup_idx, 0))
1409 }
1410 } else {
1411 None
1412 }
1413 },
1414 );
1415
1416 if let Some((popup_idx, target_scroll)) = scrollbar_scroll_info {
1417 self.mouse_state.dragging_popup_scrollbar = Some(popup_idx);
1419 self.mouse_state.drag_start_row = Some(row);
1420 let current_scroll = self
1422 .active_state()
1423 .popups
1424 .get(popup_idx)
1425 .map(|p| p.scroll_offset)
1426 .unwrap_or(0);
1427 self.mouse_state.drag_start_popup_scroll = Some(current_scroll);
1428 let state = self.active_state_mut();
1430 if let Some(popup) = state.popups.get_mut(popup_idx) {
1431 let delta = target_scroll - current_scroll as i32;
1432 popup.scroll_by(delta);
1433 }
1434 return Ok(());
1435 }
1436
1437 for (_popup_idx, popup_rect, _inner, _scroll, _n, _sb, _tl) in
1443 self.cached_layout.popup_areas.iter().rev()
1444 {
1445 if popup_rect.width < 5 {
1446 continue;
1447 }
1448 let cb_x = popup_rect.x + popup_rect.width - 4;
1449 if row == popup_rect.y && col >= cb_x && col < cb_x + 3 {
1450 return self.handle_action(Action::PopupCancel);
1451 }
1452 }
1453
1454 for (popup_idx, _popup_rect, inner_rect, scroll_offset, num_items, _, _) in
1456 self.cached_layout.popup_areas.iter().rev()
1457 {
1458 if col >= inner_rect.x
1459 && col < inner_rect.x + inner_rect.width
1460 && row >= inner_rect.y
1461 && row < inner_rect.y + inner_rect.height
1462 {
1463 let relative_col = (col - inner_rect.x) as usize;
1465 let relative_row = (row - inner_rect.y) as usize;
1466
1467 let link_url = {
1469 let state = self.active_state();
1470 state
1471 .popups
1472 .top()
1473 .and_then(|popup| popup.link_at_position(relative_col, relative_row))
1474 };
1475
1476 if let Some(url) = link_url {
1477 #[cfg(feature = "runtime")]
1479 if let Err(e) = open::that(&url) {
1480 self.set_status_message(format!("Failed to open URL: {}", e));
1481 } else {
1482 self.set_status_message(format!("Opening: {}", url));
1483 }
1484 return Ok(());
1485 }
1486
1487 if *num_items > 0 {
1489 let item_idx = scroll_offset + relative_row;
1490
1491 if item_idx < *num_items {
1492 let state = self.active_state_mut();
1494 if let Some(popup) = state.popups.top_mut() {
1495 if let crate::view::popup::PopupContent::List { items: _, selected } =
1496 &mut popup.content
1497 {
1498 *selected = item_idx;
1499 }
1500 }
1501 return self.handle_action(Action::PopupConfirm);
1503 }
1504 }
1505
1506 let is_text_popup = {
1508 let state = self.active_state();
1509 state.popups.top().is_some_and(|p| {
1510 matches!(
1511 p.content,
1512 crate::view::popup::PopupContent::Text(_)
1513 | crate::view::popup::PopupContent::Markdown(_)
1514 )
1515 })
1516 };
1517
1518 if is_text_popup {
1519 let line = scroll_offset + relative_row;
1520 let popup_idx_copy = *popup_idx; let state = self.active_state_mut();
1522 if let Some(popup) = state.popups.top_mut() {
1523 popup.start_selection(line, relative_col);
1524 }
1525 self.mouse_state.selecting_in_popup = Some(popup_idx_copy);
1527 return Ok(());
1528 }
1529 }
1530 }
1531
1532 if self.is_mouse_over_any_popup(col, row) {
1535 return Ok(());
1536 }
1537
1538 if self.is_file_open_active() && self.handle_file_open_click(col, row) {
1540 return Ok(());
1541 }
1542
1543 if self.menu_bar_visible {
1545 if let Some(ref menu_layout) = self.cached_layout.menu_layout {
1546 if let Some(menu_idx) = menu_layout.menu_at(col, row) {
1547 if self.menu_state.active_menu == Some(menu_idx) {
1549 self.close_menu_with_auto_hide();
1550 } else {
1551 self.on_editor_focus_lost();
1553 self.menu_state.open_menu(menu_idx);
1554 }
1555 return Ok(());
1556 } else if row == 0 {
1557 self.close_menu_with_auto_hide();
1559 return Ok(());
1560 }
1561 }
1562 }
1563
1564 if let Some(active_idx) = self.menu_state.active_menu {
1566 let all_menus: Vec<crate::config::Menu> = self
1567 .menus
1568 .menus
1569 .iter()
1570 .chain(self.menu_state.plugin_menus.iter())
1571 .cloned()
1572 .collect();
1573
1574 if let Some(menu) = all_menus.get(active_idx) {
1575 if let Some(click_result) = self.handle_menu_dropdown_click(col, row, menu)? {
1577 return click_result;
1578 }
1579 }
1580
1581 self.close_menu_with_auto_hide();
1583 return Ok(());
1584 }
1585
1586 if let Some(explorer_area) = self.cached_layout.file_explorer_area {
1590 let border_x = explorer_area.x + explorer_area.width.saturating_sub(1);
1591 if col == border_x
1592 && row >= explorer_area.y
1593 && row < explorer_area.y + explorer_area.height
1594 {
1595 self.mouse_state.dragging_file_explorer = true;
1596 self.mouse_state.drag_start_position = Some((col, row));
1597 self.mouse_state.drag_start_explorer_width = Some(self.file_explorer_width_percent);
1598 return Ok(());
1599 }
1600 }
1601
1602 if let Some(explorer_area) = self.cached_layout.file_explorer_area {
1604 if col >= explorer_area.x
1605 && col < explorer_area.x + explorer_area.width
1606 && row >= explorer_area.y
1607 && row < explorer_area.y + explorer_area.height
1608 {
1609 self.handle_file_explorer_click(col, row, explorer_area)?;
1610 return Ok(());
1611 }
1612 }
1613
1614 let scrollbar_hit = self.cached_layout.split_areas.iter().find_map(
1616 |(split_id, buffer_id, _content_rect, scrollbar_rect, thumb_start, thumb_end)| {
1617 if col >= scrollbar_rect.x
1618 && col < scrollbar_rect.x + scrollbar_rect.width
1619 && row >= scrollbar_rect.y
1620 && row < scrollbar_rect.y + scrollbar_rect.height
1621 {
1622 let relative_row = row.saturating_sub(scrollbar_rect.y) as usize;
1623 let is_on_thumb = relative_row >= *thumb_start && relative_row < *thumb_end;
1624 Some((*split_id, *buffer_id, *scrollbar_rect, is_on_thumb))
1625 } else {
1626 None
1627 }
1628 },
1629 );
1630
1631 if let Some((split_id, buffer_id, scrollbar_rect, is_on_thumb)) = scrollbar_hit {
1632 self.focus_split(split_id, buffer_id);
1633
1634 if is_on_thumb {
1635 self.mouse_state.dragging_scrollbar = Some(split_id);
1637 self.mouse_state.drag_start_row = Some(row);
1638 if self.is_composite_buffer(buffer_id) {
1640 if let Some(view_state) = self.composite_view_states.get(&(split_id, buffer_id))
1642 {
1643 self.mouse_state.drag_start_composite_scroll_row =
1644 Some(view_state.scroll_row);
1645 }
1646 } else if let Some(view_state) = self.split_view_states.get(&split_id) {
1647 self.mouse_state.drag_start_top_byte = Some(view_state.viewport.top_byte);
1648 self.mouse_state.drag_start_view_line_offset =
1649 Some(view_state.viewport.top_view_line_offset);
1650 }
1651 } else {
1652 self.mouse_state.dragging_scrollbar = Some(split_id);
1654 self.handle_scrollbar_jump(col, row, split_id, buffer_id, scrollbar_rect)?;
1655 self.mouse_state.hover_target = Some(HoverTarget::ScrollbarThumb(split_id));
1658 }
1659 return Ok(());
1660 }
1661
1662 let hscrollbar_hit = self
1664 .cached_layout
1665 .horizontal_scrollbar_areas
1666 .iter()
1667 .find_map(
1668 |(
1669 split_id,
1670 buffer_id,
1671 hscrollbar_rect,
1672 max_content_width,
1673 thumb_start,
1674 thumb_end,
1675 )| {
1676 if col >= hscrollbar_rect.x
1677 && col < hscrollbar_rect.x + hscrollbar_rect.width
1678 && row >= hscrollbar_rect.y
1679 && row < hscrollbar_rect.y + hscrollbar_rect.height
1680 {
1681 let relative_col = col.saturating_sub(hscrollbar_rect.x) as usize;
1682 let is_on_thumb = relative_col >= *thumb_start && relative_col < *thumb_end;
1683 Some((
1684 *split_id,
1685 *buffer_id,
1686 *hscrollbar_rect,
1687 *max_content_width,
1688 is_on_thumb,
1689 ))
1690 } else {
1691 None
1692 }
1693 },
1694 );
1695
1696 if let Some((split_id, buffer_id, hscrollbar_rect, max_content_width, is_on_thumb)) =
1697 hscrollbar_hit
1698 {
1699 self.focus_split(split_id, buffer_id);
1700 self.mouse_state.dragging_horizontal_scrollbar = Some(split_id);
1701
1702 if is_on_thumb {
1703 self.mouse_state.drag_start_hcol = Some(col);
1705 if let Some(view_state) = self.split_view_states.get(&split_id) {
1706 self.mouse_state.drag_start_left_column = Some(view_state.viewport.left_column);
1707 }
1708 } else {
1709 self.mouse_state.drag_start_hcol = None;
1711 self.mouse_state.drag_start_left_column = None;
1712
1713 let relative_col = col.saturating_sub(hscrollbar_rect.x) as f64;
1714 let track_width = hscrollbar_rect.width as f64;
1715 let ratio = if track_width > 1.0 {
1716 (relative_col / (track_width - 1.0)).clamp(0.0, 1.0)
1717 } else {
1718 0.0
1719 };
1720
1721 if let Some(view_state) = self.split_view_states.get_mut(&split_id) {
1722 let visible_width = view_state.viewport.width as usize;
1723 let max_scroll = max_content_width.saturating_sub(visible_width);
1724 let target_col = (ratio * max_scroll as f64).round() as usize;
1725 view_state.viewport.left_column = target_col.min(max_scroll);
1726 view_state.viewport.set_skip_ensure_visible();
1727 }
1728 }
1729
1730 return Ok(());
1731 }
1732
1733 if let Some((status_row, _status_x, _status_width)) = self.cached_layout.status_bar_area {
1735 if row == status_row {
1736 if let Some((le_row, le_start, le_end)) =
1738 self.cached_layout.status_bar_line_ending_area
1739 {
1740 if row == le_row && col >= le_start && col < le_end {
1741 return self.handle_action(Action::SetLineEnding);
1742 }
1743 }
1744
1745 if let Some((enc_row, enc_start, enc_end)) =
1747 self.cached_layout.status_bar_encoding_area
1748 {
1749 if row == enc_row && col >= enc_start && col < enc_end {
1750 return self.handle_action(Action::SetEncoding);
1751 }
1752 }
1753
1754 if let Some((lang_row, lang_start, lang_end)) =
1756 self.cached_layout.status_bar_language_area
1757 {
1758 if row == lang_row && col >= lang_start && col < lang_end {
1759 return self.handle_action(Action::SetLanguage);
1760 }
1761 }
1762
1763 if let Some((lsp_row, lsp_start, lsp_end)) = self.cached_layout.status_bar_lsp_area
1765 {
1766 if row == lsp_row && col >= lsp_start && col < lsp_end {
1767 return self.handle_action(Action::ShowLspStatus);
1768 }
1769 }
1770
1771 if let Some((warn_row, warn_start, warn_end)) =
1773 self.cached_layout.status_bar_warning_area
1774 {
1775 if row == warn_row && col >= warn_start && col < warn_end {
1776 return self.handle_action(Action::ShowWarnings);
1777 }
1778 }
1779
1780 if let Some((msg_row, msg_start, msg_end)) =
1782 self.cached_layout.status_bar_message_area
1783 {
1784 if row == msg_row && col >= msg_start && col < msg_end {
1785 return self.handle_action(Action::ShowStatusLog);
1786 }
1787 }
1788 }
1789 }
1790
1791 if let Some(ref layout) = self.cached_layout.search_options_layout.clone() {
1793 use crate::view::ui::status_bar::SearchOptionsHover;
1794 if let Some(hover) = layout.checkbox_at(col, row) {
1795 match hover {
1796 SearchOptionsHover::CaseSensitive => {
1797 return self.handle_action(Action::ToggleSearchCaseSensitive);
1798 }
1799 SearchOptionsHover::WholeWord => {
1800 return self.handle_action(Action::ToggleSearchWholeWord);
1801 }
1802 SearchOptionsHover::Regex => {
1803 return self.handle_action(Action::ToggleSearchRegex);
1804 }
1805 SearchOptionsHover::ConfirmEach => {
1806 return self.handle_action(Action::ToggleSearchConfirmEach);
1807 }
1808 SearchOptionsHover::None => {}
1809 }
1810 }
1811 }
1812
1813 for (split_id, direction, sep_x, sep_y, sep_length) in &self.cached_layout.separator_areas {
1815 let is_on_separator = match direction {
1816 SplitDirection::Horizontal => {
1817 row == *sep_y && col >= *sep_x && col < sep_x + sep_length
1819 }
1820 SplitDirection::Vertical => {
1821 col == *sep_x && row >= *sep_y && row < sep_y + sep_length
1823 }
1824 };
1825
1826 if is_on_separator {
1827 self.mouse_state.dragging_separator = Some((*split_id, *direction));
1829 self.mouse_state.drag_start_position = Some((col, row));
1830 let ratio = self
1834 .split_manager
1835 .get_ratio((*split_id).into())
1836 .or_else(|| self.grouped_split_ratio(*split_id));
1837 if let Some(ratio) = ratio {
1838 self.mouse_state.drag_start_ratio = Some(ratio);
1839 }
1840 return Ok(());
1841 }
1842 }
1843
1844 let close_split_click = self
1846 .cached_layout
1847 .close_split_areas
1848 .iter()
1849 .find(|(_, btn_row, start_col, end_col)| {
1850 row == *btn_row && col >= *start_col && col < *end_col
1851 })
1852 .map(|(split_id, _, _, _)| *split_id);
1853
1854 if let Some(split_id) = close_split_click {
1855 if let Err(e) = self.split_manager.close_split(split_id) {
1856 self.set_status_message(
1857 t!("error.cannot_close_split", error = e.to_string()).to_string(),
1858 );
1859 } else {
1860 let new_active_split = self.split_manager.active_split();
1862 if let Some(buffer_id) = self.split_manager.buffer_for_split(new_active_split) {
1863 self.set_active_buffer(buffer_id);
1864 }
1865 self.set_status_message(t!("split.closed").to_string());
1866 }
1867 return Ok(());
1868 }
1869
1870 let maximize_split_click = self
1872 .cached_layout
1873 .maximize_split_areas
1874 .iter()
1875 .find(|(_, btn_row, start_col, end_col)| {
1876 row == *btn_row && col >= *start_col && col < *end_col
1877 })
1878 .map(|(split_id, _, _, _)| *split_id);
1879
1880 if let Some(_split_id) = maximize_split_click {
1881 match self.split_manager.toggle_maximize() {
1883 Ok(maximized) => {
1884 if maximized {
1885 self.set_status_message(t!("split.maximized").to_string());
1886 } else {
1887 self.set_status_message(t!("split.restored").to_string());
1888 }
1889 }
1890 Err(e) => self.set_status_message(e),
1891 }
1892 return Ok(());
1893 }
1894
1895 for (split_id, tab_layout) in &self.cached_layout.tab_layouts {
1898 tracing::debug!(
1899 "Tab layout for split {:?}: bar_area={:?}, left_scroll={:?}, right_scroll={:?}",
1900 split_id,
1901 tab_layout.bar_area,
1902 tab_layout.left_scroll_area,
1903 tab_layout.right_scroll_area
1904 );
1905 }
1906
1907 let tab_hit = self
1908 .cached_layout
1909 .tab_layouts
1910 .iter()
1911 .find_map(|(split_id, tab_layout)| {
1912 let hit = tab_layout.hit_test(col, row);
1913 tracing::debug!(
1914 "Tab hit_test at ({}, {}) for split {:?} returned {:?}",
1915 col,
1916 row,
1917 split_id,
1918 hit
1919 );
1920 hit.map(|h| (*split_id, h))
1921 });
1922
1923 if let Some((split_id, hit)) = tab_hit {
1924 match hit {
1925 TabHit::CloseButton(target) => {
1926 match target {
1927 crate::view::split::TabTarget::Buffer(buffer_id) => {
1928 self.focus_split(split_id, buffer_id);
1929 self.close_tab_in_split(buffer_id, split_id);
1930 }
1931 crate::view::split::TabTarget::Group(group_leaf) => {
1932 self.close_buffer_group_by_leaf(group_leaf);
1933 }
1934 }
1935 return Ok(());
1936 }
1937 TabHit::TabName(target) => {
1938 match target {
1939 crate::view::split::TabTarget::Buffer(buffer_id) => {
1940 self.focus_split(split_id, buffer_id);
1941 self.promote_buffer_from_preview(buffer_id);
1946 self.mouse_state.dragging_tab = Some(super::types::TabDragState::new(
1948 buffer_id,
1949 split_id,
1950 (col, row),
1951 ));
1952 }
1953 crate::view::split::TabTarget::Group(group_leaf) => {
1954 self.activate_group_tab(group_leaf);
1958 }
1959 }
1960 return Ok(());
1961 }
1962 TabHit::ScrollLeft => {
1963 self.set_status_message("ScrollLeft clicked!".to_string());
1965 if let Some(view_state) = self.split_view_states.get_mut(&split_id) {
1966 view_state.tab_scroll_offset =
1967 view_state.tab_scroll_offset.saturating_sub(10);
1968 }
1969 return Ok(());
1970 }
1971 TabHit::ScrollRight => {
1972 self.set_status_message("ScrollRight clicked!".to_string());
1974 if let Some(view_state) = self.split_view_states.get_mut(&split_id) {
1975 view_state.tab_scroll_offset =
1976 view_state.tab_scroll_offset.saturating_add(10);
1977 }
1978 return Ok(());
1979 }
1980 TabHit::BarBackground => {}
1981 }
1982 }
1983
1984 tracing::debug!(
1986 "handle_mouse_click: checking {} split_areas for click at ({}, {})",
1987 self.cached_layout.split_areas.len(),
1988 col,
1989 row
1990 );
1991 for (split_id, buffer_id, content_rect, _scrollbar_rect, _thumb_start, _thumb_end) in
1992 &self.cached_layout.split_areas
1993 {
1994 tracing::debug!(
1995 " split_id={:?}, content_rect=({}, {}, {}x{})",
1996 split_id,
1997 content_rect.x,
1998 content_rect.y,
1999 content_rect.width,
2000 content_rect.height
2001 );
2002 if col >= content_rect.x
2003 && col < content_rect.x + content_rect.width
2004 && row >= content_rect.y
2005 && row < content_rect.y + content_rect.height
2006 {
2007 tracing::debug!(" -> HIT! calling handle_editor_click");
2009 self.handle_editor_click(
2010 col,
2011 row,
2012 *split_id,
2013 *buffer_id,
2014 *content_rect,
2015 modifiers,
2016 )?;
2017 return Ok(());
2018 }
2019 }
2020 tracing::debug!(" -> No split area hit");
2021
2022 Ok(())
2023 }
2024
2025 pub(super) fn handle_mouse_drag(&mut self, col: u16, row: u16) -> AnyhowResult<()> {
2027 if let Some(dragging_split_id) = self.mouse_state.dragging_scrollbar {
2029 for (split_id, buffer_id, _content_rect, scrollbar_rect, _thumb_start, _thumb_end) in
2031 &self.cached_layout.split_areas
2032 {
2033 if *split_id == dragging_split_id {
2034 if self.mouse_state.drag_start_row.is_some() {
2036 self.handle_scrollbar_drag_relative(
2038 row,
2039 *split_id,
2040 *buffer_id,
2041 *scrollbar_rect,
2042 )?;
2043 } else {
2044 self.handle_scrollbar_jump(
2046 col,
2047 row,
2048 *split_id,
2049 *buffer_id,
2050 *scrollbar_rect,
2051 )?;
2052 }
2053 return Ok(());
2054 }
2055 }
2056 }
2057
2058 if let Some(dragging_split_id) = self.mouse_state.dragging_horizontal_scrollbar {
2060 for (
2061 split_id,
2062 _buffer_id,
2063 hscrollbar_rect,
2064 max_content_width,
2065 thumb_start,
2066 thumb_end,
2067 ) in &self.cached_layout.horizontal_scrollbar_areas
2068 {
2069 if *split_id == dragging_split_id {
2070 let track_width = hscrollbar_rect.width as f64;
2071 if track_width <= 1.0 {
2072 break;
2073 }
2074
2075 if let (Some(drag_start_hcol), Some(drag_start_left_column)) = (
2076 self.mouse_state.drag_start_hcol,
2077 self.mouse_state.drag_start_left_column,
2078 ) {
2079 let col_offset = (col as i32) - (drag_start_hcol as i32);
2082 if let Some(view_state) = self.split_view_states.get_mut(&dragging_split_id)
2083 {
2084 let visible_width = view_state.viewport.width as usize;
2085 let max_scroll = max_content_width.saturating_sub(visible_width);
2086 if max_scroll > 0 {
2087 let thumb_size = thumb_end.saturating_sub(*thumb_start).max(1);
2088 let track_travel = (track_width - thumb_size as f64).max(1.0);
2089 let scroll_per_pixel = max_scroll as f64 / track_travel;
2090 let scroll_offset =
2091 (col_offset as f64 * scroll_per_pixel).round() as i64;
2092 let new_left =
2093 (drag_start_left_column as i64 + scroll_offset).max(0) as usize;
2094 view_state.viewport.left_column = new_left.min(max_scroll);
2095 view_state.viewport.set_skip_ensure_visible();
2096 }
2097 }
2098 } else {
2099 let relative_col = col.saturating_sub(hscrollbar_rect.x) as f64;
2101 let ratio = (relative_col / (track_width - 1.0)).clamp(0.0, 1.0);
2102
2103 if let Some(view_state) = self.split_view_states.get_mut(&dragging_split_id)
2104 {
2105 let visible_width = view_state.viewport.width as usize;
2106 let max_scroll = max_content_width.saturating_sub(visible_width);
2107 let target_col = (ratio * max_scroll as f64).round() as usize;
2108 view_state.viewport.left_column = target_col.min(max_scroll);
2109 view_state.viewport.set_skip_ensure_visible();
2110 }
2111 }
2112
2113 return Ok(());
2114 }
2115 }
2116 }
2117
2118 if let Some(popup_idx) = self.mouse_state.selecting_in_popup {
2120 if let Some((_, _, inner_rect, scroll_offset, _, _, _)) = self
2122 .cached_layout
2123 .popup_areas
2124 .iter()
2125 .find(|(idx, _, _, _, _, _, _)| *idx == popup_idx)
2126 {
2127 if col >= inner_rect.x
2129 && col < inner_rect.x + inner_rect.width
2130 && row >= inner_rect.y
2131 && row < inner_rect.y + inner_rect.height
2132 {
2133 let relative_col = (col - inner_rect.x) as usize;
2134 let relative_row = (row - inner_rect.y) as usize;
2135 let line = scroll_offset + relative_row;
2136
2137 let state = self.active_state_mut();
2138 if let Some(popup) = state.popups.get_mut(popup_idx) {
2139 popup.extend_selection(line, relative_col);
2140 }
2141 }
2142 }
2143 return Ok(());
2144 }
2145
2146 if let Some(popup_idx) = self.mouse_state.dragging_popup_scrollbar {
2148 if let Some((_, _, inner_rect, _, _, Some(sb_rect), total_lines)) = self
2150 .cached_layout
2151 .popup_areas
2152 .iter()
2153 .find(|(idx, _, _, _, _, _, _)| *idx == popup_idx)
2154 {
2155 let track_height = sb_rect.height as usize;
2156 let visible_lines = inner_rect.height as usize;
2157
2158 if track_height > 0 && *total_lines > visible_lines {
2159 let relative_row = row.saturating_sub(sb_rect.y) as usize;
2160 let max_scroll = total_lines.saturating_sub(visible_lines);
2161 let target_scroll = if track_height > 1 {
2162 (relative_row * max_scroll) / (track_height.saturating_sub(1))
2163 } else {
2164 0
2165 };
2166
2167 let state = self.active_state_mut();
2168 if let Some(popup) = state.popups.get_mut(popup_idx) {
2169 let current_scroll = popup.scroll_offset as i32;
2170 let delta = target_scroll as i32 - current_scroll;
2171 popup.scroll_by(delta);
2172 }
2173 }
2174 }
2175 return Ok(());
2176 }
2177
2178 if let Some((split_id, direction)) = self.mouse_state.dragging_separator {
2180 self.handle_separator_drag(col, row, split_id, direction)?;
2181 return Ok(());
2182 }
2183
2184 if self.mouse_state.dragging_file_explorer {
2186 self.handle_file_explorer_border_drag(col)?;
2187 return Ok(());
2188 }
2189
2190 if self.mouse_state.dragging_text_selection {
2192 self.handle_text_selection_drag(col, row)?;
2193 return Ok(());
2194 }
2195
2196 if self.mouse_state.dragging_tab.is_some() {
2198 self.handle_tab_drag(col, row)?;
2199 return Ok(());
2200 }
2201
2202 Ok(())
2203 }
2204
2205 fn handle_text_selection_drag(&mut self, col: u16, row: u16) -> AnyhowResult<()> {
2207 use crate::model::event::Event;
2208 use crate::primitives::word_navigation::{find_word_end, find_word_start};
2209
2210 let Some(split_id) = self.mouse_state.drag_selection_split else {
2211 return Ok(());
2212 };
2213 let Some(anchor_position) = self.mouse_state.drag_selection_anchor else {
2214 return Ok(());
2215 };
2216
2217 let buffer_id = self
2219 .cached_layout
2220 .split_areas
2221 .iter()
2222 .find(|(sid, _, _, _, _, _)| *sid == split_id)
2223 .map(|(_, bid, _, _, _, _)| *bid);
2224
2225 let Some(buffer_id) = buffer_id else {
2226 return Ok(());
2227 };
2228
2229 let content_rect = self
2231 .cached_layout
2232 .split_areas
2233 .iter()
2234 .find(|(sid, _, _, _, _, _)| *sid == split_id)
2235 .map(|(_, _, rect, _, _, _)| *rect);
2236
2237 let Some(content_rect) = content_rect else {
2238 return Ok(());
2239 };
2240
2241 let cached_mappings = self
2243 .cached_layout
2244 .view_line_mappings
2245 .get(&split_id)
2246 .cloned();
2247
2248 let leaf_id = split_id;
2249
2250 let fallback = self
2252 .split_view_states
2253 .get(&leaf_id)
2254 .map(|vs| vs.viewport.top_byte)
2255 .unwrap_or(0);
2256
2257 let compose_width = self
2259 .split_view_states
2260 .get(&leaf_id)
2261 .and_then(|vs| vs.compose_width);
2262
2263 if let Some(state) = self.buffers.get_mut(&buffer_id) {
2265 let gutter_width = state.margins.left_total_width() as u16;
2266
2267 let Some(target_position) = super::click_geometry::screen_to_buffer_position(
2268 col,
2269 row,
2270 content_rect,
2271 gutter_width,
2272 &cached_mappings,
2273 fallback,
2274 true, compose_width,
2276 ) else {
2277 return Ok(());
2278 };
2279
2280 let (new_position, anchor_position) = if self.mouse_state.drag_selection_by_words {
2285 if target_position >= anchor_position {
2286 (
2287 find_word_end(&state.buffer, target_position),
2288 anchor_position,
2289 )
2290 } else {
2291 let word_end = self
2292 .mouse_state
2293 .drag_selection_word_end
2294 .unwrap_or(anchor_position);
2295 (find_word_start(&state.buffer, target_position), word_end)
2296 }
2297 } else {
2298 (target_position, anchor_position)
2299 };
2300
2301 let (primary_cursor_id, old_position, old_anchor, old_sticky_column) = self
2302 .split_view_states
2303 .get(&leaf_id)
2304 .map(|vs| {
2305 let cursor = vs.cursors.primary();
2306 (
2307 vs.cursors.primary_id(),
2308 cursor.position,
2309 cursor.anchor,
2310 cursor.sticky_column,
2311 )
2312 })
2313 .unwrap_or((CursorId(0), 0, None, 0));
2314
2315 let new_sticky_column = state
2316 .buffer
2317 .offset_to_position(new_position)
2318 .map(|pos| pos.column)
2319 .unwrap_or(old_sticky_column);
2320 let event = Event::MoveCursor {
2321 cursor_id: primary_cursor_id,
2322 old_position,
2323 new_position,
2324 old_anchor,
2325 new_anchor: Some(anchor_position), old_sticky_column,
2327 new_sticky_column,
2328 };
2329
2330 if let Some(event_log) = self.event_logs.get_mut(&buffer_id) {
2331 event_log.append(event.clone());
2332 }
2333 if let Some(cursors) = self
2334 .split_view_states
2335 .get_mut(&leaf_id)
2336 .map(|vs| &mut vs.cursors)
2337 {
2338 state.apply(cursors, &event);
2339 }
2340 }
2341
2342 Ok(())
2343 }
2344
2345 pub(super) fn handle_file_explorer_border_drag(&mut self, col: u16) -> AnyhowResult<()> {
2347 let Some((start_col, _start_row)) = self.mouse_state.drag_start_position else {
2348 return Ok(());
2349 };
2350 let Some(start_width) = self.mouse_state.drag_start_explorer_width else {
2351 return Ok(());
2352 };
2353
2354 let delta = col as i32 - start_col as i32;
2356 let total_width = self.terminal_width as i32;
2357
2358 if total_width > 0 {
2359 let percent_delta = delta as f32 / total_width as f32;
2361 let new_width = (start_width + percent_delta).clamp(0.1, 0.5);
2363 self.file_explorer_width_percent = new_width;
2364 }
2365
2366 Ok(())
2367 }
2368
2369 pub(super) fn handle_separator_drag(
2371 &mut self,
2372 col: u16,
2373 row: u16,
2374 split_id: ContainerId,
2375 direction: SplitDirection,
2376 ) -> AnyhowResult<()> {
2377 let Some((start_col, start_row)) = self.mouse_state.drag_start_position else {
2378 return Ok(());
2379 };
2380 let Some(start_ratio) = self.mouse_state.drag_start_ratio else {
2381 return Ok(());
2382 };
2383 let Some(editor_area) = self.cached_layout.editor_content_area else {
2384 return Ok(());
2385 };
2386
2387 let (delta, total_size) = match direction {
2389 SplitDirection::Horizontal => {
2390 let delta = row as i32 - start_row as i32;
2392 let total = editor_area.height as i32;
2393 (delta, total)
2394 }
2395 SplitDirection::Vertical => {
2396 let delta = col as i32 - start_col as i32;
2398 let total = editor_area.width as i32;
2399 (delta, total)
2400 }
2401 };
2402
2403 if total_size > 0 {
2406 let ratio_delta = delta as f32 / total_size as f32;
2407 let new_ratio = (start_ratio + ratio_delta).clamp(0.1, 0.9);
2408
2409 if self.split_manager.get_ratio(split_id.into()).is_some() {
2414 self.split_manager.set_ratio(split_id, new_ratio);
2415 } else {
2416 self.set_grouped_split_ratio(split_id, new_ratio);
2417 }
2418 }
2419
2420 Ok(())
2421 }
2422
2423 pub(super) fn handle_right_click(&mut self, col: u16, row: u16) -> AnyhowResult<()> {
2425 if let Some(ref menu) = self.tab_context_menu {
2427 let menu_x = menu.position.0;
2428 let menu_y = menu.position.1;
2429 let menu_width = 22u16; let menu_height = super::types::TabContextMenuItem::all().len() as u16 + 2; if col >= menu_x
2434 && col < menu_x + menu_width
2435 && row >= menu_y
2436 && row < menu_y + menu_height
2437 {
2438 return Ok(());
2440 }
2441 }
2442
2443 let tab_hit =
2445 self.cached_layout.tab_layouts.iter().find_map(
2446 |(split_id, tab_layout)| match tab_layout.hit_test(col, row) {
2447 Some(TabHit::TabName(target) | TabHit::CloseButton(target)) => {
2448 target.as_buffer().map(|bid| (*split_id, bid))
2451 }
2452 _ => None,
2453 },
2454 );
2455
2456 if let Some((split_id, buffer_id)) = tab_hit {
2457 self.tab_context_menu = Some(TabContextMenu::new(buffer_id, split_id, col, row + 1));
2459 } else {
2460 self.tab_context_menu = None;
2462 }
2463
2464 Ok(())
2465 }
2466
2467 pub(super) fn handle_tab_context_menu_click(
2469 &mut self,
2470 col: u16,
2471 row: u16,
2472 ) -> Option<AnyhowResult<()>> {
2473 let menu = self.tab_context_menu.as_ref()?;
2474 let menu_x = menu.position.0;
2475 let menu_y = menu.position.1;
2476 let menu_width = 22u16;
2477 let items = super::types::TabContextMenuItem::all();
2478 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
2482 {
2483 self.tab_context_menu = None;
2485 return Some(Ok(()));
2486 }
2487
2488 if row == menu_y || row == menu_y + menu_height - 1 {
2490 return Some(Ok(()));
2491 }
2492
2493 let item_idx = (row - menu_y - 1) as usize;
2495 if item_idx >= items.len() {
2496 return Some(Ok(()));
2497 }
2498
2499 let buffer_id = menu.buffer_id;
2501 let split_id = menu.split_id;
2502 let item = items[item_idx];
2503
2504 self.tab_context_menu = None;
2506
2507 Some(self.execute_tab_context_menu_action(item, buffer_id, split_id))
2509 }
2510
2511 fn execute_tab_context_menu_action(
2513 &mut self,
2514 item: super::types::TabContextMenuItem,
2515 buffer_id: BufferId,
2516 leaf_id: LeafId,
2517 ) -> AnyhowResult<()> {
2518 use super::types::TabContextMenuItem;
2519 match item {
2520 TabContextMenuItem::Close => {
2521 self.close_tab_in_split(buffer_id, leaf_id);
2522 }
2523 TabContextMenuItem::CloseOthers => {
2524 self.close_other_tabs_in_split(buffer_id, leaf_id);
2525 }
2526 TabContextMenuItem::CloseToRight => {
2527 self.close_tabs_to_right_in_split(buffer_id, leaf_id);
2528 }
2529 TabContextMenuItem::CloseToLeft => {
2530 self.close_tabs_to_left_in_split(buffer_id, leaf_id);
2531 }
2532 TabContextMenuItem::CloseAll => {
2533 self.close_all_tabs_in_split(leaf_id);
2534 }
2535 }
2536
2537 Ok(())
2538 }
2539
2540 fn show_file_explorer_status_tooltip(&mut self, path: std::path::PathBuf, col: u16, row: u16) {
2542 use crate::view::popup::{Popup, PopupPosition};
2543 use ratatui::style::Style;
2544
2545 let is_directory = path.is_dir();
2546
2547 let decoration = self
2549 .file_explorer_decoration_cache
2550 .direct_for_path(&path)
2551 .cloned();
2552
2553 let bubbled_decoration = if is_directory && decoration.is_none() {
2555 self.file_explorer_decoration_cache
2556 .bubbled_for_path(&path)
2557 .cloned()
2558 } else {
2559 None
2560 };
2561
2562 let has_unsaved_changes = if is_directory {
2564 self.buffers.iter().any(|(buffer_id, state)| {
2566 if state.buffer.is_modified() {
2567 if let Some(metadata) = self.buffer_metadata.get(buffer_id) {
2568 if let Some(file_path) = metadata.file_path() {
2569 return file_path.starts_with(&path);
2570 }
2571 }
2572 }
2573 false
2574 })
2575 } else {
2576 self.buffers.iter().any(|(buffer_id, state)| {
2577 if state.buffer.is_modified() {
2578 if let Some(metadata) = self.buffer_metadata.get(buffer_id) {
2579 return metadata.file_path() == Some(&path);
2580 }
2581 }
2582 false
2583 })
2584 };
2585
2586 let mut lines: Vec<String> = Vec::new();
2588
2589 if let Some(decoration) = &decoration {
2590 let symbol = &decoration.symbol;
2591 let explanation = match symbol.as_str() {
2592 "U" => "Untracked - File is not tracked by git",
2593 "M" => "Modified - File has unstaged changes",
2594 "A" => "Added - File is staged for commit",
2595 "D" => "Deleted - File is staged for deletion",
2596 "R" => "Renamed - File has been renamed",
2597 "C" => "Copied - File has been copied",
2598 "!" => "Conflicted - File has merge conflicts",
2599 "●" => "Has changes - Contains modified files",
2600 _ => "Unknown status",
2601 };
2602 lines.push(format!("{} - {}", symbol, explanation));
2603 } else if bubbled_decoration.is_some() {
2604 lines.push("● - Contains modified files".to_string());
2605 } else if has_unsaved_changes {
2606 if is_directory {
2607 lines.push("● - Contains unsaved changes".to_string());
2608 } else {
2609 lines.push("● - Unsaved changes in editor".to_string());
2610 }
2611 } else {
2612 return; }
2614
2615 if is_directory {
2617 if let Some(modified_files) = self.get_modified_files_in_directory(&path) {
2619 lines.push(String::new()); lines.push("Modified files:".to_string());
2621 let resolved_path = path.canonicalize().unwrap_or_else(|_| path.clone());
2623 const MAX_FILES: usize = 8;
2624 for (i, file) in modified_files.iter().take(MAX_FILES).enumerate() {
2625 let display_name = file
2627 .strip_prefix(&resolved_path)
2628 .unwrap_or(file)
2629 .to_string_lossy()
2630 .to_string();
2631 lines.push(format!(" {}", display_name));
2632 if i == MAX_FILES - 1 && modified_files.len() > MAX_FILES {
2633 lines.push(format!(
2634 " ... and {} more",
2635 modified_files.len() - MAX_FILES
2636 ));
2637 break;
2638 }
2639 }
2640 }
2641 } else {
2642 if let Some(stats) = self.get_git_diff_stats(&path) {
2644 lines.push(String::new()); lines.push(stats);
2646 }
2647 }
2648
2649 if lines.is_empty() {
2650 return;
2651 }
2652
2653 let mut popup = Popup::text(lines, &self.theme);
2655 popup.title = Some("Git Status".to_string());
2656 popup.transient = true;
2657 popup.position = PopupPosition::Fixed { x: col, y: row + 1 };
2658 popup.width = 50;
2659 popup.max_height = 15;
2660 popup.border_style = Style::default().fg(self.theme.popup_border_fg);
2661 popup.background_style = Style::default().bg(self.theme.popup_bg);
2662
2663 if let Some(state) = self.buffers.get_mut(&self.active_buffer()) {
2665 state.popups.show(popup);
2666 }
2667 }
2668
2669 fn dismiss_file_explorer_status_tooltip(&mut self) {
2671 if let Some(state) = self.buffers.get_mut(&self.active_buffer()) {
2673 state.popups.dismiss_transient();
2674 }
2675 }
2676
2677 fn get_git_diff_stats(&self, path: &std::path::Path) -> Option<String> {
2679 use std::process::Command;
2680
2681 let output = Command::new("git")
2683 .args(["diff", "--numstat", "--"])
2684 .arg(path)
2685 .current_dir(&self.working_dir)
2686 .output()
2687 .ok()?;
2688
2689 if !output.status.success() {
2690 return None;
2691 }
2692
2693 let stdout = String::from_utf8_lossy(&output.stdout);
2694 let line = stdout.lines().next()?;
2695 let parts: Vec<&str> = line.split('\t').collect();
2696
2697 if parts.len() >= 2 {
2698 let insertions = parts[0];
2699 let deletions = parts[1];
2700
2701 if insertions == "-" && deletions == "-" {
2703 return Some("Binary file changed".to_string());
2704 }
2705
2706 let ins: i32 = insertions.parse().unwrap_or(0);
2707 let del: i32 = deletions.parse().unwrap_or(0);
2708
2709 if ins > 0 || del > 0 {
2710 return Some(format!("+{} -{} lines", ins, del));
2711 }
2712 }
2713
2714 let staged_output = Command::new("git")
2716 .args(["diff", "--numstat", "--cached", "--"])
2717 .arg(path)
2718 .current_dir(&self.working_dir)
2719 .output()
2720 .ok()?;
2721
2722 if staged_output.status.success() {
2723 let staged_stdout = String::from_utf8_lossy(&staged_output.stdout);
2724 if let Some(line) = staged_stdout.lines().next() {
2725 let parts: Vec<&str> = line.split('\t').collect();
2726 if parts.len() >= 2 {
2727 let insertions = parts[0];
2728 let deletions = parts[1];
2729
2730 if insertions == "-" && deletions == "-" {
2731 return Some("Binary file staged".to_string());
2732 }
2733
2734 let ins: i32 = insertions.parse().unwrap_or(0);
2735 let del: i32 = deletions.parse().unwrap_or(0);
2736
2737 if ins > 0 || del > 0 {
2738 return Some(format!("+{} -{} lines (staged)", ins, del));
2739 }
2740 }
2741 }
2742 }
2743
2744 None
2745 }
2746
2747 fn get_modified_files_in_directory(
2749 &self,
2750 dir_path: &std::path::Path,
2751 ) -> Option<Vec<std::path::PathBuf>> {
2752 use std::process::Command;
2753
2754 let resolved_path = dir_path
2756 .canonicalize()
2757 .unwrap_or_else(|_| dir_path.to_path_buf());
2758
2759 let output = Command::new("git")
2761 .args(["status", "--porcelain", "--"])
2762 .arg(&resolved_path)
2763 .current_dir(&self.working_dir)
2764 .output()
2765 .ok()?;
2766
2767 if !output.status.success() {
2768 return None;
2769 }
2770
2771 let stdout = String::from_utf8_lossy(&output.stdout);
2772 let modified_files: Vec<std::path::PathBuf> = stdout
2773 .lines()
2774 .filter_map(|line| {
2775 if line.len() > 3 {
2778 let file_part = &line[3..];
2779 let file_name = if file_part.contains(" -> ") {
2781 file_part.split(" -> ").last().unwrap_or(file_part)
2782 } else {
2783 file_part
2784 };
2785 Some(self.working_dir.join(file_name))
2786 } else {
2787 None
2788 }
2789 })
2790 .collect();
2791
2792 if modified_files.is_empty() {
2793 None
2794 } else {
2795 Some(modified_files)
2796 }
2797 }
2798}