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.dragging_popup_scrollbar = None;
219 self.mouse_state.drag_start_popup_scroll = None;
220 self.mouse_state.selecting_in_popup = None;
222
223 if was_dragging_separator {
225 self.resize_visible_terminals();
226 }
227
228 needs_render = true;
229 }
230 MouseEventKind::Moved => {
231 {
233 let content_rect = self
235 .cached_layout
236 .split_areas
237 .iter()
238 .find(|(_, _, content_rect, _, _, _)| {
239 col >= content_rect.x
240 && col < content_rect.x + content_rect.width
241 && row >= content_rect.y
242 && row < content_rect.y + content_rect.height
243 })
244 .map(|(_, _, rect, _, _, _)| *rect);
245
246 let (content_x, content_y) = content_rect.map(|r| (r.x, r.y)).unwrap_or((0, 0));
247
248 self.plugin_manager.run_hook(
249 "mouse_move",
250 HookArgs::MouseMove {
251 column: col,
252 row,
253 content_x,
254 content_y,
255 },
256 );
257 }
258
259 let hover_changed = self.update_hover_target(col, row);
262 needs_render = needs_render || hover_changed;
263
264 if let Some((popup_rect, button_row_offset)) = self.theme_info_popup_rect() {
266 let button_row = popup_rect.y + button_row_offset;
267 let new_highlighted = row == button_row
268 && col >= popup_rect.x
269 && col < popup_rect.x + popup_rect.width;
270 if let Some(ref mut popup) = self.theme_info_popup {
271 if popup.button_highlighted != new_highlighted {
272 popup.button_highlighted = new_highlighted;
273 needs_render = true;
274 }
275 }
276 }
277
278 self.update_lsp_hover_state(col, row);
280 }
281 MouseEventKind::ScrollUp => {
282 if mouse_event
284 .modifiers
285 .contains(crossterm::event::KeyModifiers::SHIFT)
286 {
287 self.handle_horizontal_scroll(col, row, -3)?;
288 needs_render = true;
289 } else if self.handle_prompt_scroll(-3) {
290 needs_render = true;
292 } else if self.is_file_open_active()
293 && self.is_mouse_over_file_browser(col, row)
294 && self.handle_file_open_scroll(-3)
295 {
296 needs_render = true;
298 } else if self.is_mouse_over_any_popup(col, row) {
299 self.scroll_popup(-3);
301 needs_render = true;
302 } else {
303 if self.terminal_mode && self.is_terminal_buffer(self.active_buffer()) {
305 self.sync_terminal_to_buffer(self.active_buffer());
306 self.terminal_mode = false;
307 self.key_context = crate::input::keybindings::KeyContext::Normal;
308 }
309 self.dismiss_transient_popups();
311 self.handle_mouse_scroll(col, row, -3)?;
312 needs_render = true;
314 }
315 }
316 MouseEventKind::ScrollDown => {
317 if mouse_event
319 .modifiers
320 .contains(crossterm::event::KeyModifiers::SHIFT)
321 {
322 self.handle_horizontal_scroll(col, row, 3)?;
323 needs_render = true;
324 } else if self.handle_prompt_scroll(3) {
325 needs_render = true;
327 } else if self.is_file_open_active()
328 && self.is_mouse_over_file_browser(col, row)
329 && self.handle_file_open_scroll(3)
330 {
331 needs_render = true;
332 } else if self.is_mouse_over_any_popup(col, row) {
333 self.scroll_popup(3);
335 needs_render = true;
336 } else {
337 if self.terminal_mode && self.is_terminal_buffer(self.active_buffer()) {
339 self.sync_terminal_to_buffer(self.active_buffer());
340 self.terminal_mode = false;
341 self.key_context = crate::input::keybindings::KeyContext::Normal;
342 }
343 self.dismiss_transient_popups();
345 self.handle_mouse_scroll(col, row, 3)?;
346 needs_render = true;
348 }
349 }
350 MouseEventKind::ScrollLeft => {
351 self.handle_horizontal_scroll(col, row, -3)?;
353 needs_render = true;
354 }
355 MouseEventKind::ScrollRight => {
356 self.handle_horizontal_scroll(col, row, 3)?;
358 needs_render = true;
359 }
360 MouseEventKind::Down(MouseButton::Right) => {
361 if mouse_event
362 .modifiers
363 .contains(crossterm::event::KeyModifiers::CONTROL)
364 {
365 self.show_theme_info_popup(col, row)?;
367 } else {
368 self.handle_right_click(col, row)?;
370 }
371 needs_render = true;
372 }
373 _ => {
374 }
376 }
377
378 self.mouse_state.last_position = Some((col, row));
379 Ok(needs_render)
380 }
381
382 pub(super) fn update_hover_target(&mut self, col: u16, row: u16) -> bool {
385 let old_target = self.mouse_state.hover_target.clone();
386 let new_target = self.compute_hover_target(col, row);
387 let changed = old_target != new_target;
388 self.mouse_state.hover_target = new_target.clone();
389
390 if let Some(active_menu_idx) = self.menu_state.active_menu {
393 if let Some(HoverTarget::MenuBarItem(hovered_menu_idx)) = new_target.clone() {
394 if hovered_menu_idx != active_menu_idx {
395 self.menu_state.open_menu(hovered_menu_idx);
396 return true; }
398 }
399
400 if let Some(HoverTarget::MenuDropdownItem(_, item_idx)) = new_target.clone() {
402 let all_menus: Vec<crate::config::Menu> = self
403 .menus
404 .menus
405 .iter()
406 .chain(self.menu_state.plugin_menus.iter())
407 .cloned()
408 .collect();
409
410 if self.menu_state.submenu_path.first() == Some(&item_idx) {
413 tracing::trace!(
414 "menu hover: staying on submenu parent item_idx={}, submenu_path={:?}",
415 item_idx,
416 self.menu_state.submenu_path
417 );
418 return changed;
419 }
420
421 if !self.menu_state.submenu_path.is_empty() {
423 tracing::trace!(
424 "menu hover: clearing submenu_path={:?} for different item_idx={}",
425 self.menu_state.submenu_path,
426 item_idx
427 );
428 self.menu_state.submenu_path.clear();
429 self.menu_state.highlighted_item = Some(item_idx);
430 return true;
431 }
432
433 if let Some(menu) = all_menus.get(active_menu_idx) {
435 if let Some(crate::config::MenuItem::Submenu { items, .. }) =
436 menu.items.get(item_idx)
437 {
438 if !items.is_empty() {
439 tracing::trace!("menu hover: opening submenu at item_idx={}", item_idx);
440 self.menu_state.submenu_path.push(item_idx);
441 self.menu_state.highlighted_item = Some(0);
442 return true;
443 }
444 }
445 }
446 if self.menu_state.highlighted_item != Some(item_idx) {
448 self.menu_state.highlighted_item = Some(item_idx);
449 return true;
450 }
451 }
452
453 if let Some(HoverTarget::SubmenuItem(depth, item_idx)) = new_target {
455 if self.menu_state.submenu_path.len() > depth
459 && self.menu_state.submenu_path.get(depth) == Some(&item_idx)
460 {
461 tracing::trace!(
462 "menu hover: staying on nested submenu parent depth={}, item_idx={}, submenu_path={:?}",
463 depth,
464 item_idx,
465 self.menu_state.submenu_path
466 );
467 return changed;
468 }
469
470 if self.menu_state.submenu_path.len() > depth {
472 tracing::trace!(
473 "menu hover: truncating submenu_path={:?} to depth={} for item_idx={}",
474 self.menu_state.submenu_path,
475 depth,
476 item_idx
477 );
478 self.menu_state.submenu_path.truncate(depth);
479 }
480
481 let all_menus: Vec<crate::config::Menu> = self
482 .menus
483 .menus
484 .iter()
485 .chain(self.menu_state.plugin_menus.iter())
486 .cloned()
487 .collect();
488
489 if let Some(items) = self
491 .menu_state
492 .get_current_items(&all_menus, active_menu_idx)
493 {
494 if let Some(crate::config::MenuItem::Submenu {
496 items: sub_items, ..
497 }) = items.get(item_idx)
498 {
499 if !sub_items.is_empty()
500 && !self.menu_state.submenu_path.contains(&item_idx)
501 {
502 tracing::trace!(
503 "menu hover: opening nested submenu at depth={}, item_idx={}",
504 depth,
505 item_idx
506 );
507 self.menu_state.submenu_path.push(item_idx);
508 self.menu_state.highlighted_item = Some(0);
509 return true;
510 }
511 }
512 if self.menu_state.highlighted_item != Some(item_idx) {
514 self.menu_state.highlighted_item = Some(item_idx);
515 return true;
516 }
517 }
518 }
519 }
520
521 if let Some(HoverTarget::TabContextMenuItem(item_idx)) = new_target.clone() {
523 if let Some(ref mut menu) = self.tab_context_menu {
524 if menu.highlighted != item_idx {
525 menu.highlighted = item_idx;
526 return true;
527 }
528 }
529 }
530
531 if old_target != new_target
534 && matches!(
535 old_target,
536 Some(HoverTarget::FileExplorerStatusIndicator(_))
537 )
538 {
539 self.dismiss_file_explorer_status_tooltip();
540 }
541
542 if let Some(HoverTarget::FileExplorerStatusIndicator(ref path)) = new_target {
543 if old_target != new_target {
545 self.show_file_explorer_status_tooltip(path.clone(), col, row);
546 return true;
547 }
548 }
549
550 changed
551 }
552
553 fn update_lsp_hover_state(&mut self, col: u16, row: u16) {
562 tracing::trace!(col, row, "update_lsp_hover_state: raw mouse position");
563
564 if self.is_mouse_over_transient_popup(col, row) {
566 return;
567 }
568
569 let split_info = self
571 .cached_layout
572 .split_areas
573 .iter()
574 .find(|(_, _, content_rect, _, _, _)| {
575 col >= content_rect.x
576 && col < content_rect.x + content_rect.width
577 && row >= content_rect.y
578 && row < content_rect.y + content_rect.height
579 })
580 .map(|(split_id, buffer_id, content_rect, _, _, _)| {
581 (*split_id, *buffer_id, *content_rect)
582 });
583
584 let Some((split_id, buffer_id, content_rect)) = split_info else {
585 if self.mouse_state.lsp_hover_state.is_some() {
587 self.mouse_state.lsp_hover_state = None;
588 self.mouse_state.lsp_hover_request_sent = false;
589 self.dismiss_transient_popups();
590 }
591 return;
592 };
593
594 let cached_mappings = self
596 .cached_layout
597 .view_line_mappings
598 .get(&split_id)
599 .cloned();
600 let gutter_width = self
601 .buffers
602 .get(&buffer_id)
603 .map(|s| s.margins.left_total_width() as u16)
604 .unwrap_or(0);
605 let fallback = self
606 .buffers
607 .get(&buffer_id)
608 .map(|s| s.buffer.len())
609 .unwrap_or(0);
610
611 let compose_width = self
613 .split_view_states
614 .get(&split_id)
615 .and_then(|vs| vs.compose_width);
616
617 let Some(byte_pos) = Self::screen_to_buffer_position(
619 col,
620 row,
621 content_rect,
622 gutter_width,
623 &cached_mappings,
624 fallback,
625 false, compose_width,
627 ) else {
628 if self.mouse_state.lsp_hover_state.is_some() {
630 self.mouse_state.lsp_hover_state = None;
631 self.mouse_state.lsp_hover_request_sent = false;
632 self.dismiss_transient_popups();
633 }
634 return;
635 };
636
637 let content_col = col.saturating_sub(content_rect.x);
639 let text_col = content_col.saturating_sub(gutter_width) as usize;
640 let visual_row = row.saturating_sub(content_rect.y) as usize;
641
642 let line_info = cached_mappings
643 .as_ref()
644 .and_then(|mappings| mappings.get(visual_row))
645 .map(|line_mapping| {
646 (
647 line_mapping.visual_to_char.len(),
648 line_mapping.line_end_byte,
649 )
650 });
651
652 let is_past_line_end_or_empty = line_info
653 .map(|(line_len, _)| {
654 if line_len <= 1 {
656 return true;
657 }
658 text_col >= line_len
659 })
660 .unwrap_or(true);
662
663 tracing::trace!(
664 col,
665 row,
666 content_col,
667 text_col,
668 visual_row,
669 gutter_width,
670 byte_pos,
671 ?line_info,
672 is_past_line_end_or_empty,
673 "update_lsp_hover_state: position check"
674 );
675
676 if is_past_line_end_or_empty {
677 tracing::trace!(
678 "update_lsp_hover_state: mouse past line end or empty line, clearing hover"
679 );
680 if self.mouse_state.lsp_hover_state.is_some() {
682 self.mouse_state.lsp_hover_state = None;
683 self.mouse_state.lsp_hover_request_sent = false;
684 self.dismiss_transient_popups();
685 }
686 return;
687 }
688
689 if let Some((start, end)) = self.hover_symbol_range {
691 if byte_pos >= start && byte_pos < end {
692 return;
694 }
695 }
696
697 if let Some((old_pos, _, _, _)) = self.mouse_state.lsp_hover_state {
699 if old_pos == byte_pos {
700 return;
702 }
703 self.dismiss_transient_popups();
705 }
706
707 self.mouse_state.lsp_hover_state = Some((byte_pos, std::time::Instant::now(), col, row));
709 self.mouse_state.lsp_hover_request_sent = false;
710 }
711
712 fn is_mouse_over_transient_popup(&self, col: u16, row: u16) -> bool {
714 let layouts = popup_areas_to_layout_info(&self.cached_layout.popup_areas);
715 let hit_tester = PopupHitTester::new(&layouts, &self.active_state().popups);
716 hit_tester.is_over_transient_popup(col, row)
717 }
718
719 fn is_mouse_over_any_popup(&self, col: u16, row: u16) -> bool {
721 let layouts = popup_areas_to_layout_info(&self.cached_layout.popup_areas);
722 let hit_tester = PopupHitTester::new(&layouts, &self.active_state().popups);
723 hit_tester.is_over_popup(col, row)
724 }
725
726 fn is_mouse_over_file_browser(&self, col: u16, row: u16) -> bool {
728 self.file_browser_layout
729 .as_ref()
730 .is_some_and(|layout| layout.contains(col, row))
731 }
732
733 fn compute_hover_target(&self, col: u16, row: u16) -> Option<HoverTarget> {
735 if let Some(ref menu) = self.tab_context_menu {
737 let menu_x = menu.position.0;
738 let menu_y = menu.position.1;
739 let menu_width = 22u16;
740 let items = super::types::TabContextMenuItem::all();
741 let menu_height = items.len() as u16 + 2;
742
743 if col >= menu_x
744 && col < menu_x + menu_width
745 && row > menu_y
746 && row < menu_y + menu_height - 1
747 {
748 let item_idx = (row - menu_y - 1) as usize;
749 if item_idx < items.len() {
750 return Some(HoverTarget::TabContextMenuItem(item_idx));
751 }
752 }
753 }
754
755 if let Some((inner_rect, start_idx, _visible_count, total_count)) =
757 &self.cached_layout.suggestions_area
758 {
759 if col >= inner_rect.x
760 && col < inner_rect.x + inner_rect.width
761 && row >= inner_rect.y
762 && row < inner_rect.y + inner_rect.height
763 {
764 let relative_row = (row - inner_rect.y) as usize;
765 let item_idx = start_idx + relative_row;
766
767 if item_idx < *total_count {
768 return Some(HoverTarget::SuggestionItem(item_idx));
769 }
770 }
771 }
772
773 for (popup_idx, _popup_rect, inner_rect, scroll_offset, num_items, _, _) in
776 self.cached_layout.popup_areas.iter().rev()
777 {
778 if col >= inner_rect.x
779 && col < inner_rect.x + inner_rect.width
780 && row >= inner_rect.y
781 && row < inner_rect.y + inner_rect.height
782 && *num_items > 0
783 {
784 let relative_row = (row - inner_rect.y) as usize;
786 let item_idx = scroll_offset + relative_row;
787
788 if item_idx < *num_items {
789 return Some(HoverTarget::PopupListItem(*popup_idx, item_idx));
790 }
791 }
792 }
793
794 if self.is_file_open_active() {
796 if let Some(hover) = self.compute_file_browser_hover(col, row) {
797 return Some(hover);
798 }
799 }
800
801 if self.menu_bar_visible {
804 if let Some(ref menu_layout) = self.cached_layout.menu_layout {
805 if let Some(menu_idx) = menu_layout.menu_at(col, row) {
806 return Some(HoverTarget::MenuBarItem(menu_idx));
807 }
808 }
809 }
810
811 if let Some(active_idx) = self.menu_state.active_menu {
813 if let Some(hover) = self.compute_menu_dropdown_hover(col, row, active_idx) {
814 return Some(hover);
815 }
816 }
817
818 if let Some(explorer_area) = self.cached_layout.file_explorer_area {
820 let close_button_x = explorer_area.x + explorer_area.width.saturating_sub(3);
822 if row == explorer_area.y
823 && col >= close_button_x
824 && col < explorer_area.x + explorer_area.width
825 {
826 return Some(HoverTarget::FileExplorerCloseButton);
827 }
828
829 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
836 && row < content_end_y
837 && col >= status_indicator_x
838 && col < explorer_area.x + explorer_area.width.saturating_sub(1)
839 {
840 if let Some(ref explorer) = self.file_explorer {
842 let relative_row = row.saturating_sub(content_start_y) as usize;
843 let scroll_offset = explorer.get_scroll_offset();
844 let item_index = relative_row + scroll_offset;
845 let display_nodes = explorer.get_display_nodes();
846
847 if item_index < display_nodes.len() {
848 let (node_id, _indent) = display_nodes[item_index];
849 if let Some(node) = explorer.tree().get_node(node_id) {
850 return Some(HoverTarget::FileExplorerStatusIndicator(
851 node.entry.path.clone(),
852 ));
853 }
854 }
855 }
856 }
857
858 let border_x = explorer_area.x + explorer_area.width.saturating_sub(1);
861 if col == border_x
862 && row >= explorer_area.y
863 && row < explorer_area.y + explorer_area.height
864 {
865 return Some(HoverTarget::FileExplorerBorder);
866 }
867 }
868
869 for (split_id, direction, sep_x, sep_y, sep_length) in &self.cached_layout.separator_areas {
871 let is_on_separator = match direction {
872 SplitDirection::Horizontal => {
873 row == *sep_y && col >= *sep_x && col < sep_x + sep_length
874 }
875 SplitDirection::Vertical => {
876 col == *sep_x && row >= *sep_y && row < sep_y + sep_length
877 }
878 };
879
880 if is_on_separator {
881 return Some(HoverTarget::SplitSeparator(*split_id, *direction));
882 }
883 }
884
885 for (split_id, btn_row, start_col, end_col) in &self.cached_layout.close_split_areas {
888 if row == *btn_row && col >= *start_col && col < *end_col {
889 return Some(HoverTarget::CloseSplitButton(*split_id));
890 }
891 }
892
893 for (split_id, btn_row, start_col, end_col) in &self.cached_layout.maximize_split_areas {
894 if row == *btn_row && col >= *start_col && col < *end_col {
895 return Some(HoverTarget::MaximizeSplitButton(*split_id));
896 }
897 }
898
899 for (split_id, tab_layout) in &self.cached_layout.tab_layouts {
900 match tab_layout.hit_test(col, row) {
901 Some(TabHit::CloseButton(buffer_id)) => {
902 return Some(HoverTarget::TabCloseButton(buffer_id, *split_id));
903 }
904 Some(TabHit::TabName(buffer_id)) => {
905 return Some(HoverTarget::TabName(buffer_id, *split_id));
906 }
907 Some(TabHit::ScrollLeft)
908 | Some(TabHit::ScrollRight)
909 | Some(TabHit::BarBackground)
910 | None => {}
911 }
912 }
913
914 for (split_id, _buffer_id, _content_rect, scrollbar_rect, thumb_start, thumb_end) in
916 &self.cached_layout.split_areas
917 {
918 if col >= scrollbar_rect.x
919 && col < scrollbar_rect.x + scrollbar_rect.width
920 && row >= scrollbar_rect.y
921 && row < scrollbar_rect.y + scrollbar_rect.height
922 {
923 let relative_row = row.saturating_sub(scrollbar_rect.y) as usize;
924 let is_on_thumb = relative_row >= *thumb_start && relative_row < *thumb_end;
925
926 if is_on_thumb {
927 return Some(HoverTarget::ScrollbarThumb(*split_id));
928 } else {
929 return Some(HoverTarget::ScrollbarTrack(*split_id));
930 }
931 }
932 }
933
934 if let Some((status_row, _status_x, _status_width)) = self.cached_layout.status_bar_area {
936 if row == status_row {
937 if let Some((le_row, le_start, le_end)) =
939 self.cached_layout.status_bar_line_ending_area
940 {
941 if row == le_row && col >= le_start && col < le_end {
942 return Some(HoverTarget::StatusBarLineEndingIndicator);
943 }
944 }
945
946 if let Some((enc_row, enc_start, enc_end)) =
948 self.cached_layout.status_bar_encoding_area
949 {
950 if row == enc_row && col >= enc_start && col < enc_end {
951 return Some(HoverTarget::StatusBarEncodingIndicator);
952 }
953 }
954
955 if let Some((lang_row, lang_start, lang_end)) =
957 self.cached_layout.status_bar_language_area
958 {
959 if row == lang_row && col >= lang_start && col < lang_end {
960 return Some(HoverTarget::StatusBarLanguageIndicator);
961 }
962 }
963
964 if let Some((lsp_row, lsp_start, lsp_end)) = self.cached_layout.status_bar_lsp_area
966 {
967 if row == lsp_row && col >= lsp_start && col < lsp_end {
968 return Some(HoverTarget::StatusBarLspIndicator);
969 }
970 }
971
972 if let Some((warn_row, warn_start, warn_end)) =
974 self.cached_layout.status_bar_warning_area
975 {
976 if row == warn_row && col >= warn_start && col < warn_end {
977 return Some(HoverTarget::StatusBarWarningBadge);
978 }
979 }
980 }
981 }
982
983 if let Some(ref layout) = self.cached_layout.search_options_layout {
985 use crate::view::ui::status_bar::SearchOptionsHover;
986 if let Some(hover) = layout.checkbox_at(col, row) {
987 return Some(match hover {
988 SearchOptionsHover::CaseSensitive => HoverTarget::SearchOptionCaseSensitive,
989 SearchOptionsHover::WholeWord => HoverTarget::SearchOptionWholeWord,
990 SearchOptionsHover::Regex => HoverTarget::SearchOptionRegex,
991 SearchOptionsHover::ConfirmEach => HoverTarget::SearchOptionConfirmEach,
992 SearchOptionsHover::None => return None,
993 });
994 }
995 }
996
997 None
999 }
1000
1001 pub(super) fn handle_mouse_double_click(&mut self, col: u16, row: u16) -> AnyhowResult<()> {
1004 tracing::debug!("handle_mouse_double_click at col={}, row={}", col, row);
1005
1006 if self.is_mouse_over_any_popup(col, row) {
1008 return Ok(());
1010 } else {
1011 self.dismiss_transient_popups();
1013 }
1014
1015 if self.handle_file_open_double_click(col, row) {
1017 return Ok(());
1018 }
1019
1020 if let Some(explorer_area) = self.cached_layout.file_explorer_area {
1022 if col >= explorer_area.x
1023 && col < explorer_area.x + explorer_area.width
1024 && row > explorer_area.y && row < explorer_area.y + explorer_area.height
1026 {
1027 self.file_explorer_open_file()?;
1029 return Ok(());
1030 }
1031 }
1032
1033 let split_areas = self.cached_layout.split_areas.clone();
1035 for (split_id, buffer_id, content_rect, _scrollbar_rect, _thumb_start, _thumb_end) in
1036 &split_areas
1037 {
1038 if col >= content_rect.x
1039 && col < content_rect.x + content_rect.width
1040 && row >= content_rect.y
1041 && row < content_rect.y + content_rect.height
1042 {
1043 if self.is_terminal_buffer(*buffer_id) {
1045 self.key_context = crate::input::keybindings::KeyContext::Terminal;
1046 return Ok(());
1048 }
1049
1050 self.key_context = crate::input::keybindings::KeyContext::Normal;
1051
1052 self.handle_editor_double_click(col, row, *split_id, *buffer_id, *content_rect)?;
1054 return Ok(());
1055 }
1056 }
1057
1058 Ok(())
1059 }
1060
1061 fn handle_editor_double_click(
1063 &mut self,
1064 col: u16,
1065 row: u16,
1066 split_id: LeafId,
1067 buffer_id: BufferId,
1068 content_rect: ratatui::layout::Rect,
1069 ) -> AnyhowResult<()> {
1070 use crate::model::event::Event;
1071
1072 self.focus_split(split_id, buffer_id);
1074
1075 let cached_mappings = self
1077 .cached_layout
1078 .view_line_mappings
1079 .get(&split_id)
1080 .cloned();
1081
1082 let leaf_id = split_id;
1084 let fallback = self
1085 .split_view_states
1086 .get(&leaf_id)
1087 .map(|vs| vs.viewport.top_byte)
1088 .unwrap_or(0);
1089
1090 let compose_width = self
1092 .split_view_states
1093 .get(&leaf_id)
1094 .and_then(|vs| vs.compose_width);
1095
1096 if let Some(state) = self.buffers.get_mut(&buffer_id) {
1098 let gutter_width = state.margins.left_total_width() as u16;
1099
1100 let Some(target_position) = Self::screen_to_buffer_position(
1101 col,
1102 row,
1103 content_rect,
1104 gutter_width,
1105 &cached_mappings,
1106 fallback,
1107 true, compose_width,
1109 ) else {
1110 return Ok(());
1111 };
1112
1113 let primary_cursor_id = self
1115 .split_view_states
1116 .get(&leaf_id)
1117 .map(|vs| vs.cursors.primary_id())
1118 .unwrap_or(CursorId(0));
1119 let event = Event::MoveCursor {
1120 cursor_id: primary_cursor_id,
1121 old_position: 0,
1122 new_position: target_position,
1123 old_anchor: None,
1124 new_anchor: None,
1125 old_sticky_column: 0,
1126 new_sticky_column: 0,
1127 };
1128
1129 if let Some(event_log) = self.event_logs.get_mut(&buffer_id) {
1130 event_log.append(event.clone());
1131 }
1132 if let Some(cursors) = self
1133 .split_view_states
1134 .get_mut(&leaf_id)
1135 .map(|vs| &mut vs.cursors)
1136 {
1137 state.apply(cursors, &event);
1138 }
1139 }
1140
1141 self.handle_action(Action::SelectWord)?;
1143
1144 Ok(())
1145 }
1146 pub(super) fn handle_mouse_triple_click(&mut self, col: u16, row: u16) -> AnyhowResult<()> {
1149 tracing::debug!("handle_mouse_triple_click at col={}, row={}", col, row);
1150
1151 if self.is_mouse_over_any_popup(col, row) {
1153 return Ok(());
1154 } else {
1155 self.dismiss_transient_popups();
1156 }
1157
1158 let split_areas = self.cached_layout.split_areas.clone();
1160 for (split_id, buffer_id, content_rect, _scrollbar_rect, _thumb_start, _thumb_end) in
1161 &split_areas
1162 {
1163 if col >= content_rect.x
1164 && col < content_rect.x + content_rect.width
1165 && row >= content_rect.y
1166 && row < content_rect.y + content_rect.height
1167 {
1168 if self.is_terminal_buffer(*buffer_id) {
1169 return Ok(());
1170 }
1171
1172 self.key_context = crate::input::keybindings::KeyContext::Normal;
1173
1174 self.handle_editor_triple_click(col, row, *split_id, *buffer_id, *content_rect)?;
1177 return Ok(());
1178 }
1179 }
1180
1181 Ok(())
1182 }
1183
1184 fn handle_editor_triple_click(
1186 &mut self,
1187 col: u16,
1188 row: u16,
1189 split_id: LeafId,
1190 buffer_id: BufferId,
1191 content_rect: ratatui::layout::Rect,
1192 ) -> AnyhowResult<()> {
1193 use crate::model::event::Event;
1194
1195 self.focus_split(split_id, buffer_id);
1197
1198 let cached_mappings = self
1200 .cached_layout
1201 .view_line_mappings
1202 .get(&split_id)
1203 .cloned();
1204
1205 let leaf_id = split_id;
1206 let fallback = self
1207 .split_view_states
1208 .get(&leaf_id)
1209 .map(|vs| vs.viewport.top_byte)
1210 .unwrap_or(0);
1211
1212 let compose_width = self
1214 .split_view_states
1215 .get(&leaf_id)
1216 .and_then(|vs| vs.compose_width);
1217
1218 if let Some(state) = self.buffers.get_mut(&buffer_id) {
1220 let gutter_width = state.margins.left_total_width() as u16;
1221
1222 let Some(target_position) = Self::screen_to_buffer_position(
1223 col,
1224 row,
1225 content_rect,
1226 gutter_width,
1227 &cached_mappings,
1228 fallback,
1229 true,
1230 compose_width,
1231 ) else {
1232 return Ok(());
1233 };
1234
1235 let primary_cursor_id = self
1237 .split_view_states
1238 .get(&leaf_id)
1239 .map(|vs| vs.cursors.primary_id())
1240 .unwrap_or(CursorId(0));
1241 let event = Event::MoveCursor {
1242 cursor_id: primary_cursor_id,
1243 old_position: 0,
1244 new_position: target_position,
1245 old_anchor: None,
1246 new_anchor: None,
1247 old_sticky_column: 0,
1248 new_sticky_column: 0,
1249 };
1250
1251 if let Some(event_log) = self.event_logs.get_mut(&buffer_id) {
1252 event_log.append(event.clone());
1253 }
1254 if let Some(cursors) = self
1255 .split_view_states
1256 .get_mut(&leaf_id)
1257 .map(|vs| &mut vs.cursors)
1258 {
1259 state.apply(cursors, &event);
1260 }
1261 }
1262
1263 self.handle_action(Action::SelectLine)?;
1265
1266 Ok(())
1267 }
1268
1269 pub(super) fn handle_mouse_click(
1271 &mut self,
1272 col: u16,
1273 row: u16,
1274 modifiers: crossterm::event::KeyModifiers,
1275 ) -> AnyhowResult<()> {
1276 if self.tab_context_menu.is_some() {
1278 if let Some(result) = self.handle_tab_context_menu_click(col, row) {
1279 return result;
1280 }
1281 }
1282
1283 if !self.is_mouse_over_any_popup(col, row) {
1286 self.dismiss_transient_popups();
1287 }
1288
1289 if let Some((inner_rect, start_idx, _visible_count, total_count)) =
1291 &self.cached_layout.suggestions_area.clone()
1292 {
1293 if col >= inner_rect.x
1294 && col < inner_rect.x + inner_rect.width
1295 && row >= inner_rect.y
1296 && row < inner_rect.y + inner_rect.height
1297 {
1298 let relative_row = (row - inner_rect.y) as usize;
1299 let item_idx = start_idx + relative_row;
1300
1301 if item_idx < *total_count {
1302 if let Some(prompt) = &mut self.prompt {
1304 prompt.selected_suggestion = Some(item_idx);
1305 }
1306 return self.handle_action(Action::PromptConfirm);
1308 }
1309 }
1310 }
1311
1312 let scrollbar_scroll_info: Option<(usize, i32)> =
1315 self.cached_layout.popup_areas.iter().rev().find_map(
1316 |(
1317 popup_idx,
1318 _popup_rect,
1319 inner_rect,
1320 _scroll_offset,
1321 _num_items,
1322 scrollbar_rect,
1323 total_lines,
1324 )| {
1325 let sb_rect = scrollbar_rect.as_ref()?;
1326 if col >= sb_rect.x
1327 && col < sb_rect.x + sb_rect.width
1328 && row >= sb_rect.y
1329 && row < sb_rect.y + sb_rect.height
1330 {
1331 let relative_row = (row - sb_rect.y) as usize;
1332 let track_height = sb_rect.height as usize;
1333 let visible_lines = inner_rect.height as usize;
1334
1335 if track_height > 0 && *total_lines > visible_lines {
1336 let max_scroll = total_lines.saturating_sub(visible_lines);
1337 let target_scroll = if track_height > 1 {
1338 (relative_row * max_scroll) / (track_height.saturating_sub(1))
1339 } else {
1340 0
1341 };
1342 Some((*popup_idx, target_scroll as i32))
1343 } else {
1344 Some((*popup_idx, 0))
1345 }
1346 } else {
1347 None
1348 }
1349 },
1350 );
1351
1352 if let Some((popup_idx, target_scroll)) = scrollbar_scroll_info {
1353 self.mouse_state.dragging_popup_scrollbar = Some(popup_idx);
1355 self.mouse_state.drag_start_row = Some(row);
1356 let current_scroll = self
1358 .active_state()
1359 .popups
1360 .get(popup_idx)
1361 .map(|p| p.scroll_offset)
1362 .unwrap_or(0);
1363 self.mouse_state.drag_start_popup_scroll = Some(current_scroll);
1364 let state = self.active_state_mut();
1366 if let Some(popup) = state.popups.get_mut(popup_idx) {
1367 let delta = target_scroll - current_scroll as i32;
1368 popup.scroll_by(delta);
1369 }
1370 return Ok(());
1371 }
1372
1373 for (popup_idx, _popup_rect, inner_rect, scroll_offset, num_items, _, _) in
1375 self.cached_layout.popup_areas.iter().rev()
1376 {
1377 if col >= inner_rect.x
1378 && col < inner_rect.x + inner_rect.width
1379 && row >= inner_rect.y
1380 && row < inner_rect.y + inner_rect.height
1381 {
1382 let relative_col = (col - inner_rect.x) as usize;
1384 let relative_row = (row - inner_rect.y) as usize;
1385
1386 let link_url = {
1388 let state = self.active_state();
1389 state
1390 .popups
1391 .top()
1392 .and_then(|popup| popup.link_at_position(relative_col, relative_row))
1393 };
1394
1395 if let Some(url) = link_url {
1396 #[cfg(feature = "runtime")]
1398 if let Err(e) = open::that(&url) {
1399 self.set_status_message(format!("Failed to open URL: {}", e));
1400 } else {
1401 self.set_status_message(format!("Opening: {}", url));
1402 }
1403 return Ok(());
1404 }
1405
1406 if *num_items > 0 {
1408 let item_idx = scroll_offset + relative_row;
1409
1410 if item_idx < *num_items {
1411 let state = self.active_state_mut();
1413 if let Some(popup) = state.popups.top_mut() {
1414 if let crate::view::popup::PopupContent::List { items: _, selected } =
1415 &mut popup.content
1416 {
1417 *selected = item_idx;
1418 }
1419 }
1420 return self.handle_action(Action::PopupConfirm);
1422 }
1423 }
1424
1425 let is_text_popup = {
1427 let state = self.active_state();
1428 state.popups.top().is_some_and(|p| {
1429 matches!(
1430 p.content,
1431 crate::view::popup::PopupContent::Text(_)
1432 | crate::view::popup::PopupContent::Markdown(_)
1433 )
1434 })
1435 };
1436
1437 if is_text_popup {
1438 let line = scroll_offset + relative_row;
1439 let popup_idx_copy = *popup_idx; let state = self.active_state_mut();
1441 if let Some(popup) = state.popups.top_mut() {
1442 popup.start_selection(line, relative_col);
1443 }
1444 self.mouse_state.selecting_in_popup = Some(popup_idx_copy);
1446 return Ok(());
1447 }
1448 }
1449 }
1450
1451 if self.is_mouse_over_any_popup(col, row) {
1454 return Ok(());
1455 }
1456
1457 if self.is_file_open_active() && self.handle_file_open_click(col, row) {
1459 return Ok(());
1460 }
1461
1462 if self.menu_bar_visible {
1464 if let Some(ref menu_layout) = self.cached_layout.menu_layout {
1465 if let Some(menu_idx) = menu_layout.menu_at(col, row) {
1466 if self.menu_state.active_menu == Some(menu_idx) {
1468 self.close_menu_with_auto_hide();
1469 } else {
1470 self.on_editor_focus_lost();
1472 self.menu_state.open_menu(menu_idx);
1473 }
1474 return Ok(());
1475 } else if row == 0 {
1476 self.close_menu_with_auto_hide();
1478 return Ok(());
1479 }
1480 }
1481 }
1482
1483 if let Some(active_idx) = self.menu_state.active_menu {
1485 let all_menus: Vec<crate::config::Menu> = self
1486 .menus
1487 .menus
1488 .iter()
1489 .chain(self.menu_state.plugin_menus.iter())
1490 .cloned()
1491 .collect();
1492
1493 if let Some(menu) = all_menus.get(active_idx) {
1494 if let Some(click_result) = self.handle_menu_dropdown_click(col, row, menu)? {
1496 return click_result;
1497 }
1498 }
1499
1500 self.close_menu_with_auto_hide();
1502 return Ok(());
1503 }
1504
1505 if let Some(explorer_area) = self.cached_layout.file_explorer_area {
1509 let border_x = explorer_area.x + explorer_area.width.saturating_sub(1);
1510 if col == border_x
1511 && row >= explorer_area.y
1512 && row < explorer_area.y + explorer_area.height
1513 {
1514 self.mouse_state.dragging_file_explorer = true;
1515 self.mouse_state.drag_start_position = Some((col, row));
1516 self.mouse_state.drag_start_explorer_width = Some(self.file_explorer_width_percent);
1517 return Ok(());
1518 }
1519 }
1520
1521 if let Some(explorer_area) = self.cached_layout.file_explorer_area {
1523 if col >= explorer_area.x
1524 && col < explorer_area.x + explorer_area.width
1525 && row >= explorer_area.y
1526 && row < explorer_area.y + explorer_area.height
1527 {
1528 self.handle_file_explorer_click(col, row, explorer_area)?;
1529 return Ok(());
1530 }
1531 }
1532
1533 let scrollbar_hit = self.cached_layout.split_areas.iter().find_map(
1535 |(split_id, buffer_id, _content_rect, scrollbar_rect, thumb_start, thumb_end)| {
1536 if col >= scrollbar_rect.x
1537 && col < scrollbar_rect.x + scrollbar_rect.width
1538 && row >= scrollbar_rect.y
1539 && row < scrollbar_rect.y + scrollbar_rect.height
1540 {
1541 let relative_row = row.saturating_sub(scrollbar_rect.y) as usize;
1542 let is_on_thumb = relative_row >= *thumb_start && relative_row < *thumb_end;
1543 Some((*split_id, *buffer_id, *scrollbar_rect, is_on_thumb))
1544 } else {
1545 None
1546 }
1547 },
1548 );
1549
1550 if let Some((split_id, buffer_id, scrollbar_rect, is_on_thumb)) = scrollbar_hit {
1551 self.focus_split(split_id, buffer_id);
1552
1553 if is_on_thumb {
1554 self.mouse_state.dragging_scrollbar = Some(split_id);
1556 self.mouse_state.drag_start_row = Some(row);
1557 if self.is_composite_buffer(buffer_id) {
1559 if let Some(view_state) = self.composite_view_states.get(&(split_id, buffer_id))
1561 {
1562 self.mouse_state.drag_start_composite_scroll_row =
1563 Some(view_state.scroll_row);
1564 }
1565 } else if let Some(view_state) = self.split_view_states.get(&split_id) {
1566 self.mouse_state.drag_start_top_byte = Some(view_state.viewport.top_byte);
1567 self.mouse_state.drag_start_view_line_offset =
1568 Some(view_state.viewport.top_view_line_offset);
1569 }
1570 } else {
1571 self.mouse_state.dragging_scrollbar = Some(split_id);
1573 self.handle_scrollbar_jump(col, row, split_id, buffer_id, scrollbar_rect)?;
1574 }
1575 return Ok(());
1576 }
1577
1578 let hscrollbar_hit = self
1580 .cached_layout
1581 .horizontal_scrollbar_areas
1582 .iter()
1583 .find_map(
1584 |(
1585 split_id,
1586 buffer_id,
1587 hscrollbar_rect,
1588 max_content_width,
1589 thumb_start,
1590 thumb_end,
1591 )| {
1592 if col >= hscrollbar_rect.x
1593 && col < hscrollbar_rect.x + hscrollbar_rect.width
1594 && row >= hscrollbar_rect.y
1595 && row < hscrollbar_rect.y + hscrollbar_rect.height
1596 {
1597 let relative_col = col.saturating_sub(hscrollbar_rect.x) as usize;
1598 let is_on_thumb = relative_col >= *thumb_start && relative_col < *thumb_end;
1599 Some((
1600 *split_id,
1601 *buffer_id,
1602 *hscrollbar_rect,
1603 *max_content_width,
1604 is_on_thumb,
1605 ))
1606 } else {
1607 None
1608 }
1609 },
1610 );
1611
1612 if let Some((split_id, buffer_id, hscrollbar_rect, max_content_width, is_on_thumb)) =
1613 hscrollbar_hit
1614 {
1615 self.focus_split(split_id, buffer_id);
1616 self.mouse_state.dragging_horizontal_scrollbar = Some(split_id);
1617
1618 if is_on_thumb {
1619 self.mouse_state.drag_start_hcol = Some(col);
1621 if let Some(view_state) = self.split_view_states.get(&split_id) {
1622 self.mouse_state.drag_start_left_column = Some(view_state.viewport.left_column);
1623 }
1624 } else {
1625 self.mouse_state.drag_start_hcol = None;
1627 self.mouse_state.drag_start_left_column = None;
1628
1629 let relative_col = col.saturating_sub(hscrollbar_rect.x) as f64;
1630 let track_width = hscrollbar_rect.width as f64;
1631 let ratio = if track_width > 1.0 {
1632 (relative_col / (track_width - 1.0)).clamp(0.0, 1.0)
1633 } else {
1634 0.0
1635 };
1636
1637 if let Some(view_state) = self.split_view_states.get_mut(&split_id) {
1638 let visible_width = view_state.viewport.width as usize;
1639 let max_scroll = max_content_width.saturating_sub(visible_width);
1640 let target_col = (ratio * max_scroll as f64).round() as usize;
1641 view_state.viewport.left_column = target_col.min(max_scroll);
1642 view_state.viewport.set_skip_ensure_visible();
1643 }
1644 }
1645
1646 return Ok(());
1647 }
1648
1649 if let Some((status_row, _status_x, _status_width)) = self.cached_layout.status_bar_area {
1651 if row == status_row {
1652 if let Some((le_row, le_start, le_end)) =
1654 self.cached_layout.status_bar_line_ending_area
1655 {
1656 if row == le_row && col >= le_start && col < le_end {
1657 return self.handle_action(Action::SetLineEnding);
1658 }
1659 }
1660
1661 if let Some((enc_row, enc_start, enc_end)) =
1663 self.cached_layout.status_bar_encoding_area
1664 {
1665 if row == enc_row && col >= enc_start && col < enc_end {
1666 return self.handle_action(Action::SetEncoding);
1667 }
1668 }
1669
1670 if let Some((lang_row, lang_start, lang_end)) =
1672 self.cached_layout.status_bar_language_area
1673 {
1674 if row == lang_row && col >= lang_start && col < lang_end {
1675 return self.handle_action(Action::SetLanguage);
1676 }
1677 }
1678
1679 if let Some((lsp_row, lsp_start, lsp_end)) = self.cached_layout.status_bar_lsp_area
1681 {
1682 if row == lsp_row && col >= lsp_start && col < lsp_end {
1683 return self.handle_action(Action::ShowLspStatus);
1684 }
1685 }
1686
1687 if let Some((warn_row, warn_start, warn_end)) =
1689 self.cached_layout.status_bar_warning_area
1690 {
1691 if row == warn_row && col >= warn_start && col < warn_end {
1692 return self.handle_action(Action::ShowWarnings);
1693 }
1694 }
1695
1696 if let Some((msg_row, msg_start, msg_end)) =
1698 self.cached_layout.status_bar_message_area
1699 {
1700 if row == msg_row && col >= msg_start && col < msg_end {
1701 return self.handle_action(Action::ShowStatusLog);
1702 }
1703 }
1704 }
1705 }
1706
1707 if let Some(ref layout) = self.cached_layout.search_options_layout.clone() {
1709 use crate::view::ui::status_bar::SearchOptionsHover;
1710 if let Some(hover) = layout.checkbox_at(col, row) {
1711 match hover {
1712 SearchOptionsHover::CaseSensitive => {
1713 return self.handle_action(Action::ToggleSearchCaseSensitive);
1714 }
1715 SearchOptionsHover::WholeWord => {
1716 return self.handle_action(Action::ToggleSearchWholeWord);
1717 }
1718 SearchOptionsHover::Regex => {
1719 return self.handle_action(Action::ToggleSearchRegex);
1720 }
1721 SearchOptionsHover::ConfirmEach => {
1722 return self.handle_action(Action::ToggleSearchConfirmEach);
1723 }
1724 SearchOptionsHover::None => {}
1725 }
1726 }
1727 }
1728
1729 for (split_id, direction, sep_x, sep_y, sep_length) in &self.cached_layout.separator_areas {
1731 let is_on_separator = match direction {
1732 SplitDirection::Horizontal => {
1733 row == *sep_y && col >= *sep_x && col < sep_x + sep_length
1735 }
1736 SplitDirection::Vertical => {
1737 col == *sep_x && row >= *sep_y && row < sep_y + sep_length
1739 }
1740 };
1741
1742 if is_on_separator {
1743 self.mouse_state.dragging_separator = Some((*split_id, *direction));
1745 self.mouse_state.drag_start_position = Some((col, row));
1746 if let Some(ratio) = self.split_manager.get_ratio((*split_id).into()) {
1748 self.mouse_state.drag_start_ratio = Some(ratio);
1749 }
1750 return Ok(());
1751 }
1752 }
1753
1754 let close_split_click = self
1756 .cached_layout
1757 .close_split_areas
1758 .iter()
1759 .find(|(_, btn_row, start_col, end_col)| {
1760 row == *btn_row && col >= *start_col && col < *end_col
1761 })
1762 .map(|(split_id, _, _, _)| *split_id);
1763
1764 if let Some(split_id) = close_split_click {
1765 if let Err(e) = self.split_manager.close_split(split_id) {
1766 self.set_status_message(
1767 t!("error.cannot_close_split", error = e.to_string()).to_string(),
1768 );
1769 } else {
1770 let new_active_split = self.split_manager.active_split();
1772 if let Some(buffer_id) = self.split_manager.buffer_for_split(new_active_split) {
1773 self.set_active_buffer(buffer_id);
1774 }
1775 self.set_status_message(t!("split.closed").to_string());
1776 }
1777 return Ok(());
1778 }
1779
1780 let maximize_split_click = self
1782 .cached_layout
1783 .maximize_split_areas
1784 .iter()
1785 .find(|(_, btn_row, start_col, end_col)| {
1786 row == *btn_row && col >= *start_col && col < *end_col
1787 })
1788 .map(|(split_id, _, _, _)| *split_id);
1789
1790 if let Some(_split_id) = maximize_split_click {
1791 match self.split_manager.toggle_maximize() {
1793 Ok(maximized) => {
1794 if maximized {
1795 self.set_status_message(t!("split.maximized").to_string());
1796 } else {
1797 self.set_status_message(t!("split.restored").to_string());
1798 }
1799 }
1800 Err(e) => self.set_status_message(e),
1801 }
1802 return Ok(());
1803 }
1804
1805 for (split_id, tab_layout) in &self.cached_layout.tab_layouts {
1808 tracing::debug!(
1809 "Tab layout for split {:?}: bar_area={:?}, left_scroll={:?}, right_scroll={:?}",
1810 split_id,
1811 tab_layout.bar_area,
1812 tab_layout.left_scroll_area,
1813 tab_layout.right_scroll_area
1814 );
1815 }
1816
1817 let tab_hit = self
1818 .cached_layout
1819 .tab_layouts
1820 .iter()
1821 .find_map(|(split_id, tab_layout)| {
1822 let hit = tab_layout.hit_test(col, row);
1823 tracing::debug!(
1824 "Tab hit_test at ({}, {}) for split {:?} returned {:?}",
1825 col,
1826 row,
1827 split_id,
1828 hit
1829 );
1830 hit.map(|h| (*split_id, h))
1831 });
1832
1833 if let Some((split_id, hit)) = tab_hit {
1834 match hit {
1835 TabHit::CloseButton(buffer_id) => {
1836 self.focus_split(split_id, buffer_id);
1837 self.close_tab_in_split(buffer_id, split_id);
1838 return Ok(());
1839 }
1840 TabHit::TabName(buffer_id) => {
1841 self.focus_split(split_id, buffer_id);
1842 self.mouse_state.dragging_tab = Some(super::types::TabDragState::new(
1844 buffer_id,
1845 split_id,
1846 (col, row),
1847 ));
1848 return Ok(());
1849 }
1850 TabHit::ScrollLeft => {
1851 self.set_status_message("ScrollLeft clicked!".to_string());
1853 if let Some(view_state) = self.split_view_states.get_mut(&split_id) {
1854 view_state.tab_scroll_offset =
1855 view_state.tab_scroll_offset.saturating_sub(10);
1856 }
1857 return Ok(());
1858 }
1859 TabHit::ScrollRight => {
1860 self.set_status_message("ScrollRight clicked!".to_string());
1862 if let Some(view_state) = self.split_view_states.get_mut(&split_id) {
1863 view_state.tab_scroll_offset =
1864 view_state.tab_scroll_offset.saturating_add(10);
1865 }
1866 return Ok(());
1867 }
1868 TabHit::BarBackground => {}
1869 }
1870 }
1871
1872 tracing::debug!(
1874 "handle_mouse_click: checking {} split_areas for click at ({}, {})",
1875 self.cached_layout.split_areas.len(),
1876 col,
1877 row
1878 );
1879 for (split_id, buffer_id, content_rect, _scrollbar_rect, _thumb_start, _thumb_end) in
1880 &self.cached_layout.split_areas
1881 {
1882 tracing::debug!(
1883 " split_id={:?}, content_rect=({}, {}, {}x{})",
1884 split_id,
1885 content_rect.x,
1886 content_rect.y,
1887 content_rect.width,
1888 content_rect.height
1889 );
1890 if col >= content_rect.x
1891 && col < content_rect.x + content_rect.width
1892 && row >= content_rect.y
1893 && row < content_rect.y + content_rect.height
1894 {
1895 tracing::debug!(" -> HIT! calling handle_editor_click");
1897 self.handle_editor_click(
1898 col,
1899 row,
1900 *split_id,
1901 *buffer_id,
1902 *content_rect,
1903 modifiers,
1904 )?;
1905 return Ok(());
1906 }
1907 }
1908 tracing::debug!(" -> No split area hit");
1909
1910 Ok(())
1911 }
1912
1913 pub(super) fn handle_mouse_drag(&mut self, col: u16, row: u16) -> AnyhowResult<()> {
1915 if let Some(dragging_split_id) = self.mouse_state.dragging_scrollbar {
1917 for (split_id, buffer_id, _content_rect, scrollbar_rect, _thumb_start, _thumb_end) in
1919 &self.cached_layout.split_areas
1920 {
1921 if *split_id == dragging_split_id {
1922 if self.mouse_state.drag_start_row.is_some() {
1924 self.handle_scrollbar_drag_relative(
1926 row,
1927 *split_id,
1928 *buffer_id,
1929 *scrollbar_rect,
1930 )?;
1931 } else {
1932 self.handle_scrollbar_jump(
1934 col,
1935 row,
1936 *split_id,
1937 *buffer_id,
1938 *scrollbar_rect,
1939 )?;
1940 }
1941 return Ok(());
1942 }
1943 }
1944 }
1945
1946 if let Some(dragging_split_id) = self.mouse_state.dragging_horizontal_scrollbar {
1948 for (
1949 split_id,
1950 _buffer_id,
1951 hscrollbar_rect,
1952 max_content_width,
1953 thumb_start,
1954 thumb_end,
1955 ) in &self.cached_layout.horizontal_scrollbar_areas
1956 {
1957 if *split_id == dragging_split_id {
1958 let track_width = hscrollbar_rect.width as f64;
1959 if track_width <= 1.0 {
1960 break;
1961 }
1962
1963 if let (Some(drag_start_hcol), Some(drag_start_left_column)) = (
1964 self.mouse_state.drag_start_hcol,
1965 self.mouse_state.drag_start_left_column,
1966 ) {
1967 let col_offset = (col as i32) - (drag_start_hcol as i32);
1970 if let Some(view_state) = self.split_view_states.get_mut(&dragging_split_id)
1971 {
1972 let visible_width = view_state.viewport.width as usize;
1973 let max_scroll = max_content_width.saturating_sub(visible_width);
1974 if max_scroll > 0 {
1975 let thumb_size = thumb_end.saturating_sub(*thumb_start).max(1);
1976 let track_travel = (track_width - thumb_size as f64).max(1.0);
1977 let scroll_per_pixel = max_scroll as f64 / track_travel;
1978 let scroll_offset =
1979 (col_offset as f64 * scroll_per_pixel).round() as i64;
1980 let new_left =
1981 (drag_start_left_column as i64 + scroll_offset).max(0) as usize;
1982 view_state.viewport.left_column = new_left.min(max_scroll);
1983 view_state.viewport.set_skip_ensure_visible();
1984 }
1985 }
1986 } else {
1987 let relative_col = col.saturating_sub(hscrollbar_rect.x) as f64;
1989 let ratio = (relative_col / (track_width - 1.0)).clamp(0.0, 1.0);
1990
1991 if let Some(view_state) = self.split_view_states.get_mut(&dragging_split_id)
1992 {
1993 let visible_width = view_state.viewport.width as usize;
1994 let max_scroll = max_content_width.saturating_sub(visible_width);
1995 let target_col = (ratio * max_scroll as f64).round() as usize;
1996 view_state.viewport.left_column = target_col.min(max_scroll);
1997 view_state.viewport.set_skip_ensure_visible();
1998 }
1999 }
2000
2001 return Ok(());
2002 }
2003 }
2004 }
2005
2006 if let Some(popup_idx) = self.mouse_state.selecting_in_popup {
2008 if let Some((_, _, inner_rect, scroll_offset, _, _, _)) = self
2010 .cached_layout
2011 .popup_areas
2012 .iter()
2013 .find(|(idx, _, _, _, _, _, _)| *idx == popup_idx)
2014 {
2015 if col >= inner_rect.x
2017 && col < inner_rect.x + inner_rect.width
2018 && row >= inner_rect.y
2019 && row < inner_rect.y + inner_rect.height
2020 {
2021 let relative_col = (col - inner_rect.x) as usize;
2022 let relative_row = (row - inner_rect.y) as usize;
2023 let line = scroll_offset + relative_row;
2024
2025 let state = self.active_state_mut();
2026 if let Some(popup) = state.popups.get_mut(popup_idx) {
2027 popup.extend_selection(line, relative_col);
2028 }
2029 }
2030 }
2031 return Ok(());
2032 }
2033
2034 if let Some(popup_idx) = self.mouse_state.dragging_popup_scrollbar {
2036 if let Some((_, _, inner_rect, _, _, Some(sb_rect), total_lines)) = self
2038 .cached_layout
2039 .popup_areas
2040 .iter()
2041 .find(|(idx, _, _, _, _, _, _)| *idx == popup_idx)
2042 {
2043 let track_height = sb_rect.height as usize;
2044 let visible_lines = inner_rect.height as usize;
2045
2046 if track_height > 0 && *total_lines > visible_lines {
2047 let relative_row = row.saturating_sub(sb_rect.y) as usize;
2048 let max_scroll = total_lines.saturating_sub(visible_lines);
2049 let target_scroll = if track_height > 1 {
2050 (relative_row * max_scroll) / (track_height.saturating_sub(1))
2051 } else {
2052 0
2053 };
2054
2055 let state = self.active_state_mut();
2056 if let Some(popup) = state.popups.get_mut(popup_idx) {
2057 let current_scroll = popup.scroll_offset as i32;
2058 let delta = target_scroll as i32 - current_scroll;
2059 popup.scroll_by(delta);
2060 }
2061 }
2062 }
2063 return Ok(());
2064 }
2065
2066 if let Some((split_id, direction)) = self.mouse_state.dragging_separator {
2068 self.handle_separator_drag(col, row, split_id, direction)?;
2069 return Ok(());
2070 }
2071
2072 if self.mouse_state.dragging_file_explorer {
2074 self.handle_file_explorer_border_drag(col)?;
2075 return Ok(());
2076 }
2077
2078 if self.mouse_state.dragging_text_selection {
2080 self.handle_text_selection_drag(col, row)?;
2081 return Ok(());
2082 }
2083
2084 if self.mouse_state.dragging_tab.is_some() {
2086 self.handle_tab_drag(col, row)?;
2087 return Ok(());
2088 }
2089
2090 Ok(())
2091 }
2092
2093 fn handle_text_selection_drag(&mut self, col: u16, row: u16) -> AnyhowResult<()> {
2095 use crate::model::event::Event;
2096
2097 let Some(split_id) = self.mouse_state.drag_selection_split else {
2098 return Ok(());
2099 };
2100 let Some(anchor_position) = self.mouse_state.drag_selection_anchor else {
2101 return Ok(());
2102 };
2103
2104 let buffer_id = self
2106 .cached_layout
2107 .split_areas
2108 .iter()
2109 .find(|(sid, _, _, _, _, _)| *sid == split_id)
2110 .map(|(_, bid, _, _, _, _)| *bid);
2111
2112 let Some(buffer_id) = buffer_id else {
2113 return Ok(());
2114 };
2115
2116 let content_rect = self
2118 .cached_layout
2119 .split_areas
2120 .iter()
2121 .find(|(sid, _, _, _, _, _)| *sid == split_id)
2122 .map(|(_, _, rect, _, _, _)| *rect);
2123
2124 let Some(content_rect) = content_rect else {
2125 return Ok(());
2126 };
2127
2128 let cached_mappings = self
2130 .cached_layout
2131 .view_line_mappings
2132 .get(&split_id)
2133 .cloned();
2134
2135 let leaf_id = split_id;
2136
2137 let fallback = self
2139 .split_view_states
2140 .get(&leaf_id)
2141 .map(|vs| vs.viewport.top_byte)
2142 .unwrap_or(0);
2143
2144 let compose_width = self
2146 .split_view_states
2147 .get(&leaf_id)
2148 .and_then(|vs| vs.compose_width);
2149
2150 if let Some(state) = self.buffers.get_mut(&buffer_id) {
2152 let gutter_width = state.margins.left_total_width() as u16;
2153
2154 let Some(target_position) = Self::screen_to_buffer_position(
2155 col,
2156 row,
2157 content_rect,
2158 gutter_width,
2159 &cached_mappings,
2160 fallback,
2161 true, compose_width,
2163 ) else {
2164 return Ok(());
2165 };
2166
2167 let (primary_cursor_id, old_position, old_anchor, old_sticky_column) = self
2168 .split_view_states
2169 .get(&leaf_id)
2170 .map(|vs| {
2171 let cursor = vs.cursors.primary();
2172 (
2173 vs.cursors.primary_id(),
2174 cursor.position,
2175 cursor.anchor,
2176 cursor.sticky_column,
2177 )
2178 })
2179 .unwrap_or((CursorId(0), 0, None, 0));
2180
2181 let new_sticky_column = state
2182 .buffer
2183 .offset_to_position(target_position)
2184 .map(|pos| pos.column)
2185 .unwrap_or(old_sticky_column);
2186 let event = Event::MoveCursor {
2187 cursor_id: primary_cursor_id,
2188 old_position,
2189 new_position: target_position,
2190 old_anchor,
2191 new_anchor: Some(anchor_position), old_sticky_column,
2193 new_sticky_column,
2194 };
2195
2196 if let Some(event_log) = self.event_logs.get_mut(&buffer_id) {
2197 event_log.append(event.clone());
2198 }
2199 if let Some(cursors) = self
2200 .split_view_states
2201 .get_mut(&leaf_id)
2202 .map(|vs| &mut vs.cursors)
2203 {
2204 state.apply(cursors, &event);
2205 }
2206 }
2207
2208 Ok(())
2209 }
2210
2211 pub(super) fn handle_file_explorer_border_drag(&mut self, col: u16) -> AnyhowResult<()> {
2213 let Some((start_col, _start_row)) = self.mouse_state.drag_start_position else {
2214 return Ok(());
2215 };
2216 let Some(start_width) = self.mouse_state.drag_start_explorer_width else {
2217 return Ok(());
2218 };
2219
2220 let delta = col as i32 - start_col as i32;
2222 let total_width = self.terminal_width as i32;
2223
2224 if total_width > 0 {
2225 let percent_delta = delta as f32 / total_width as f32;
2227 let new_width = (start_width + percent_delta).clamp(0.1, 0.5);
2229 self.file_explorer_width_percent = new_width;
2230 }
2231
2232 Ok(())
2233 }
2234
2235 pub(super) fn handle_separator_drag(
2237 &mut self,
2238 col: u16,
2239 row: u16,
2240 split_id: ContainerId,
2241 direction: SplitDirection,
2242 ) -> AnyhowResult<()> {
2243 let Some((start_col, start_row)) = self.mouse_state.drag_start_position else {
2244 return Ok(());
2245 };
2246 let Some(start_ratio) = self.mouse_state.drag_start_ratio else {
2247 return Ok(());
2248 };
2249 let Some(editor_area) = self.cached_layout.editor_content_area else {
2250 return Ok(());
2251 };
2252
2253 let (delta, total_size) = match direction {
2255 SplitDirection::Horizontal => {
2256 let delta = row as i32 - start_row as i32;
2258 let total = editor_area.height as i32;
2259 (delta, total)
2260 }
2261 SplitDirection::Vertical => {
2262 let delta = col as i32 - start_col as i32;
2264 let total = editor_area.width as i32;
2265 (delta, total)
2266 }
2267 };
2268
2269 if total_size > 0 {
2272 let ratio_delta = delta as f32 / total_size as f32;
2273 let new_ratio = (start_ratio + ratio_delta).clamp(0.1, 0.9);
2274
2275 self.split_manager.set_ratio(split_id, new_ratio);
2277 }
2278
2279 Ok(())
2280 }
2281
2282 pub(super) fn handle_right_click(&mut self, col: u16, row: u16) -> AnyhowResult<()> {
2284 if let Some(ref menu) = self.tab_context_menu {
2286 let menu_x = menu.position.0;
2287 let menu_y = menu.position.1;
2288 let menu_width = 22u16; let menu_height = super::types::TabContextMenuItem::all().len() as u16 + 2; if col >= menu_x
2293 && col < menu_x + menu_width
2294 && row >= menu_y
2295 && row < menu_y + menu_height
2296 {
2297 return Ok(());
2299 }
2300 }
2301
2302 let tab_hit =
2304 self.cached_layout.tab_layouts.iter().find_map(
2305 |(split_id, tab_layout)| match tab_layout.hit_test(col, row) {
2306 Some(TabHit::TabName(buffer_id) | TabHit::CloseButton(buffer_id)) => {
2307 Some((*split_id, buffer_id))
2308 }
2309 _ => None,
2310 },
2311 );
2312
2313 if let Some((split_id, buffer_id)) = tab_hit {
2314 self.tab_context_menu = Some(TabContextMenu::new(buffer_id, split_id, col, row + 1));
2316 } else {
2317 self.tab_context_menu = None;
2319 }
2320
2321 Ok(())
2322 }
2323
2324 pub(super) fn handle_tab_context_menu_click(
2326 &mut self,
2327 col: u16,
2328 row: u16,
2329 ) -> Option<AnyhowResult<()>> {
2330 let menu = self.tab_context_menu.as_ref()?;
2331 let menu_x = menu.position.0;
2332 let menu_y = menu.position.1;
2333 let menu_width = 22u16;
2334 let items = super::types::TabContextMenuItem::all();
2335 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
2339 {
2340 self.tab_context_menu = None;
2342 return Some(Ok(()));
2343 }
2344
2345 if row == menu_y || row == menu_y + menu_height - 1 {
2347 return Some(Ok(()));
2348 }
2349
2350 let item_idx = (row - menu_y - 1) as usize;
2352 if item_idx >= items.len() {
2353 return Some(Ok(()));
2354 }
2355
2356 let buffer_id = menu.buffer_id;
2358 let split_id = menu.split_id;
2359 let item = items[item_idx];
2360
2361 self.tab_context_menu = None;
2363
2364 Some(self.execute_tab_context_menu_action(item, buffer_id, split_id))
2366 }
2367
2368 fn execute_tab_context_menu_action(
2370 &mut self,
2371 item: super::types::TabContextMenuItem,
2372 buffer_id: BufferId,
2373 leaf_id: LeafId,
2374 ) -> AnyhowResult<()> {
2375 use super::types::TabContextMenuItem;
2376 match item {
2377 TabContextMenuItem::Close => {
2378 self.close_tab_in_split(buffer_id, leaf_id);
2379 }
2380 TabContextMenuItem::CloseOthers => {
2381 self.close_other_tabs_in_split(buffer_id, leaf_id);
2382 }
2383 TabContextMenuItem::CloseToRight => {
2384 self.close_tabs_to_right_in_split(buffer_id, leaf_id);
2385 }
2386 TabContextMenuItem::CloseToLeft => {
2387 self.close_tabs_to_left_in_split(buffer_id, leaf_id);
2388 }
2389 TabContextMenuItem::CloseAll => {
2390 self.close_all_tabs_in_split(leaf_id);
2391 }
2392 }
2393
2394 Ok(())
2395 }
2396
2397 fn show_file_explorer_status_tooltip(&mut self, path: std::path::PathBuf, col: u16, row: u16) {
2399 use crate::view::popup::{Popup, PopupPosition};
2400 use ratatui::style::Style;
2401
2402 let is_directory = path.is_dir();
2403
2404 let decoration = self
2406 .file_explorer_decoration_cache
2407 .direct_for_path(&path)
2408 .cloned();
2409
2410 let bubbled_decoration = if is_directory && decoration.is_none() {
2412 self.file_explorer_decoration_cache
2413 .bubbled_for_path(&path)
2414 .cloned()
2415 } else {
2416 None
2417 };
2418
2419 let has_unsaved_changes = if is_directory {
2421 self.buffers.iter().any(|(buffer_id, state)| {
2423 if state.buffer.is_modified() {
2424 if let Some(metadata) = self.buffer_metadata.get(buffer_id) {
2425 if let Some(file_path) = metadata.file_path() {
2426 return file_path.starts_with(&path);
2427 }
2428 }
2429 }
2430 false
2431 })
2432 } else {
2433 self.buffers.iter().any(|(buffer_id, state)| {
2434 if state.buffer.is_modified() {
2435 if let Some(metadata) = self.buffer_metadata.get(buffer_id) {
2436 return metadata.file_path() == Some(&path);
2437 }
2438 }
2439 false
2440 })
2441 };
2442
2443 let mut lines: Vec<String> = Vec::new();
2445
2446 if let Some(decoration) = &decoration {
2447 let symbol = &decoration.symbol;
2448 let explanation = match symbol.as_str() {
2449 "U" => "Untracked - File is not tracked by git",
2450 "M" => "Modified - File has unstaged changes",
2451 "A" => "Added - File is staged for commit",
2452 "D" => "Deleted - File is staged for deletion",
2453 "R" => "Renamed - File has been renamed",
2454 "C" => "Copied - File has been copied",
2455 "!" => "Conflicted - File has merge conflicts",
2456 "●" => "Has changes - Contains modified files",
2457 _ => "Unknown status",
2458 };
2459 lines.push(format!("{} - {}", symbol, explanation));
2460 } else if bubbled_decoration.is_some() {
2461 lines.push("● - Contains modified files".to_string());
2462 } else if has_unsaved_changes {
2463 if is_directory {
2464 lines.push("● - Contains unsaved changes".to_string());
2465 } else {
2466 lines.push("● - Unsaved changes in editor".to_string());
2467 }
2468 } else {
2469 return; }
2471
2472 if is_directory {
2474 if let Some(modified_files) = self.get_modified_files_in_directory(&path) {
2476 lines.push(String::new()); lines.push("Modified files:".to_string());
2478 let resolved_path = path.canonicalize().unwrap_or_else(|_| path.clone());
2480 const MAX_FILES: usize = 8;
2481 for (i, file) in modified_files.iter().take(MAX_FILES).enumerate() {
2482 let display_name = file
2484 .strip_prefix(&resolved_path)
2485 .unwrap_or(file)
2486 .to_string_lossy()
2487 .to_string();
2488 lines.push(format!(" {}", display_name));
2489 if i == MAX_FILES - 1 && modified_files.len() > MAX_FILES {
2490 lines.push(format!(
2491 " ... and {} more",
2492 modified_files.len() - MAX_FILES
2493 ));
2494 break;
2495 }
2496 }
2497 }
2498 } else {
2499 if let Some(stats) = self.get_git_diff_stats(&path) {
2501 lines.push(String::new()); lines.push(stats);
2503 }
2504 }
2505
2506 if lines.is_empty() {
2507 return;
2508 }
2509
2510 let mut popup = Popup::text(lines, &self.theme);
2512 popup.title = Some("Git Status".to_string());
2513 popup.transient = true;
2514 popup.position = PopupPosition::Fixed { x: col, y: row + 1 };
2515 popup.width = 50;
2516 popup.max_height = 15;
2517 popup.border_style = Style::default().fg(self.theme.popup_border_fg);
2518 popup.background_style = Style::default().bg(self.theme.popup_bg);
2519
2520 if let Some(state) = self.buffers.get_mut(&self.active_buffer()) {
2522 state.popups.show(popup);
2523 }
2524 }
2525
2526 fn dismiss_file_explorer_status_tooltip(&mut self) {
2528 if let Some(state) = self.buffers.get_mut(&self.active_buffer()) {
2530 state.popups.dismiss_transient();
2531 }
2532 }
2533
2534 fn get_git_diff_stats(&self, path: &std::path::Path) -> Option<String> {
2536 use std::process::Command;
2537
2538 let output = Command::new("git")
2540 .args(["diff", "--numstat", "--"])
2541 .arg(path)
2542 .current_dir(&self.working_dir)
2543 .output()
2544 .ok()?;
2545
2546 if !output.status.success() {
2547 return None;
2548 }
2549
2550 let stdout = String::from_utf8_lossy(&output.stdout);
2551 let line = stdout.lines().next()?;
2552 let parts: Vec<&str> = line.split('\t').collect();
2553
2554 if parts.len() >= 2 {
2555 let insertions = parts[0];
2556 let deletions = parts[1];
2557
2558 if insertions == "-" && deletions == "-" {
2560 return Some("Binary file changed".to_string());
2561 }
2562
2563 let ins: i32 = insertions.parse().unwrap_or(0);
2564 let del: i32 = deletions.parse().unwrap_or(0);
2565
2566 if ins > 0 || del > 0 {
2567 return Some(format!("+{} -{} lines", ins, del));
2568 }
2569 }
2570
2571 let staged_output = Command::new("git")
2573 .args(["diff", "--numstat", "--cached", "--"])
2574 .arg(path)
2575 .current_dir(&self.working_dir)
2576 .output()
2577 .ok()?;
2578
2579 if staged_output.status.success() {
2580 let staged_stdout = String::from_utf8_lossy(&staged_output.stdout);
2581 if let Some(line) = staged_stdout.lines().next() {
2582 let parts: Vec<&str> = line.split('\t').collect();
2583 if parts.len() >= 2 {
2584 let insertions = parts[0];
2585 let deletions = parts[1];
2586
2587 if insertions == "-" && deletions == "-" {
2588 return Some("Binary file staged".to_string());
2589 }
2590
2591 let ins: i32 = insertions.parse().unwrap_or(0);
2592 let del: i32 = deletions.parse().unwrap_or(0);
2593
2594 if ins > 0 || del > 0 {
2595 return Some(format!("+{} -{} lines (staged)", ins, del));
2596 }
2597 }
2598 }
2599 }
2600
2601 None
2602 }
2603
2604 fn get_modified_files_in_directory(
2606 &self,
2607 dir_path: &std::path::Path,
2608 ) -> Option<Vec<std::path::PathBuf>> {
2609 use std::process::Command;
2610
2611 let resolved_path = dir_path
2613 .canonicalize()
2614 .unwrap_or_else(|_| dir_path.to_path_buf());
2615
2616 let output = Command::new("git")
2618 .args(["status", "--porcelain", "--"])
2619 .arg(&resolved_path)
2620 .current_dir(&self.working_dir)
2621 .output()
2622 .ok()?;
2623
2624 if !output.status.success() {
2625 return None;
2626 }
2627
2628 let stdout = String::from_utf8_lossy(&output.stdout);
2629 let modified_files: Vec<std::path::PathBuf> = stdout
2630 .lines()
2631 .filter_map(|line| {
2632 if line.len() > 3 {
2635 let file_part = &line[3..];
2636 let file_name = if file_part.contains(" -> ") {
2638 file_part.split(" -> ").last().unwrap_or(file_part)
2639 } else {
2640 file_part
2641 };
2642 Some(self.working_dir.join(file_name))
2643 } else {
2644 None
2645 }
2646 })
2647 .collect();
2648
2649 if modified_files.is_empty() {
2650 None
2651 } else {
2652 Some(modified_files)
2653 }
2654 }
2655}