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(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 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 self.mouse_state.hover_target = Some(HoverTarget::ScrollbarThumb(split_id));
1630 }
1631 return Ok(());
1632 }
1633
1634 let hscrollbar_hit = self
1636 .cached_layout
1637 .horizontal_scrollbar_areas
1638 .iter()
1639 .find_map(
1640 |(
1641 split_id,
1642 buffer_id,
1643 hscrollbar_rect,
1644 max_content_width,
1645 thumb_start,
1646 thumb_end,
1647 )| {
1648 if col >= hscrollbar_rect.x
1649 && col < hscrollbar_rect.x + hscrollbar_rect.width
1650 && row >= hscrollbar_rect.y
1651 && row < hscrollbar_rect.y + hscrollbar_rect.height
1652 {
1653 let relative_col = col.saturating_sub(hscrollbar_rect.x) as usize;
1654 let is_on_thumb = relative_col >= *thumb_start && relative_col < *thumb_end;
1655 Some((
1656 *split_id,
1657 *buffer_id,
1658 *hscrollbar_rect,
1659 *max_content_width,
1660 is_on_thumb,
1661 ))
1662 } else {
1663 None
1664 }
1665 },
1666 );
1667
1668 if let Some((split_id, buffer_id, hscrollbar_rect, max_content_width, is_on_thumb)) =
1669 hscrollbar_hit
1670 {
1671 self.focus_split(split_id, buffer_id);
1672 self.mouse_state.dragging_horizontal_scrollbar = Some(split_id);
1673
1674 if is_on_thumb {
1675 self.mouse_state.drag_start_hcol = Some(col);
1677 if let Some(view_state) = self.split_view_states.get(&split_id) {
1678 self.mouse_state.drag_start_left_column = Some(view_state.viewport.left_column);
1679 }
1680 } else {
1681 self.mouse_state.drag_start_hcol = None;
1683 self.mouse_state.drag_start_left_column = None;
1684
1685 let relative_col = col.saturating_sub(hscrollbar_rect.x) as f64;
1686 let track_width = hscrollbar_rect.width as f64;
1687 let ratio = if track_width > 1.0 {
1688 (relative_col / (track_width - 1.0)).clamp(0.0, 1.0)
1689 } else {
1690 0.0
1691 };
1692
1693 if let Some(view_state) = self.split_view_states.get_mut(&split_id) {
1694 let visible_width = view_state.viewport.width as usize;
1695 let max_scroll = max_content_width.saturating_sub(visible_width);
1696 let target_col = (ratio * max_scroll as f64).round() as usize;
1697 view_state.viewport.left_column = target_col.min(max_scroll);
1698 view_state.viewport.set_skip_ensure_visible();
1699 }
1700 }
1701
1702 return Ok(());
1703 }
1704
1705 if let Some((status_row, _status_x, _status_width)) = self.cached_layout.status_bar_area {
1707 if row == status_row {
1708 if let Some((le_row, le_start, le_end)) =
1710 self.cached_layout.status_bar_line_ending_area
1711 {
1712 if row == le_row && col >= le_start && col < le_end {
1713 return self.handle_action(Action::SetLineEnding);
1714 }
1715 }
1716
1717 if let Some((enc_row, enc_start, enc_end)) =
1719 self.cached_layout.status_bar_encoding_area
1720 {
1721 if row == enc_row && col >= enc_start && col < enc_end {
1722 return self.handle_action(Action::SetEncoding);
1723 }
1724 }
1725
1726 if let Some((lang_row, lang_start, lang_end)) =
1728 self.cached_layout.status_bar_language_area
1729 {
1730 if row == lang_row && col >= lang_start && col < lang_end {
1731 return self.handle_action(Action::SetLanguage);
1732 }
1733 }
1734
1735 if let Some((lsp_row, lsp_start, lsp_end)) = self.cached_layout.status_bar_lsp_area
1737 {
1738 if row == lsp_row && col >= lsp_start && col < lsp_end {
1739 return self.handle_action(Action::ShowLspStatus);
1740 }
1741 }
1742
1743 if let Some((warn_row, warn_start, warn_end)) =
1745 self.cached_layout.status_bar_warning_area
1746 {
1747 if row == warn_row && col >= warn_start && col < warn_end {
1748 return self.handle_action(Action::ShowWarnings);
1749 }
1750 }
1751
1752 if let Some((msg_row, msg_start, msg_end)) =
1754 self.cached_layout.status_bar_message_area
1755 {
1756 if row == msg_row && col >= msg_start && col < msg_end {
1757 return self.handle_action(Action::ShowStatusLog);
1758 }
1759 }
1760 }
1761 }
1762
1763 if let Some(ref layout) = self.cached_layout.search_options_layout.clone() {
1765 use crate::view::ui::status_bar::SearchOptionsHover;
1766 if let Some(hover) = layout.checkbox_at(col, row) {
1767 match hover {
1768 SearchOptionsHover::CaseSensitive => {
1769 return self.handle_action(Action::ToggleSearchCaseSensitive);
1770 }
1771 SearchOptionsHover::WholeWord => {
1772 return self.handle_action(Action::ToggleSearchWholeWord);
1773 }
1774 SearchOptionsHover::Regex => {
1775 return self.handle_action(Action::ToggleSearchRegex);
1776 }
1777 SearchOptionsHover::ConfirmEach => {
1778 return self.handle_action(Action::ToggleSearchConfirmEach);
1779 }
1780 SearchOptionsHover::None => {}
1781 }
1782 }
1783 }
1784
1785 for (split_id, direction, sep_x, sep_y, sep_length) in &self.cached_layout.separator_areas {
1787 let is_on_separator = match direction {
1788 SplitDirection::Horizontal => {
1789 row == *sep_y && col >= *sep_x && col < sep_x + sep_length
1791 }
1792 SplitDirection::Vertical => {
1793 col == *sep_x && row >= *sep_y && row < sep_y + sep_length
1795 }
1796 };
1797
1798 if is_on_separator {
1799 self.mouse_state.dragging_separator = Some((*split_id, *direction));
1801 self.mouse_state.drag_start_position = Some((col, row));
1802 let ratio = self
1806 .split_manager
1807 .get_ratio((*split_id).into())
1808 .or_else(|| self.grouped_split_ratio(*split_id));
1809 if let Some(ratio) = ratio {
1810 self.mouse_state.drag_start_ratio = Some(ratio);
1811 }
1812 return Ok(());
1813 }
1814 }
1815
1816 let close_split_click = self
1818 .cached_layout
1819 .close_split_areas
1820 .iter()
1821 .find(|(_, btn_row, start_col, end_col)| {
1822 row == *btn_row && col >= *start_col && col < *end_col
1823 })
1824 .map(|(split_id, _, _, _)| *split_id);
1825
1826 if let Some(split_id) = close_split_click {
1827 if let Err(e) = self.split_manager.close_split(split_id) {
1828 self.set_status_message(
1829 t!("error.cannot_close_split", error = e.to_string()).to_string(),
1830 );
1831 } else {
1832 let new_active_split = self.split_manager.active_split();
1834 if let Some(buffer_id) = self.split_manager.buffer_for_split(new_active_split) {
1835 self.set_active_buffer(buffer_id);
1836 }
1837 self.set_status_message(t!("split.closed").to_string());
1838 }
1839 return Ok(());
1840 }
1841
1842 let maximize_split_click = self
1844 .cached_layout
1845 .maximize_split_areas
1846 .iter()
1847 .find(|(_, btn_row, start_col, end_col)| {
1848 row == *btn_row && col >= *start_col && col < *end_col
1849 })
1850 .map(|(split_id, _, _, _)| *split_id);
1851
1852 if let Some(_split_id) = maximize_split_click {
1853 match self.split_manager.toggle_maximize() {
1855 Ok(maximized) => {
1856 if maximized {
1857 self.set_status_message(t!("split.maximized").to_string());
1858 } else {
1859 self.set_status_message(t!("split.restored").to_string());
1860 }
1861 }
1862 Err(e) => self.set_status_message(e),
1863 }
1864 return Ok(());
1865 }
1866
1867 for (split_id, tab_layout) in &self.cached_layout.tab_layouts {
1870 tracing::debug!(
1871 "Tab layout for split {:?}: bar_area={:?}, left_scroll={:?}, right_scroll={:?}",
1872 split_id,
1873 tab_layout.bar_area,
1874 tab_layout.left_scroll_area,
1875 tab_layout.right_scroll_area
1876 );
1877 }
1878
1879 let tab_hit = self
1880 .cached_layout
1881 .tab_layouts
1882 .iter()
1883 .find_map(|(split_id, tab_layout)| {
1884 let hit = tab_layout.hit_test(col, row);
1885 tracing::debug!(
1886 "Tab hit_test at ({}, {}) for split {:?} returned {:?}",
1887 col,
1888 row,
1889 split_id,
1890 hit
1891 );
1892 hit.map(|h| (*split_id, h))
1893 });
1894
1895 if let Some((split_id, hit)) = tab_hit {
1896 match hit {
1897 TabHit::CloseButton(target) => {
1898 match target {
1899 crate::view::split::TabTarget::Buffer(buffer_id) => {
1900 self.focus_split(split_id, buffer_id);
1901 self.close_tab_in_split(buffer_id, split_id);
1902 }
1903 crate::view::split::TabTarget::Group(group_leaf) => {
1904 self.close_buffer_group_by_leaf(group_leaf);
1905 }
1906 }
1907 return Ok(());
1908 }
1909 TabHit::TabName(target) => {
1910 match target {
1911 crate::view::split::TabTarget::Buffer(buffer_id) => {
1912 self.focus_split(split_id, buffer_id);
1913 self.mouse_state.dragging_tab = Some(super::types::TabDragState::new(
1915 buffer_id,
1916 split_id,
1917 (col, row),
1918 ));
1919 }
1920 crate::view::split::TabTarget::Group(group_leaf) => {
1921 self.activate_group_tab(group_leaf);
1925 }
1926 }
1927 return Ok(());
1928 }
1929 TabHit::ScrollLeft => {
1930 self.set_status_message("ScrollLeft clicked!".to_string());
1932 if let Some(view_state) = self.split_view_states.get_mut(&split_id) {
1933 view_state.tab_scroll_offset =
1934 view_state.tab_scroll_offset.saturating_sub(10);
1935 }
1936 return Ok(());
1937 }
1938 TabHit::ScrollRight => {
1939 self.set_status_message("ScrollRight clicked!".to_string());
1941 if let Some(view_state) = self.split_view_states.get_mut(&split_id) {
1942 view_state.tab_scroll_offset =
1943 view_state.tab_scroll_offset.saturating_add(10);
1944 }
1945 return Ok(());
1946 }
1947 TabHit::BarBackground => {}
1948 }
1949 }
1950
1951 tracing::debug!(
1953 "handle_mouse_click: checking {} split_areas for click at ({}, {})",
1954 self.cached_layout.split_areas.len(),
1955 col,
1956 row
1957 );
1958 for (split_id, buffer_id, content_rect, _scrollbar_rect, _thumb_start, _thumb_end) in
1959 &self.cached_layout.split_areas
1960 {
1961 tracing::debug!(
1962 " split_id={:?}, content_rect=({}, {}, {}x{})",
1963 split_id,
1964 content_rect.x,
1965 content_rect.y,
1966 content_rect.width,
1967 content_rect.height
1968 );
1969 if col >= content_rect.x
1970 && col < content_rect.x + content_rect.width
1971 && row >= content_rect.y
1972 && row < content_rect.y + content_rect.height
1973 {
1974 tracing::debug!(" -> HIT! calling handle_editor_click");
1976 self.handle_editor_click(
1977 col,
1978 row,
1979 *split_id,
1980 *buffer_id,
1981 *content_rect,
1982 modifiers,
1983 )?;
1984 return Ok(());
1985 }
1986 }
1987 tracing::debug!(" -> No split area hit");
1988
1989 Ok(())
1990 }
1991
1992 pub(super) fn handle_mouse_drag(&mut self, col: u16, row: u16) -> AnyhowResult<()> {
1994 if let Some(dragging_split_id) = self.mouse_state.dragging_scrollbar {
1996 for (split_id, buffer_id, _content_rect, scrollbar_rect, _thumb_start, _thumb_end) in
1998 &self.cached_layout.split_areas
1999 {
2000 if *split_id == dragging_split_id {
2001 if self.mouse_state.drag_start_row.is_some() {
2003 self.handle_scrollbar_drag_relative(
2005 row,
2006 *split_id,
2007 *buffer_id,
2008 *scrollbar_rect,
2009 )?;
2010 } else {
2011 self.handle_scrollbar_jump(
2013 col,
2014 row,
2015 *split_id,
2016 *buffer_id,
2017 *scrollbar_rect,
2018 )?;
2019 }
2020 return Ok(());
2021 }
2022 }
2023 }
2024
2025 if let Some(dragging_split_id) = self.mouse_state.dragging_horizontal_scrollbar {
2027 for (
2028 split_id,
2029 _buffer_id,
2030 hscrollbar_rect,
2031 max_content_width,
2032 thumb_start,
2033 thumb_end,
2034 ) in &self.cached_layout.horizontal_scrollbar_areas
2035 {
2036 if *split_id == dragging_split_id {
2037 let track_width = hscrollbar_rect.width as f64;
2038 if track_width <= 1.0 {
2039 break;
2040 }
2041
2042 if let (Some(drag_start_hcol), Some(drag_start_left_column)) = (
2043 self.mouse_state.drag_start_hcol,
2044 self.mouse_state.drag_start_left_column,
2045 ) {
2046 let col_offset = (col as i32) - (drag_start_hcol as i32);
2049 if let Some(view_state) = self.split_view_states.get_mut(&dragging_split_id)
2050 {
2051 let visible_width = view_state.viewport.width as usize;
2052 let max_scroll = max_content_width.saturating_sub(visible_width);
2053 if max_scroll > 0 {
2054 let thumb_size = thumb_end.saturating_sub(*thumb_start).max(1);
2055 let track_travel = (track_width - thumb_size as f64).max(1.0);
2056 let scroll_per_pixel = max_scroll as f64 / track_travel;
2057 let scroll_offset =
2058 (col_offset as f64 * scroll_per_pixel).round() as i64;
2059 let new_left =
2060 (drag_start_left_column as i64 + scroll_offset).max(0) as usize;
2061 view_state.viewport.left_column = new_left.min(max_scroll);
2062 view_state.viewport.set_skip_ensure_visible();
2063 }
2064 }
2065 } else {
2066 let relative_col = col.saturating_sub(hscrollbar_rect.x) as f64;
2068 let ratio = (relative_col / (track_width - 1.0)).clamp(0.0, 1.0);
2069
2070 if let Some(view_state) = self.split_view_states.get_mut(&dragging_split_id)
2071 {
2072 let visible_width = view_state.viewport.width as usize;
2073 let max_scroll = max_content_width.saturating_sub(visible_width);
2074 let target_col = (ratio * max_scroll as f64).round() as usize;
2075 view_state.viewport.left_column = target_col.min(max_scroll);
2076 view_state.viewport.set_skip_ensure_visible();
2077 }
2078 }
2079
2080 return Ok(());
2081 }
2082 }
2083 }
2084
2085 if let Some(popup_idx) = self.mouse_state.selecting_in_popup {
2087 if let Some((_, _, inner_rect, scroll_offset, _, _, _)) = self
2089 .cached_layout
2090 .popup_areas
2091 .iter()
2092 .find(|(idx, _, _, _, _, _, _)| *idx == popup_idx)
2093 {
2094 if col >= inner_rect.x
2096 && col < inner_rect.x + inner_rect.width
2097 && row >= inner_rect.y
2098 && row < inner_rect.y + inner_rect.height
2099 {
2100 let relative_col = (col - inner_rect.x) as usize;
2101 let relative_row = (row - inner_rect.y) as usize;
2102 let line = scroll_offset + relative_row;
2103
2104 let state = self.active_state_mut();
2105 if let Some(popup) = state.popups.get_mut(popup_idx) {
2106 popup.extend_selection(line, relative_col);
2107 }
2108 }
2109 }
2110 return Ok(());
2111 }
2112
2113 if let Some(popup_idx) = self.mouse_state.dragging_popup_scrollbar {
2115 if let Some((_, _, inner_rect, _, _, Some(sb_rect), total_lines)) = self
2117 .cached_layout
2118 .popup_areas
2119 .iter()
2120 .find(|(idx, _, _, _, _, _, _)| *idx == popup_idx)
2121 {
2122 let track_height = sb_rect.height as usize;
2123 let visible_lines = inner_rect.height as usize;
2124
2125 if track_height > 0 && *total_lines > visible_lines {
2126 let relative_row = row.saturating_sub(sb_rect.y) as usize;
2127 let max_scroll = total_lines.saturating_sub(visible_lines);
2128 let target_scroll = if track_height > 1 {
2129 (relative_row * max_scroll) / (track_height.saturating_sub(1))
2130 } else {
2131 0
2132 };
2133
2134 let state = self.active_state_mut();
2135 if let Some(popup) = state.popups.get_mut(popup_idx) {
2136 let current_scroll = popup.scroll_offset as i32;
2137 let delta = target_scroll as i32 - current_scroll;
2138 popup.scroll_by(delta);
2139 }
2140 }
2141 }
2142 return Ok(());
2143 }
2144
2145 if let Some((split_id, direction)) = self.mouse_state.dragging_separator {
2147 self.handle_separator_drag(col, row, split_id, direction)?;
2148 return Ok(());
2149 }
2150
2151 if self.mouse_state.dragging_file_explorer {
2153 self.handle_file_explorer_border_drag(col)?;
2154 return Ok(());
2155 }
2156
2157 if self.mouse_state.dragging_text_selection {
2159 self.handle_text_selection_drag(col, row)?;
2160 return Ok(());
2161 }
2162
2163 if self.mouse_state.dragging_tab.is_some() {
2165 self.handle_tab_drag(col, row)?;
2166 return Ok(());
2167 }
2168
2169 Ok(())
2170 }
2171
2172 fn handle_text_selection_drag(&mut self, col: u16, row: u16) -> AnyhowResult<()> {
2174 use crate::model::event::Event;
2175 use crate::primitives::word_navigation::{find_word_end, find_word_start};
2176
2177 let Some(split_id) = self.mouse_state.drag_selection_split else {
2178 return Ok(());
2179 };
2180 let Some(anchor_position) = self.mouse_state.drag_selection_anchor else {
2181 return Ok(());
2182 };
2183
2184 let buffer_id = self
2186 .cached_layout
2187 .split_areas
2188 .iter()
2189 .find(|(sid, _, _, _, _, _)| *sid == split_id)
2190 .map(|(_, bid, _, _, _, _)| *bid);
2191
2192 let Some(buffer_id) = buffer_id else {
2193 return Ok(());
2194 };
2195
2196 let content_rect = self
2198 .cached_layout
2199 .split_areas
2200 .iter()
2201 .find(|(sid, _, _, _, _, _)| *sid == split_id)
2202 .map(|(_, _, rect, _, _, _)| *rect);
2203
2204 let Some(content_rect) = content_rect else {
2205 return Ok(());
2206 };
2207
2208 let cached_mappings = self
2210 .cached_layout
2211 .view_line_mappings
2212 .get(&split_id)
2213 .cloned();
2214
2215 let leaf_id = split_id;
2216
2217 let fallback = self
2219 .split_view_states
2220 .get(&leaf_id)
2221 .map(|vs| vs.viewport.top_byte)
2222 .unwrap_or(0);
2223
2224 let compose_width = self
2226 .split_view_states
2227 .get(&leaf_id)
2228 .and_then(|vs| vs.compose_width);
2229
2230 if let Some(state) = self.buffers.get_mut(&buffer_id) {
2232 let gutter_width = state.margins.left_total_width() as u16;
2233
2234 let Some(target_position) = Self::screen_to_buffer_position(
2235 col,
2236 row,
2237 content_rect,
2238 gutter_width,
2239 &cached_mappings,
2240 fallback,
2241 true, compose_width,
2243 ) else {
2244 return Ok(());
2245 };
2246
2247 let (new_position, anchor_position) = if self.mouse_state.drag_selection_by_words {
2252 if target_position >= anchor_position {
2253 (
2254 find_word_end(&state.buffer, target_position),
2255 anchor_position,
2256 )
2257 } else {
2258 let word_end = self
2259 .mouse_state
2260 .drag_selection_word_end
2261 .unwrap_or(anchor_position);
2262 (find_word_start(&state.buffer, target_position), word_end)
2263 }
2264 } else {
2265 (target_position, anchor_position)
2266 };
2267
2268 let (primary_cursor_id, old_position, old_anchor, old_sticky_column) = self
2269 .split_view_states
2270 .get(&leaf_id)
2271 .map(|vs| {
2272 let cursor = vs.cursors.primary();
2273 (
2274 vs.cursors.primary_id(),
2275 cursor.position,
2276 cursor.anchor,
2277 cursor.sticky_column,
2278 )
2279 })
2280 .unwrap_or((CursorId(0), 0, None, 0));
2281
2282 let new_sticky_column = state
2283 .buffer
2284 .offset_to_position(new_position)
2285 .map(|pos| pos.column)
2286 .unwrap_or(old_sticky_column);
2287 let event = Event::MoveCursor {
2288 cursor_id: primary_cursor_id,
2289 old_position,
2290 new_position,
2291 old_anchor,
2292 new_anchor: Some(anchor_position), old_sticky_column,
2294 new_sticky_column,
2295 };
2296
2297 if let Some(event_log) = self.event_logs.get_mut(&buffer_id) {
2298 event_log.append(event.clone());
2299 }
2300 if let Some(cursors) = self
2301 .split_view_states
2302 .get_mut(&leaf_id)
2303 .map(|vs| &mut vs.cursors)
2304 {
2305 state.apply(cursors, &event);
2306 }
2307 }
2308
2309 Ok(())
2310 }
2311
2312 pub(super) fn handle_file_explorer_border_drag(&mut self, col: u16) -> AnyhowResult<()> {
2314 let Some((start_col, _start_row)) = self.mouse_state.drag_start_position else {
2315 return Ok(());
2316 };
2317 let Some(start_width) = self.mouse_state.drag_start_explorer_width else {
2318 return Ok(());
2319 };
2320
2321 let delta = col as i32 - start_col as i32;
2323 let total_width = self.terminal_width as i32;
2324
2325 if total_width > 0 {
2326 let percent_delta = delta as f32 / total_width as f32;
2328 let new_width = (start_width + percent_delta).clamp(0.1, 0.5);
2330 self.file_explorer_width_percent = new_width;
2331 }
2332
2333 Ok(())
2334 }
2335
2336 pub(super) fn handle_separator_drag(
2338 &mut self,
2339 col: u16,
2340 row: u16,
2341 split_id: ContainerId,
2342 direction: SplitDirection,
2343 ) -> AnyhowResult<()> {
2344 let Some((start_col, start_row)) = self.mouse_state.drag_start_position else {
2345 return Ok(());
2346 };
2347 let Some(start_ratio) = self.mouse_state.drag_start_ratio else {
2348 return Ok(());
2349 };
2350 let Some(editor_area) = self.cached_layout.editor_content_area else {
2351 return Ok(());
2352 };
2353
2354 let (delta, total_size) = match direction {
2356 SplitDirection::Horizontal => {
2357 let delta = row as i32 - start_row as i32;
2359 let total = editor_area.height as i32;
2360 (delta, total)
2361 }
2362 SplitDirection::Vertical => {
2363 let delta = col as i32 - start_col as i32;
2365 let total = editor_area.width as i32;
2366 (delta, total)
2367 }
2368 };
2369
2370 if total_size > 0 {
2373 let ratio_delta = delta as f32 / total_size as f32;
2374 let new_ratio = (start_ratio + ratio_delta).clamp(0.1, 0.9);
2375
2376 if self.split_manager.get_ratio(split_id.into()).is_some() {
2381 self.split_manager.set_ratio(split_id, new_ratio);
2382 } else {
2383 self.set_grouped_split_ratio(split_id, new_ratio);
2384 }
2385 }
2386
2387 Ok(())
2388 }
2389
2390 pub(super) fn handle_right_click(&mut self, col: u16, row: u16) -> AnyhowResult<()> {
2392 if let Some(ref menu) = self.tab_context_menu {
2394 let menu_x = menu.position.0;
2395 let menu_y = menu.position.1;
2396 let menu_width = 22u16; let menu_height = super::types::TabContextMenuItem::all().len() as u16 + 2; if col >= menu_x
2401 && col < menu_x + menu_width
2402 && row >= menu_y
2403 && row < menu_y + menu_height
2404 {
2405 return Ok(());
2407 }
2408 }
2409
2410 let tab_hit =
2412 self.cached_layout.tab_layouts.iter().find_map(
2413 |(split_id, tab_layout)| match tab_layout.hit_test(col, row) {
2414 Some(TabHit::TabName(target) | TabHit::CloseButton(target)) => {
2415 target.as_buffer().map(|bid| (*split_id, bid))
2418 }
2419 _ => None,
2420 },
2421 );
2422
2423 if let Some((split_id, buffer_id)) = tab_hit {
2424 self.tab_context_menu = Some(TabContextMenu::new(buffer_id, split_id, col, row + 1));
2426 } else {
2427 self.tab_context_menu = None;
2429 }
2430
2431 Ok(())
2432 }
2433
2434 pub(super) fn handle_tab_context_menu_click(
2436 &mut self,
2437 col: u16,
2438 row: u16,
2439 ) -> Option<AnyhowResult<()>> {
2440 let menu = self.tab_context_menu.as_ref()?;
2441 let menu_x = menu.position.0;
2442 let menu_y = menu.position.1;
2443 let menu_width = 22u16;
2444 let items = super::types::TabContextMenuItem::all();
2445 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
2449 {
2450 self.tab_context_menu = None;
2452 return Some(Ok(()));
2453 }
2454
2455 if row == menu_y || row == menu_y + menu_height - 1 {
2457 return Some(Ok(()));
2458 }
2459
2460 let item_idx = (row - menu_y - 1) as usize;
2462 if item_idx >= items.len() {
2463 return Some(Ok(()));
2464 }
2465
2466 let buffer_id = menu.buffer_id;
2468 let split_id = menu.split_id;
2469 let item = items[item_idx];
2470
2471 self.tab_context_menu = None;
2473
2474 Some(self.execute_tab_context_menu_action(item, buffer_id, split_id))
2476 }
2477
2478 fn execute_tab_context_menu_action(
2480 &mut self,
2481 item: super::types::TabContextMenuItem,
2482 buffer_id: BufferId,
2483 leaf_id: LeafId,
2484 ) -> AnyhowResult<()> {
2485 use super::types::TabContextMenuItem;
2486 match item {
2487 TabContextMenuItem::Close => {
2488 self.close_tab_in_split(buffer_id, leaf_id);
2489 }
2490 TabContextMenuItem::CloseOthers => {
2491 self.close_other_tabs_in_split(buffer_id, leaf_id);
2492 }
2493 TabContextMenuItem::CloseToRight => {
2494 self.close_tabs_to_right_in_split(buffer_id, leaf_id);
2495 }
2496 TabContextMenuItem::CloseToLeft => {
2497 self.close_tabs_to_left_in_split(buffer_id, leaf_id);
2498 }
2499 TabContextMenuItem::CloseAll => {
2500 self.close_all_tabs_in_split(leaf_id);
2501 }
2502 }
2503
2504 Ok(())
2505 }
2506
2507 fn show_file_explorer_status_tooltip(&mut self, path: std::path::PathBuf, col: u16, row: u16) {
2509 use crate::view::popup::{Popup, PopupPosition};
2510 use ratatui::style::Style;
2511
2512 let is_directory = path.is_dir();
2513
2514 let decoration = self
2516 .file_explorer_decoration_cache
2517 .direct_for_path(&path)
2518 .cloned();
2519
2520 let bubbled_decoration = if is_directory && decoration.is_none() {
2522 self.file_explorer_decoration_cache
2523 .bubbled_for_path(&path)
2524 .cloned()
2525 } else {
2526 None
2527 };
2528
2529 let has_unsaved_changes = if is_directory {
2531 self.buffers.iter().any(|(buffer_id, state)| {
2533 if state.buffer.is_modified() {
2534 if let Some(metadata) = self.buffer_metadata.get(buffer_id) {
2535 if let Some(file_path) = metadata.file_path() {
2536 return file_path.starts_with(&path);
2537 }
2538 }
2539 }
2540 false
2541 })
2542 } else {
2543 self.buffers.iter().any(|(buffer_id, state)| {
2544 if state.buffer.is_modified() {
2545 if let Some(metadata) = self.buffer_metadata.get(buffer_id) {
2546 return metadata.file_path() == Some(&path);
2547 }
2548 }
2549 false
2550 })
2551 };
2552
2553 let mut lines: Vec<String> = Vec::new();
2555
2556 if let Some(decoration) = &decoration {
2557 let symbol = &decoration.symbol;
2558 let explanation = match symbol.as_str() {
2559 "U" => "Untracked - File is not tracked by git",
2560 "M" => "Modified - File has unstaged changes",
2561 "A" => "Added - File is staged for commit",
2562 "D" => "Deleted - File is staged for deletion",
2563 "R" => "Renamed - File has been renamed",
2564 "C" => "Copied - File has been copied",
2565 "!" => "Conflicted - File has merge conflicts",
2566 "●" => "Has changes - Contains modified files",
2567 _ => "Unknown status",
2568 };
2569 lines.push(format!("{} - {}", symbol, explanation));
2570 } else if bubbled_decoration.is_some() {
2571 lines.push("● - Contains modified files".to_string());
2572 } else if has_unsaved_changes {
2573 if is_directory {
2574 lines.push("● - Contains unsaved changes".to_string());
2575 } else {
2576 lines.push("● - Unsaved changes in editor".to_string());
2577 }
2578 } else {
2579 return; }
2581
2582 if is_directory {
2584 if let Some(modified_files) = self.get_modified_files_in_directory(&path) {
2586 lines.push(String::new()); lines.push("Modified files:".to_string());
2588 let resolved_path = path.canonicalize().unwrap_or_else(|_| path.clone());
2590 const MAX_FILES: usize = 8;
2591 for (i, file) in modified_files.iter().take(MAX_FILES).enumerate() {
2592 let display_name = file
2594 .strip_prefix(&resolved_path)
2595 .unwrap_or(file)
2596 .to_string_lossy()
2597 .to_string();
2598 lines.push(format!(" {}", display_name));
2599 if i == MAX_FILES - 1 && modified_files.len() > MAX_FILES {
2600 lines.push(format!(
2601 " ... and {} more",
2602 modified_files.len() - MAX_FILES
2603 ));
2604 break;
2605 }
2606 }
2607 }
2608 } else {
2609 if let Some(stats) = self.get_git_diff_stats(&path) {
2611 lines.push(String::new()); lines.push(stats);
2613 }
2614 }
2615
2616 if lines.is_empty() {
2617 return;
2618 }
2619
2620 let mut popup = Popup::text(lines, &self.theme);
2622 popup.title = Some("Git Status".to_string());
2623 popup.transient = true;
2624 popup.position = PopupPosition::Fixed { x: col, y: row + 1 };
2625 popup.width = 50;
2626 popup.max_height = 15;
2627 popup.border_style = Style::default().fg(self.theme.popup_border_fg);
2628 popup.background_style = Style::default().bg(self.theme.popup_bg);
2629
2630 if let Some(state) = self.buffers.get_mut(&self.active_buffer()) {
2632 state.popups.show(popup);
2633 }
2634 }
2635
2636 fn dismiss_file_explorer_status_tooltip(&mut self) {
2638 if let Some(state) = self.buffers.get_mut(&self.active_buffer()) {
2640 state.popups.dismiss_transient();
2641 }
2642 }
2643
2644 fn get_git_diff_stats(&self, path: &std::path::Path) -> Option<String> {
2646 use std::process::Command;
2647
2648 let output = Command::new("git")
2650 .args(["diff", "--numstat", "--"])
2651 .arg(path)
2652 .current_dir(&self.working_dir)
2653 .output()
2654 .ok()?;
2655
2656 if !output.status.success() {
2657 return None;
2658 }
2659
2660 let stdout = String::from_utf8_lossy(&output.stdout);
2661 let line = stdout.lines().next()?;
2662 let parts: Vec<&str> = line.split('\t').collect();
2663
2664 if parts.len() >= 2 {
2665 let insertions = parts[0];
2666 let deletions = parts[1];
2667
2668 if insertions == "-" && deletions == "-" {
2670 return Some("Binary file changed".to_string());
2671 }
2672
2673 let ins: i32 = insertions.parse().unwrap_or(0);
2674 let del: i32 = deletions.parse().unwrap_or(0);
2675
2676 if ins > 0 || del > 0 {
2677 return Some(format!("+{} -{} lines", ins, del));
2678 }
2679 }
2680
2681 let staged_output = Command::new("git")
2683 .args(["diff", "--numstat", "--cached", "--"])
2684 .arg(path)
2685 .current_dir(&self.working_dir)
2686 .output()
2687 .ok()?;
2688
2689 if staged_output.status.success() {
2690 let staged_stdout = String::from_utf8_lossy(&staged_output.stdout);
2691 if let Some(line) = staged_stdout.lines().next() {
2692 let parts: Vec<&str> = line.split('\t').collect();
2693 if parts.len() >= 2 {
2694 let insertions = parts[0];
2695 let deletions = parts[1];
2696
2697 if insertions == "-" && deletions == "-" {
2698 return Some("Binary file staged".to_string());
2699 }
2700
2701 let ins: i32 = insertions.parse().unwrap_or(0);
2702 let del: i32 = deletions.parse().unwrap_or(0);
2703
2704 if ins > 0 || del > 0 {
2705 return Some(format!("+{} -{} lines (staged)", ins, del));
2706 }
2707 }
2708 }
2709 }
2710
2711 None
2712 }
2713
2714 fn get_modified_files_in_directory(
2716 &self,
2717 dir_path: &std::path::Path,
2718 ) -> Option<Vec<std::path::PathBuf>> {
2719 use std::process::Command;
2720
2721 let resolved_path = dir_path
2723 .canonicalize()
2724 .unwrap_or_else(|_| dir_path.to_path_buf());
2725
2726 let output = Command::new("git")
2728 .args(["status", "--porcelain", "--"])
2729 .arg(&resolved_path)
2730 .current_dir(&self.working_dir)
2731 .output()
2732 .ok()?;
2733
2734 if !output.status.success() {
2735 return None;
2736 }
2737
2738 let stdout = String::from_utf8_lossy(&output.stdout);
2739 let modified_files: Vec<std::path::PathBuf> = stdout
2740 .lines()
2741 .filter_map(|line| {
2742 if line.len() > 3 {
2745 let file_part = &line[3..];
2746 let file_name = if file_part.contains(" -> ") {
2748 file_part.split(" -> ").last().unwrap_or(file_part)
2749 } else {
2750 file_part
2751 };
2752 Some(self.working_dir.join(file_name))
2753 } else {
2754 None
2755 }
2756 })
2757 .collect();
2758
2759 if modified_files.is_empty() {
2760 None
2761 } else {
2762 Some(modified_files)
2763 }
2764 }
2765}