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 let Some(&HoverTarget::FileExplorerContextMenuItem(item_idx)) = new_target.as_ref() {
534 if let Some(ref mut menu) = self.file_explorer_context_menu {
535 if menu.highlighted != item_idx {
536 menu.highlighted = item_idx;
537 return true;
538 }
539 }
540 }
541
542 if old_target != new_target
545 && matches!(
546 old_target,
547 Some(HoverTarget::FileExplorerStatusIndicator(_))
548 )
549 {
550 self.dismiss_file_explorer_status_tooltip();
551 }
552
553 if let Some(HoverTarget::FileExplorerStatusIndicator(ref path)) = new_target {
554 if old_target != new_target {
556 self.show_file_explorer_status_tooltip(path.clone(), col, row);
557 return true;
558 }
559 }
560
561 changed
562 }
563
564 fn update_lsp_hover_state(&mut self, col: u16, row: u16) {
573 tracing::trace!(col, row, "update_lsp_hover_state: raw mouse position");
574
575 if self.theme_info_popup.is_some()
578 || self.tab_context_menu.is_some()
579 || self.file_explorer_context_menu.is_some()
580 {
581 if self.mouse_state.lsp_hover_state.is_some() {
582 self.mouse_state.lsp_hover_state = None;
583 self.mouse_state.lsp_hover_request_sent = false;
584 self.dismiss_transient_popups();
585 }
586 return;
587 }
588
589 if self.is_mouse_over_transient_popup(col, row) {
591 return;
592 }
593
594 let split_info = self
596 .cached_layout
597 .split_areas
598 .iter()
599 .find(|(_, _, content_rect, _, _, _)| {
600 col >= content_rect.x
601 && col < content_rect.x + content_rect.width
602 && row >= content_rect.y
603 && row < content_rect.y + content_rect.height
604 })
605 .map(|(split_id, buffer_id, content_rect, _, _, _)| {
606 (*split_id, *buffer_id, *content_rect)
607 });
608
609 let Some((split_id, buffer_id, content_rect)) = split_info else {
610 if self.mouse_state.lsp_hover_state.is_some() {
612 self.mouse_state.lsp_hover_state = None;
613 self.mouse_state.lsp_hover_request_sent = false;
614 self.dismiss_transient_popups();
615 }
616 return;
617 };
618
619 let cached_mappings = self
621 .cached_layout
622 .view_line_mappings
623 .get(&split_id)
624 .cloned();
625 let gutter_width = self
626 .buffers
627 .get(&buffer_id)
628 .map(|s| s.margins.left_total_width() as u16)
629 .unwrap_or(0);
630 let fallback = self
631 .buffers
632 .get(&buffer_id)
633 .map(|s| s.buffer.len())
634 .unwrap_or(0);
635
636 let compose_width = self
638 .split_view_states
639 .get(&split_id)
640 .and_then(|vs| vs.compose_width);
641
642 let Some(byte_pos) = super::click_geometry::screen_to_buffer_position(
644 col,
645 row,
646 content_rect,
647 gutter_width,
648 &cached_mappings,
649 fallback,
650 false, compose_width,
652 ) else {
653 if self.mouse_state.lsp_hover_state.is_some() {
655 self.mouse_state.lsp_hover_state = None;
656 self.mouse_state.lsp_hover_request_sent = false;
657 self.dismiss_transient_popups();
658 }
659 return;
660 };
661
662 let content_col = col.saturating_sub(content_rect.x);
664 let text_col = content_col.saturating_sub(gutter_width) as usize;
665 let visual_row = row.saturating_sub(content_rect.y) as usize;
666
667 let line_info = cached_mappings
668 .as_ref()
669 .and_then(|mappings| mappings.get(visual_row))
670 .map(|line_mapping| {
671 (
672 line_mapping.visual_to_char.len(),
673 line_mapping.line_end_byte,
674 )
675 });
676
677 let is_past_line_end_or_empty = line_info
678 .map(|(line_len, _)| {
679 if line_len <= 1 {
681 return true;
682 }
683 text_col >= line_len
684 })
685 .unwrap_or(true);
687
688 tracing::trace!(
689 col,
690 row,
691 content_col,
692 text_col,
693 visual_row,
694 gutter_width,
695 byte_pos,
696 ?line_info,
697 is_past_line_end_or_empty,
698 "update_lsp_hover_state: position check"
699 );
700
701 if is_past_line_end_or_empty {
702 tracing::trace!(
703 "update_lsp_hover_state: mouse past line end or empty line, clearing hover"
704 );
705 if self.mouse_state.lsp_hover_state.is_some() {
707 self.mouse_state.lsp_hover_state = None;
708 self.mouse_state.lsp_hover_request_sent = false;
709 self.dismiss_transient_popups();
710 }
711 return;
712 }
713
714 if let Some((start, end)) = self.hover.symbol_range() {
716 if byte_pos >= start && byte_pos < end {
717 return;
719 }
720 }
721
722 if let Some((old_pos, _, _, _)) = self.mouse_state.lsp_hover_state {
724 if old_pos == byte_pos {
725 return;
727 }
728 self.dismiss_transient_popups();
730 }
731
732 self.mouse_state.lsp_hover_state = Some((byte_pos, std::time::Instant::now(), col, row));
734 self.mouse_state.lsp_hover_request_sent = false;
735 }
736
737 fn is_mouse_over_transient_popup(&self, col: u16, row: u16) -> bool {
739 let layouts = popup_areas_to_layout_info(&self.cached_layout.popup_areas);
740 let hit_tester = PopupHitTester::new(&layouts, &self.active_state().popups);
741 hit_tester.is_over_transient_popup(col, row)
742 }
743
744 fn is_mouse_over_any_popup(&self, col: u16, row: u16) -> bool {
746 for (_, popup_area, _, _, _) in &self.cached_layout.global_popup_areas {
749 if col >= popup_area.x
750 && col < popup_area.x + popup_area.width
751 && row >= popup_area.y
752 && row < popup_area.y + popup_area.height
753 {
754 return true;
755 }
756 }
757 let layouts = popup_areas_to_layout_info(&self.cached_layout.popup_areas);
758 let hit_tester = PopupHitTester::new(&layouts, &self.active_state().popups);
759 hit_tester.is_over_popup(col, row)
760 }
761
762 fn is_mouse_over_file_browser(&self, col: u16, row: u16) -> bool {
764 self.file_browser_layout
765 .as_ref()
766 .is_some_and(|layout| layout.contains(col, row))
767 }
768
769 pub(super) fn split_at_position(&self, col: u16, row: u16) -> Option<(LeafId, BufferId)> {
772 for &(split_id, buffer_id, content_rect, scrollbar_rect, _, _) in
773 &self.cached_layout.split_areas
774 {
775 let in_content = col >= content_rect.x
776 && col < content_rect.x + content_rect.width
777 && row >= content_rect.y
778 && row < content_rect.y + content_rect.height;
779 let in_scrollbar = scrollbar_rect.width > 0
780 && scrollbar_rect.height > 0
781 && col >= scrollbar_rect.x
782 && col < scrollbar_rect.x + scrollbar_rect.width
783 && row >= scrollbar_rect.y
784 && row < scrollbar_rect.y + scrollbar_rect.height;
785 if in_content || in_scrollbar {
786 return Some((split_id, buffer_id));
787 }
788 }
789 None
790 }
791
792 fn compute_hover_target(&self, col: u16, row: u16) -> Option<HoverTarget> {
794 if let Some(ref menu) = self.file_explorer_context_menu {
795 let (menu_x, menu_y) = menu.clamped_position(
796 self.cached_layout.last_frame_width,
797 self.cached_layout.last_frame_height,
798 );
799 let menu_width = super::types::FILE_EXPLORER_CONTEXT_MENU_WIDTH;
800 let menu_height = menu.height();
801
802 if col >= menu_x
803 && col < menu_x + menu_width
804 && row > menu_y
805 && row < menu_y + menu_height - 1
806 {
807 let item_idx = (row - menu_y - 1) as usize;
808 if item_idx < menu.items().len() {
809 return Some(HoverTarget::FileExplorerContextMenuItem(item_idx));
810 }
811 }
812 }
813
814 if let Some(ref menu) = self.tab_context_menu {
816 let menu_x = menu.position.0;
817 let menu_y = menu.position.1;
818 let menu_width = 22u16;
819 let items = super::types::TabContextMenuItem::all();
820 let menu_height = items.len() as u16 + 2;
821
822 if col >= menu_x
823 && col < menu_x + menu_width
824 && row > menu_y
825 && row < menu_y + menu_height - 1
826 {
827 let item_idx = (row - menu_y - 1) as usize;
828 if item_idx < items.len() {
829 return Some(HoverTarget::TabContextMenuItem(item_idx));
830 }
831 }
832 }
833
834 if let Some((inner_rect, start_idx, _visible_count, total_count)) =
836 &self.cached_layout.suggestions_area
837 {
838 if col >= inner_rect.x
839 && col < inner_rect.x + inner_rect.width
840 && row >= inner_rect.y
841 && row < inner_rect.y + inner_rect.height
842 {
843 let relative_row = (row - inner_rect.y) as usize;
844 let item_idx = start_idx + relative_row;
845
846 if item_idx < *total_count {
847 return Some(HoverTarget::SuggestionItem(item_idx));
848 }
849 }
850 }
851
852 for (popup_idx, _popup_rect, inner_rect, scroll_offset, num_items, _, _) in
855 self.cached_layout.popup_areas.iter().rev()
856 {
857 if col >= inner_rect.x
858 && col < inner_rect.x + inner_rect.width
859 && row >= inner_rect.y
860 && row < inner_rect.y + inner_rect.height
861 && *num_items > 0
862 {
863 let relative_row = (row - inner_rect.y) as usize;
865 let item_idx = scroll_offset + relative_row;
866
867 if item_idx < *num_items {
868 return Some(HoverTarget::PopupListItem(*popup_idx, item_idx));
869 }
870 }
871 }
872
873 if self.is_file_open_active() {
875 if let Some(hover) = self.compute_file_browser_hover(col, row) {
876 return Some(hover);
877 }
878 }
879
880 if self.menu_bar_visible {
883 if let Some(ref menu_layout) = self.cached_layout.menu_layout {
884 if let Some(menu_idx) = menu_layout.menu_at(col, row) {
885 return Some(HoverTarget::MenuBarItem(menu_idx));
886 }
887 }
888 }
889
890 if let Some(active_idx) = self.menu_state.active_menu {
892 if let Some(hover) = self.compute_menu_dropdown_hover(col, row, active_idx) {
893 return Some(hover);
894 }
895 }
896
897 if let Some(explorer_area) = self.cached_layout.file_explorer_area {
899 let close_button_x = explorer_area.x + explorer_area.width.saturating_sub(3);
901 if row == explorer_area.y
902 && col >= close_button_x
903 && col < explorer_area.x + explorer_area.width
904 {
905 return Some(HoverTarget::FileExplorerCloseButton);
906 }
907
908 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
915 && row < content_end_y
916 && col >= status_indicator_x
917 && col < explorer_area.x + explorer_area.width.saturating_sub(1)
918 {
919 if let Some(ref explorer) = self.file_explorer {
921 let relative_row = row.saturating_sub(content_start_y) as usize;
922 let scroll_offset = explorer.get_scroll_offset();
923 let item_index = relative_row + scroll_offset;
924 let display_nodes = explorer.get_display_nodes();
925
926 if item_index < display_nodes.len() {
927 let (node_id, _indent) = display_nodes[item_index];
928 if let Some(node) = explorer.tree().get_node(node_id) {
929 return Some(HoverTarget::FileExplorerStatusIndicator(
930 node.entry.path.clone(),
931 ));
932 }
933 }
934 }
935 }
936
937 let border_x = explorer_area.x + explorer_area.width.saturating_sub(1);
940 if col == border_x
941 && row >= explorer_area.y
942 && row < explorer_area.y + explorer_area.height
943 {
944 return Some(HoverTarget::FileExplorerBorder);
945 }
946 }
947
948 for (split_id, direction, sep_x, sep_y, sep_length) in &self.cached_layout.separator_areas {
950 let is_on_separator = match direction {
951 SplitDirection::Horizontal => {
952 row == *sep_y && col >= *sep_x && col < sep_x + sep_length
953 }
954 SplitDirection::Vertical => {
955 col == *sep_x && row >= *sep_y && row < sep_y + sep_length
956 }
957 };
958
959 if is_on_separator {
960 return Some(HoverTarget::SplitSeparator(*split_id, *direction));
961 }
962 }
963
964 for (split_id, btn_row, start_col, end_col) in &self.cached_layout.close_split_areas {
967 if row == *btn_row && col >= *start_col && col < *end_col {
968 return Some(HoverTarget::CloseSplitButton(*split_id));
969 }
970 }
971
972 for (split_id, btn_row, start_col, end_col) in &self.cached_layout.maximize_split_areas {
973 if row == *btn_row && col >= *start_col && col < *end_col {
974 return Some(HoverTarget::MaximizeSplitButton(*split_id));
975 }
976 }
977
978 for (split_id, tab_layout) in &self.cached_layout.tab_layouts {
979 match tab_layout.hit_test(col, row) {
980 Some(TabHit::CloseButton(target)) => {
981 return Some(HoverTarget::TabCloseButton(target, *split_id));
982 }
983 Some(TabHit::TabName(target)) => {
984 return Some(HoverTarget::TabName(target, *split_id));
985 }
986 Some(TabHit::ScrollLeft)
987 | Some(TabHit::ScrollRight)
988 | Some(TabHit::BarBackground)
989 | None => {}
990 }
991 }
992
993 for (split_id, _buffer_id, _content_rect, scrollbar_rect, thumb_start, thumb_end) in
995 &self.cached_layout.split_areas
996 {
997 if col >= scrollbar_rect.x
998 && col < scrollbar_rect.x + scrollbar_rect.width
999 && row >= scrollbar_rect.y
1000 && row < scrollbar_rect.y + scrollbar_rect.height
1001 {
1002 let relative_row = row.saturating_sub(scrollbar_rect.y) as usize;
1003 let is_on_thumb = relative_row >= *thumb_start && relative_row < *thumb_end;
1004
1005 if is_on_thumb {
1006 return Some(HoverTarget::ScrollbarThumb(*split_id));
1007 } else {
1008 return Some(HoverTarget::ScrollbarTrack(*split_id, relative_row as u16));
1009 }
1010 }
1011 }
1012
1013 if let Some((status_row, _status_x, _status_width)) = self.cached_layout.status_bar_area {
1015 if row == status_row {
1016 if let Some((le_row, le_start, le_end)) =
1018 self.cached_layout.status_bar_line_ending_area
1019 {
1020 if row == le_row && col >= le_start && col < le_end {
1021 return Some(HoverTarget::StatusBarLineEndingIndicator);
1022 }
1023 }
1024
1025 if let Some((enc_row, enc_start, enc_end)) =
1027 self.cached_layout.status_bar_encoding_area
1028 {
1029 if row == enc_row && col >= enc_start && col < enc_end {
1030 return Some(HoverTarget::StatusBarEncodingIndicator);
1031 }
1032 }
1033
1034 if let Some((lang_row, lang_start, lang_end)) =
1036 self.cached_layout.status_bar_language_area
1037 {
1038 if row == lang_row && col >= lang_start && col < lang_end {
1039 return Some(HoverTarget::StatusBarLanguageIndicator);
1040 }
1041 }
1042
1043 if let Some((lsp_row, lsp_start, lsp_end)) = self.cached_layout.status_bar_lsp_area
1045 {
1046 if row == lsp_row && col >= lsp_start && col < lsp_end {
1047 return Some(HoverTarget::StatusBarLspIndicator);
1048 }
1049 }
1050
1051 if let Some((rem_row, rem_start, rem_end)) =
1053 self.cached_layout.status_bar_remote_area
1054 {
1055 if row == rem_row && col >= rem_start && col < rem_end {
1056 return Some(HoverTarget::StatusBarRemoteIndicator);
1057 }
1058 }
1059
1060 if let Some((warn_row, warn_start, warn_end)) =
1062 self.cached_layout.status_bar_warning_area
1063 {
1064 if row == warn_row && col >= warn_start && col < warn_end {
1065 return Some(HoverTarget::StatusBarWarningBadge);
1066 }
1067 }
1068 }
1069 }
1070
1071 if let Some(ref layout) = self.cached_layout.search_options_layout {
1073 use crate::view::ui::status_bar::SearchOptionsHover;
1074 if let Some(hover) = layout.checkbox_at(col, row) {
1075 return Some(match hover {
1076 SearchOptionsHover::CaseSensitive => HoverTarget::SearchOptionCaseSensitive,
1077 SearchOptionsHover::WholeWord => HoverTarget::SearchOptionWholeWord,
1078 SearchOptionsHover::Regex => HoverTarget::SearchOptionRegex,
1079 SearchOptionsHover::ConfirmEach => HoverTarget::SearchOptionConfirmEach,
1080 SearchOptionsHover::None => return None,
1081 });
1082 }
1083 }
1084
1085 None
1087 }
1088
1089 pub(super) fn handle_mouse_double_click(&mut self, col: u16, row: u16) -> AnyhowResult<()> {
1092 tracing::debug!("handle_mouse_double_click at col={}, row={}", col, row);
1093
1094 if self.is_mouse_over_any_popup(col, row) {
1096 return Ok(());
1098 } else {
1099 self.dismiss_transient_popups();
1101 }
1102
1103 if self.handle_file_open_double_click(col, row) {
1105 return Ok(());
1106 }
1107
1108 if let Some(explorer_area) = self.cached_layout.file_explorer_area {
1110 if col >= explorer_area.x
1111 && col < explorer_area.x + explorer_area.width
1112 && row > explorer_area.y && row < explorer_area.y + explorer_area.height
1114 {
1115 self.file_explorer_open_file()?;
1117 return Ok(());
1118 }
1119 }
1120
1121 let split_areas = self.cached_layout.split_areas.clone();
1123 for (split_id, buffer_id, content_rect, _scrollbar_rect, _thumb_start, _thumb_end) in
1124 &split_areas
1125 {
1126 if col >= content_rect.x
1127 && col < content_rect.x + content_rect.width
1128 && row >= content_rect.y
1129 && row < content_rect.y + content_rect.height
1130 {
1131 if self.is_terminal_buffer(*buffer_id) {
1133 self.key_context = crate::input::keybindings::KeyContext::Terminal;
1134 return Ok(());
1136 }
1137
1138 self.key_context = crate::input::keybindings::KeyContext::Normal;
1139
1140 self.handle_editor_double_click(col, row, *split_id, *buffer_id, *content_rect)?;
1142 return Ok(());
1143 }
1144 }
1145
1146 Ok(())
1147 }
1148
1149 fn handle_editor_double_click(
1151 &mut self,
1152 col: u16,
1153 row: u16,
1154 split_id: LeafId,
1155 buffer_id: BufferId,
1156 content_rect: ratatui::layout::Rect,
1157 ) -> AnyhowResult<()> {
1158 use crate::model::event::Event;
1159
1160 if self.is_non_scrollable_buffer(buffer_id) {
1164 return Ok(());
1165 }
1166
1167 self.focus_split(split_id, buffer_id);
1169
1170 let cached_mappings = self
1172 .cached_layout
1173 .view_line_mappings
1174 .get(&split_id)
1175 .cloned();
1176
1177 let leaf_id = split_id;
1179 let fallback = self
1180 .split_view_states
1181 .get(&leaf_id)
1182 .map(|vs| vs.viewport.top_byte)
1183 .unwrap_or(0);
1184
1185 let compose_width = self
1187 .split_view_states
1188 .get(&leaf_id)
1189 .and_then(|vs| vs.compose_width);
1190
1191 if let Some(state) = self.buffers.get_mut(&buffer_id) {
1193 let gutter_width = state.margins.left_total_width() as u16;
1194
1195 let Some(target_position) = super::click_geometry::screen_to_buffer_position(
1196 col,
1197 row,
1198 content_rect,
1199 gutter_width,
1200 &cached_mappings,
1201 fallback,
1202 true, compose_width,
1204 ) else {
1205 return Ok(());
1206 };
1207
1208 let primary_cursor_id = self
1210 .split_view_states
1211 .get(&leaf_id)
1212 .map(|vs| vs.cursors.primary_id())
1213 .unwrap_or(CursorId(0));
1214 let event = Event::MoveCursor {
1215 cursor_id: primary_cursor_id,
1216 old_position: 0,
1217 new_position: target_position,
1218 old_anchor: None,
1219 new_anchor: None,
1220 old_sticky_column: 0,
1221 new_sticky_column: 0,
1222 };
1223
1224 if let Some(event_log) = self.event_logs.get_mut(&buffer_id) {
1225 event_log.append(event.clone());
1226 }
1227 if let Some(cursors) = self
1228 .split_view_states
1229 .get_mut(&leaf_id)
1230 .map(|vs| &mut vs.cursors)
1231 {
1232 state.apply(cursors, &event);
1233 }
1234 }
1235
1236 self.handle_action(Action::SelectWord)?;
1238
1239 if let Some(cursor) = self
1241 .split_view_states
1242 .get(&leaf_id)
1243 .map(|vs| vs.cursors.primary())
1244 {
1245 let sel_start = cursor.selection_start();
1248 let sel_end = cursor.selection_end();
1249 self.mouse_state.dragging_text_selection = true;
1250 self.mouse_state.drag_selection_split = Some(split_id);
1251 self.mouse_state.drag_selection_anchor = Some(sel_start);
1252 self.mouse_state.drag_selection_by_words = true;
1253 self.mouse_state.drag_selection_word_end = Some(sel_end);
1254 }
1255
1256 Ok(())
1257 }
1258 pub(super) fn handle_mouse_triple_click(&mut self, col: u16, row: u16) -> AnyhowResult<()> {
1261 tracing::debug!("handle_mouse_triple_click at col={}, row={}", col, row);
1262
1263 if self.is_mouse_over_any_popup(col, row) {
1265 return Ok(());
1266 } else {
1267 self.dismiss_transient_popups();
1268 }
1269
1270 let split_areas = self.cached_layout.split_areas.clone();
1272 for (split_id, buffer_id, content_rect, _scrollbar_rect, _thumb_start, _thumb_end) in
1273 &split_areas
1274 {
1275 if col >= content_rect.x
1276 && col < content_rect.x + content_rect.width
1277 && row >= content_rect.y
1278 && row < content_rect.y + content_rect.height
1279 {
1280 if self.is_terminal_buffer(*buffer_id) {
1281 return Ok(());
1282 }
1283
1284 self.key_context = crate::input::keybindings::KeyContext::Normal;
1285
1286 self.handle_editor_triple_click(col, row, *split_id, *buffer_id, *content_rect)?;
1289 return Ok(());
1290 }
1291 }
1292
1293 Ok(())
1294 }
1295
1296 fn handle_editor_triple_click(
1298 &mut self,
1299 col: u16,
1300 row: u16,
1301 split_id: LeafId,
1302 buffer_id: BufferId,
1303 content_rect: ratatui::layout::Rect,
1304 ) -> AnyhowResult<()> {
1305 use crate::model::event::Event;
1306
1307 if self.is_non_scrollable_buffer(buffer_id) {
1308 return Ok(());
1309 }
1310
1311 self.focus_split(split_id, buffer_id);
1313
1314 let cached_mappings = self
1316 .cached_layout
1317 .view_line_mappings
1318 .get(&split_id)
1319 .cloned();
1320
1321 let leaf_id = split_id;
1322 let fallback = self
1323 .split_view_states
1324 .get(&leaf_id)
1325 .map(|vs| vs.viewport.top_byte)
1326 .unwrap_or(0);
1327
1328 let compose_width = self
1330 .split_view_states
1331 .get(&leaf_id)
1332 .and_then(|vs| vs.compose_width);
1333
1334 if let Some(state) = self.buffers.get_mut(&buffer_id) {
1336 let gutter_width = state.margins.left_total_width() as u16;
1337
1338 let Some(target_position) = super::click_geometry::screen_to_buffer_position(
1339 col,
1340 row,
1341 content_rect,
1342 gutter_width,
1343 &cached_mappings,
1344 fallback,
1345 true,
1346 compose_width,
1347 ) else {
1348 return Ok(());
1349 };
1350
1351 let primary_cursor_id = self
1353 .split_view_states
1354 .get(&leaf_id)
1355 .map(|vs| vs.cursors.primary_id())
1356 .unwrap_or(CursorId(0));
1357 let event = Event::MoveCursor {
1358 cursor_id: primary_cursor_id,
1359 old_position: 0,
1360 new_position: target_position,
1361 old_anchor: None,
1362 new_anchor: None,
1363 old_sticky_column: 0,
1364 new_sticky_column: 0,
1365 };
1366
1367 if let Some(event_log) = self.event_logs.get_mut(&buffer_id) {
1368 event_log.append(event.clone());
1369 }
1370 if let Some(cursors) = self
1371 .split_view_states
1372 .get_mut(&leaf_id)
1373 .map(|vs| &mut vs.cursors)
1374 {
1375 state.apply(cursors, &event);
1376 }
1377 }
1378
1379 self.handle_action(Action::SelectLine)?;
1381
1382 Ok(())
1383 }
1384
1385 pub(super) fn handle_mouse_click(
1387 &mut self,
1388 col: u16,
1389 row: u16,
1390 modifiers: crossterm::event::KeyModifiers,
1391 ) -> AnyhowResult<()> {
1392 if self.file_explorer_context_menu.is_some() {
1393 if let Some(result) = self.handle_file_explorer_context_menu_click(col, row) {
1394 return result;
1395 }
1396 }
1397
1398 if self.tab_context_menu.is_some() {
1400 if let Some(result) = self.handle_tab_context_menu_click(col, row) {
1401 return result;
1402 }
1403 }
1404
1405 if !self.is_mouse_over_any_popup(col, row) {
1408 self.dismiss_transient_popups();
1409 }
1410
1411 if let Some((inner_rect, start_idx, _visible_count, total_count)) =
1413 &self.cached_layout.suggestions_area.clone()
1414 {
1415 if col >= inner_rect.x
1416 && col < inner_rect.x + inner_rect.width
1417 && row >= inner_rect.y
1418 && row < inner_rect.y + inner_rect.height
1419 {
1420 let relative_row = (row - inner_rect.y) as usize;
1421 let item_idx = start_idx + relative_row;
1422
1423 if item_idx < *total_count {
1424 if let Some(prompt) = &mut self.prompt {
1426 prompt.selected_suggestion = Some(item_idx);
1427 }
1428 return self.handle_action(Action::PromptConfirm);
1430 }
1431 }
1432 }
1433
1434 let scrollbar_scroll_info: Option<(usize, i32)> =
1437 self.cached_layout.popup_areas.iter().rev().find_map(
1438 |(
1439 popup_idx,
1440 _popup_rect,
1441 inner_rect,
1442 _scroll_offset,
1443 _num_items,
1444 scrollbar_rect,
1445 total_lines,
1446 )| {
1447 let sb_rect = scrollbar_rect.as_ref()?;
1448 if col >= sb_rect.x
1449 && col < sb_rect.x + sb_rect.width
1450 && row >= sb_rect.y
1451 && row < sb_rect.y + sb_rect.height
1452 {
1453 let relative_row = (row - sb_rect.y) as usize;
1454 let track_height = sb_rect.height as usize;
1455 let visible_lines = inner_rect.height as usize;
1456
1457 if track_height > 0 && *total_lines > visible_lines {
1458 let max_scroll = total_lines.saturating_sub(visible_lines);
1459 let target_scroll = if track_height > 1 {
1460 (relative_row * max_scroll) / (track_height.saturating_sub(1))
1461 } else {
1462 0
1463 };
1464 Some((*popup_idx, target_scroll as i32))
1465 } else {
1466 Some((*popup_idx, 0))
1467 }
1468 } else {
1469 None
1470 }
1471 },
1472 );
1473
1474 if let Some((popup_idx, target_scroll)) = scrollbar_scroll_info {
1475 self.mouse_state.dragging_popup_scrollbar = Some(popup_idx);
1477 self.mouse_state.drag_start_row = Some(row);
1478 let current_scroll = self
1480 .active_state()
1481 .popups
1482 .get(popup_idx)
1483 .map(|p| p.scroll_offset)
1484 .unwrap_or(0);
1485 self.mouse_state.drag_start_popup_scroll = Some(current_scroll);
1486 let state = self.active_state_mut();
1488 if let Some(popup) = state.popups.get_mut(popup_idx) {
1489 let delta = target_scroll - current_scroll as i32;
1490 popup.scroll_by(delta);
1491 }
1492 return Ok(());
1493 }
1494
1495 for (popup_idx, popup_rect, inner_rect, scroll_offset, num_items) in
1499 self.cached_layout.global_popup_areas.clone().iter().rev()
1500 {
1501 if popup_rect.width >= 5 {
1502 let cb_x = popup_rect.x + popup_rect.width - 4;
1503 if row == popup_rect.y && col >= cb_x && col < cb_x + 3 {
1504 return self.handle_action(Action::PopupCancel);
1505 }
1506 }
1507 if col >= inner_rect.x
1508 && col < inner_rect.x + inner_rect.width
1509 && row >= inner_rect.y
1510 && row < inner_rect.y + inner_rect.height
1511 && *num_items > 0
1512 {
1513 let relative_row = (row - inner_rect.y) as usize;
1514 let item_idx = scroll_offset + relative_row;
1515 if item_idx < *num_items {
1516 if let Some(popup) = self.global_popups.get_mut(*popup_idx) {
1517 if let crate::view::popup::PopupContent::List { items: _, selected } =
1518 &mut popup.content
1519 {
1520 *selected = item_idx;
1521 }
1522 }
1523 return self.handle_action(Action::PopupConfirm);
1524 }
1525 }
1526 }
1527
1528 for (_popup_idx, popup_rect, _inner, _scroll, _n, _sb, _tl) in
1534 self.cached_layout.popup_areas.iter().rev()
1535 {
1536 if popup_rect.width < 5 {
1537 continue;
1538 }
1539 let cb_x = popup_rect.x + popup_rect.width - 4;
1540 if row == popup_rect.y && col >= cb_x && col < cb_x + 3 {
1541 return self.handle_action(Action::PopupCancel);
1542 }
1543 }
1544
1545 for (popup_idx, _popup_rect, inner_rect, scroll_offset, num_items, _, _) in
1547 self.cached_layout.popup_areas.iter().rev()
1548 {
1549 if col >= inner_rect.x
1550 && col < inner_rect.x + inner_rect.width
1551 && row >= inner_rect.y
1552 && row < inner_rect.y + inner_rect.height
1553 {
1554 let relative_col = (col - inner_rect.x) as usize;
1556 let relative_row = (row - inner_rect.y) as usize;
1557
1558 let link_url = {
1560 let state = self.active_state();
1561 state
1562 .popups
1563 .top()
1564 .and_then(|popup| popup.link_at_position(relative_col, relative_row))
1565 };
1566
1567 if let Some(url) = link_url {
1568 #[cfg(feature = "runtime")]
1570 if let Err(e) = open::that(&url) {
1571 self.set_status_message(format!("Failed to open URL: {}", e));
1572 } else {
1573 self.set_status_message(format!("Opening: {}", url));
1574 }
1575 return Ok(());
1576 }
1577
1578 if *num_items > 0 {
1580 let item_idx = scroll_offset + relative_row;
1581
1582 if item_idx < *num_items {
1583 let state = self.active_state_mut();
1585 if let Some(popup) = state.popups.top_mut() {
1586 if let crate::view::popup::PopupContent::List { items: _, selected } =
1587 &mut popup.content
1588 {
1589 *selected = item_idx;
1590 }
1591 }
1592 return self.handle_action(Action::PopupConfirm);
1594 }
1595 }
1596
1597 let is_text_popup = {
1599 let state = self.active_state();
1600 state.popups.top().is_some_and(|p| {
1601 matches!(
1602 p.content,
1603 crate::view::popup::PopupContent::Text(_)
1604 | crate::view::popup::PopupContent::Markdown(_)
1605 )
1606 })
1607 };
1608
1609 if is_text_popup {
1610 let line = scroll_offset + relative_row;
1611 let popup_idx_copy = *popup_idx; let state = self.active_state_mut();
1613 if let Some(popup) = state.popups.top_mut() {
1614 popup.start_selection(line, relative_col);
1615 }
1616 self.mouse_state.selecting_in_popup = Some(popup_idx_copy);
1618 return Ok(());
1619 }
1620 }
1621 }
1622
1623 if self.is_mouse_over_any_popup(col, row) {
1626 return Ok(());
1627 }
1628
1629 if self.is_file_open_active() && self.handle_file_open_click(col, row) {
1631 return Ok(());
1632 }
1633
1634 if self.menu_bar_visible {
1636 if let Some(ref menu_layout) = self.cached_layout.menu_layout {
1637 if let Some(menu_idx) = menu_layout.menu_at(col, row) {
1638 if self.menu_state.active_menu == Some(menu_idx) {
1640 self.close_menu_with_auto_hide();
1641 } else {
1642 self.on_editor_focus_lost();
1644 self.menu_state.open_menu(menu_idx);
1645 }
1646 return Ok(());
1647 } else if row == 0 {
1648 self.close_menu_with_auto_hide();
1650 return Ok(());
1651 }
1652 }
1653 }
1654
1655 if let Some(active_idx) = self.menu_state.active_menu {
1657 let all_menus: Vec<crate::config::Menu> = self
1658 .menus
1659 .menus
1660 .iter()
1661 .chain(self.menu_state.plugin_menus.iter())
1662 .cloned()
1663 .collect();
1664
1665 if let Some(menu) = all_menus.get(active_idx) {
1666 if let Some(click_result) = self.handle_menu_dropdown_click(col, row, menu)? {
1668 return click_result;
1669 }
1670 }
1671
1672 self.close_menu_with_auto_hide();
1674 return Ok(());
1675 }
1676
1677 if let Some(explorer_area) = self.cached_layout.file_explorer_area {
1681 let border_x = explorer_area.x + explorer_area.width.saturating_sub(1);
1682 if col == border_x
1683 && row >= explorer_area.y
1684 && row < explorer_area.y + explorer_area.height
1685 {
1686 self.mouse_state.dragging_file_explorer = true;
1687 self.mouse_state.drag_start_position = Some((col, row));
1688 self.mouse_state.drag_start_explorer_width = Some(self.file_explorer_width);
1689 return Ok(());
1690 }
1691 }
1692
1693 if let Some(explorer_area) = self.cached_layout.file_explorer_area {
1695 if col >= explorer_area.x
1696 && col < explorer_area.x + explorer_area.width
1697 && row >= explorer_area.y
1698 && row < explorer_area.y + explorer_area.height
1699 {
1700 self.handle_file_explorer_click(col, row, explorer_area)?;
1701 return Ok(());
1702 }
1703 }
1704
1705 let scrollbar_hit = self.cached_layout.split_areas.iter().find_map(
1707 |(split_id, buffer_id, _content_rect, scrollbar_rect, thumb_start, thumb_end)| {
1708 if col >= scrollbar_rect.x
1709 && col < scrollbar_rect.x + scrollbar_rect.width
1710 && row >= scrollbar_rect.y
1711 && row < scrollbar_rect.y + scrollbar_rect.height
1712 {
1713 let relative_row = row.saturating_sub(scrollbar_rect.y) as usize;
1714 let is_on_thumb = relative_row >= *thumb_start && relative_row < *thumb_end;
1715 Some((*split_id, *buffer_id, *scrollbar_rect, is_on_thumb))
1716 } else {
1717 None
1718 }
1719 },
1720 );
1721
1722 if let Some((split_id, buffer_id, scrollbar_rect, is_on_thumb)) = scrollbar_hit {
1723 self.focus_split(split_id, buffer_id);
1724
1725 if is_on_thumb {
1726 self.mouse_state.dragging_scrollbar = Some(split_id);
1728 self.mouse_state.drag_start_row = Some(row);
1729 if self.is_composite_buffer(buffer_id) {
1731 if let Some(view_state) = self.composite_view_states.get(&(split_id, buffer_id))
1733 {
1734 self.mouse_state.drag_start_composite_scroll_row =
1735 Some(view_state.scroll_row);
1736 }
1737 } else if let Some(view_state) = self.split_view_states.get(&split_id) {
1738 self.mouse_state.drag_start_top_byte = Some(view_state.viewport.top_byte);
1739 self.mouse_state.drag_start_view_line_offset =
1740 Some(view_state.viewport.top_view_line_offset);
1741 }
1742 } else {
1743 self.mouse_state.dragging_scrollbar = Some(split_id);
1745 self.handle_scrollbar_jump(col, row, split_id, buffer_id, scrollbar_rect)?;
1746 self.mouse_state.hover_target = Some(HoverTarget::ScrollbarThumb(split_id));
1749 }
1750 return Ok(());
1751 }
1752
1753 let hscrollbar_hit = self
1755 .cached_layout
1756 .horizontal_scrollbar_areas
1757 .iter()
1758 .find_map(
1759 |(
1760 split_id,
1761 buffer_id,
1762 hscrollbar_rect,
1763 max_content_width,
1764 thumb_start,
1765 thumb_end,
1766 )| {
1767 if col >= hscrollbar_rect.x
1768 && col < hscrollbar_rect.x + hscrollbar_rect.width
1769 && row >= hscrollbar_rect.y
1770 && row < hscrollbar_rect.y + hscrollbar_rect.height
1771 {
1772 let relative_col = col.saturating_sub(hscrollbar_rect.x) as usize;
1773 let is_on_thumb = relative_col >= *thumb_start && relative_col < *thumb_end;
1774 Some((
1775 *split_id,
1776 *buffer_id,
1777 *hscrollbar_rect,
1778 *max_content_width,
1779 is_on_thumb,
1780 ))
1781 } else {
1782 None
1783 }
1784 },
1785 );
1786
1787 if let Some((split_id, buffer_id, hscrollbar_rect, max_content_width, is_on_thumb)) =
1788 hscrollbar_hit
1789 {
1790 self.focus_split(split_id, buffer_id);
1791 self.mouse_state.dragging_horizontal_scrollbar = Some(split_id);
1792
1793 if is_on_thumb {
1794 self.mouse_state.drag_start_hcol = Some(col);
1796 if let Some(view_state) = self.split_view_states.get(&split_id) {
1797 self.mouse_state.drag_start_left_column = Some(view_state.viewport.left_column);
1798 }
1799 } else {
1800 self.mouse_state.drag_start_hcol = None;
1802 self.mouse_state.drag_start_left_column = None;
1803
1804 let relative_col = col.saturating_sub(hscrollbar_rect.x) as f64;
1805 let track_width = hscrollbar_rect.width as f64;
1806 let ratio = if track_width > 1.0 {
1807 (relative_col / (track_width - 1.0)).clamp(0.0, 1.0)
1808 } else {
1809 0.0
1810 };
1811
1812 if let Some(view_state) = self.split_view_states.get_mut(&split_id) {
1813 let visible_width = view_state.viewport.width as usize;
1814 let max_scroll = max_content_width.saturating_sub(visible_width);
1815 let target_col = (ratio * max_scroll as f64).round() as usize;
1816 view_state.viewport.left_column = target_col.min(max_scroll);
1817 view_state.viewport.set_skip_ensure_visible();
1818 }
1819 }
1820
1821 return Ok(());
1822 }
1823
1824 if let Some((status_row, _status_x, _status_width)) = self.cached_layout.status_bar_area {
1826 if row == status_row {
1827 if let Some((le_row, le_start, le_end)) =
1829 self.cached_layout.status_bar_line_ending_area
1830 {
1831 if row == le_row && col >= le_start && col < le_end {
1832 return self.handle_action(Action::SetLineEnding);
1833 }
1834 }
1835
1836 if let Some((enc_row, enc_start, enc_end)) =
1838 self.cached_layout.status_bar_encoding_area
1839 {
1840 if row == enc_row && col >= enc_start && col < enc_end {
1841 return self.handle_action(Action::SetEncoding);
1842 }
1843 }
1844
1845 if let Some((lang_row, lang_start, lang_end)) =
1847 self.cached_layout.status_bar_language_area
1848 {
1849 if row == lang_row && col >= lang_start && col < lang_end {
1850 return self.handle_action(Action::SetLanguage);
1851 }
1852 }
1853
1854 if let Some((lsp_row, lsp_start, lsp_end)) = self.cached_layout.status_bar_lsp_area
1856 {
1857 if row == lsp_row && col >= lsp_start && col < lsp_end {
1858 return self.handle_action(Action::ShowLspStatus);
1859 }
1860 }
1861
1862 if let Some((rem_row, rem_start, rem_end)) =
1864 self.cached_layout.status_bar_remote_area
1865 {
1866 if row == rem_row && col >= rem_start && col < rem_end {
1867 return self.handle_action(Action::ShowRemoteIndicatorMenu);
1868 }
1869 }
1870
1871 if let Some((warn_row, warn_start, warn_end)) =
1873 self.cached_layout.status_bar_warning_area
1874 {
1875 if row == warn_row && col >= warn_start && col < warn_end {
1876 return self.handle_action(Action::ShowWarnings);
1877 }
1878 }
1879
1880 if let Some((msg_row, msg_start, msg_end)) =
1882 self.cached_layout.status_bar_message_area
1883 {
1884 if row == msg_row && col >= msg_start && col < msg_end {
1885 return self.handle_action(Action::ShowStatusLog);
1886 }
1887 }
1888 }
1889 }
1890
1891 if let Some(ref layout) = self.cached_layout.search_options_layout.clone() {
1893 use crate::view::ui::status_bar::SearchOptionsHover;
1894 if let Some(hover) = layout.checkbox_at(col, row) {
1895 match hover {
1896 SearchOptionsHover::CaseSensitive => {
1897 return self.handle_action(Action::ToggleSearchCaseSensitive);
1898 }
1899 SearchOptionsHover::WholeWord => {
1900 return self.handle_action(Action::ToggleSearchWholeWord);
1901 }
1902 SearchOptionsHover::Regex => {
1903 return self.handle_action(Action::ToggleSearchRegex);
1904 }
1905 SearchOptionsHover::ConfirmEach => {
1906 return self.handle_action(Action::ToggleSearchConfirmEach);
1907 }
1908 SearchOptionsHover::None => {}
1909 }
1910 }
1911 }
1912
1913 for (split_id, direction, sep_x, sep_y, sep_length) in &self.cached_layout.separator_areas {
1915 let is_on_separator = match direction {
1916 SplitDirection::Horizontal => {
1917 row == *sep_y && col >= *sep_x && col < sep_x + sep_length
1919 }
1920 SplitDirection::Vertical => {
1921 col == *sep_x && row >= *sep_y && row < sep_y + sep_length
1923 }
1924 };
1925
1926 if is_on_separator {
1927 self.mouse_state.dragging_separator = Some((*split_id, *direction));
1929 self.mouse_state.drag_start_position = Some((col, row));
1930 let ratio = self
1934 .split_manager
1935 .get_ratio((*split_id).into())
1936 .or_else(|| self.grouped_split_ratio(*split_id));
1937 if let Some(ratio) = ratio {
1938 self.mouse_state.drag_start_ratio = Some(ratio);
1939 }
1940 return Ok(());
1941 }
1942 }
1943
1944 let close_split_click = self
1946 .cached_layout
1947 .close_split_areas
1948 .iter()
1949 .find(|(_, btn_row, start_col, end_col)| {
1950 row == *btn_row && col >= *start_col && col < *end_col
1951 })
1952 .map(|(split_id, _, _, _)| *split_id);
1953
1954 if let Some(split_id) = close_split_click {
1955 if let Err(e) = self.split_manager.close_split(split_id) {
1956 self.set_status_message(
1957 t!("error.cannot_close_split", error = e.to_string()).to_string(),
1958 );
1959 } else {
1960 let new_active_split = self.split_manager.active_split();
1962 if let Some(buffer_id) = self.split_manager.buffer_for_split(new_active_split) {
1963 self.set_active_buffer(buffer_id);
1964 }
1965 self.set_status_message(t!("split.closed").to_string());
1966 }
1967 return Ok(());
1968 }
1969
1970 let maximize_split_click = self
1972 .cached_layout
1973 .maximize_split_areas
1974 .iter()
1975 .find(|(_, btn_row, start_col, end_col)| {
1976 row == *btn_row && col >= *start_col && col < *end_col
1977 })
1978 .map(|(split_id, _, _, _)| *split_id);
1979
1980 if let Some(_split_id) = maximize_split_click {
1981 match self.split_manager.toggle_maximize() {
1983 Ok(maximized) => {
1984 if maximized {
1985 self.set_status_message(t!("split.maximized").to_string());
1986 } else {
1987 self.set_status_message(t!("split.restored").to_string());
1988 }
1989 }
1990 Err(e) => self.set_status_message(e),
1991 }
1992 return Ok(());
1993 }
1994
1995 for (split_id, tab_layout) in &self.cached_layout.tab_layouts {
1998 tracing::debug!(
1999 "Tab layout for split {:?}: bar_area={:?}, left_scroll={:?}, right_scroll={:?}",
2000 split_id,
2001 tab_layout.bar_area,
2002 tab_layout.left_scroll_area,
2003 tab_layout.right_scroll_area
2004 );
2005 }
2006
2007 let tab_hit = self
2008 .cached_layout
2009 .tab_layouts
2010 .iter()
2011 .find_map(|(split_id, tab_layout)| {
2012 let hit = tab_layout.hit_test(col, row);
2013 tracing::debug!(
2014 "Tab hit_test at ({}, {}) for split {:?} returned {:?}",
2015 col,
2016 row,
2017 split_id,
2018 hit
2019 );
2020 hit.map(|h| (*split_id, h))
2021 });
2022
2023 if let Some((split_id, hit)) = tab_hit {
2024 match hit {
2025 TabHit::CloseButton(target) => {
2026 match target {
2027 crate::view::split::TabTarget::Buffer(buffer_id) => {
2028 self.focus_split(split_id, buffer_id);
2029 self.close_tab_in_split(buffer_id, split_id);
2030 }
2031 crate::view::split::TabTarget::Group(group_leaf) => {
2032 self.close_buffer_group_by_leaf(group_leaf);
2033 }
2034 }
2035 return Ok(());
2036 }
2037 TabHit::TabName(target) => {
2038 match target {
2039 crate::view::split::TabTarget::Buffer(buffer_id) => {
2040 self.focus_split(split_id, buffer_id);
2041 self.promote_buffer_from_preview(buffer_id);
2046 self.mouse_state.dragging_tab = Some(super::types::TabDragState::new(
2048 buffer_id,
2049 split_id,
2050 (col, row),
2051 ));
2052 }
2053 crate::view::split::TabTarget::Group(group_leaf) => {
2054 self.activate_group_tab(split_id, group_leaf);
2059 }
2060 }
2061 return Ok(());
2062 }
2063 TabHit::ScrollLeft => {
2064 self.set_status_message("ScrollLeft clicked!".to_string());
2066 if let Some(view_state) = self.split_view_states.get_mut(&split_id) {
2067 view_state.tab_scroll_offset =
2068 view_state.tab_scroll_offset.saturating_sub(10);
2069 }
2070 return Ok(());
2071 }
2072 TabHit::ScrollRight => {
2073 self.set_status_message("ScrollRight clicked!".to_string());
2075 if let Some(view_state) = self.split_view_states.get_mut(&split_id) {
2076 view_state.tab_scroll_offset =
2077 view_state.tab_scroll_offset.saturating_add(10);
2078 }
2079 return Ok(());
2080 }
2081 TabHit::BarBackground => {}
2082 }
2083 }
2084
2085 tracing::debug!(
2087 "handle_mouse_click: checking {} split_areas for click at ({}, {})",
2088 self.cached_layout.split_areas.len(),
2089 col,
2090 row
2091 );
2092 for (split_id, buffer_id, content_rect, _scrollbar_rect, _thumb_start, _thumb_end) in
2093 &self.cached_layout.split_areas
2094 {
2095 tracing::debug!(
2096 " split_id={:?}, content_rect=({}, {}, {}x{})",
2097 split_id,
2098 content_rect.x,
2099 content_rect.y,
2100 content_rect.width,
2101 content_rect.height
2102 );
2103 if col >= content_rect.x
2104 && col < content_rect.x + content_rect.width
2105 && row >= content_rect.y
2106 && row < content_rect.y + content_rect.height
2107 {
2108 tracing::debug!(" -> HIT! calling handle_editor_click");
2110 self.handle_editor_click(
2111 col,
2112 row,
2113 *split_id,
2114 *buffer_id,
2115 *content_rect,
2116 modifiers,
2117 )?;
2118 return Ok(());
2119 }
2120 }
2121 tracing::debug!(" -> No split area hit");
2122
2123 Ok(())
2124 }
2125
2126 pub(super) fn handle_mouse_drag(&mut self, col: u16, row: u16) -> AnyhowResult<()> {
2128 if let Some(dragging_split_id) = self.mouse_state.dragging_scrollbar {
2130 for (split_id, buffer_id, _content_rect, scrollbar_rect, _thumb_start, _thumb_end) in
2132 &self.cached_layout.split_areas
2133 {
2134 if *split_id == dragging_split_id {
2135 if self.mouse_state.drag_start_row.is_some() {
2137 self.handle_scrollbar_drag_relative(
2139 row,
2140 *split_id,
2141 *buffer_id,
2142 *scrollbar_rect,
2143 )?;
2144 } else {
2145 self.handle_scrollbar_jump(
2147 col,
2148 row,
2149 *split_id,
2150 *buffer_id,
2151 *scrollbar_rect,
2152 )?;
2153 }
2154 return Ok(());
2155 }
2156 }
2157 }
2158
2159 if let Some(dragging_split_id) = self.mouse_state.dragging_horizontal_scrollbar {
2161 for (
2162 split_id,
2163 _buffer_id,
2164 hscrollbar_rect,
2165 max_content_width,
2166 thumb_start,
2167 thumb_end,
2168 ) in &self.cached_layout.horizontal_scrollbar_areas
2169 {
2170 if *split_id == dragging_split_id {
2171 let track_width = hscrollbar_rect.width as f64;
2172 if track_width <= 1.0 {
2173 break;
2174 }
2175
2176 if let (Some(drag_start_hcol), Some(drag_start_left_column)) = (
2177 self.mouse_state.drag_start_hcol,
2178 self.mouse_state.drag_start_left_column,
2179 ) {
2180 let col_offset = (col as i32) - (drag_start_hcol as i32);
2183 if let Some(view_state) = self.split_view_states.get_mut(&dragging_split_id)
2184 {
2185 let visible_width = view_state.viewport.width as usize;
2186 let max_scroll = max_content_width.saturating_sub(visible_width);
2187 if max_scroll > 0 {
2188 let thumb_size = thumb_end.saturating_sub(*thumb_start).max(1);
2189 let track_travel = (track_width - thumb_size as f64).max(1.0);
2190 let scroll_per_pixel = max_scroll as f64 / track_travel;
2191 let scroll_offset =
2192 (col_offset as f64 * scroll_per_pixel).round() as i64;
2193 let new_left =
2194 (drag_start_left_column as i64 + scroll_offset).max(0) as usize;
2195 view_state.viewport.left_column = new_left.min(max_scroll);
2196 view_state.viewport.set_skip_ensure_visible();
2197 }
2198 }
2199 } else {
2200 let relative_col = col.saturating_sub(hscrollbar_rect.x) as f64;
2202 let ratio = (relative_col / (track_width - 1.0)).clamp(0.0, 1.0);
2203
2204 if let Some(view_state) = self.split_view_states.get_mut(&dragging_split_id)
2205 {
2206 let visible_width = view_state.viewport.width as usize;
2207 let max_scroll = max_content_width.saturating_sub(visible_width);
2208 let target_col = (ratio * max_scroll as f64).round() as usize;
2209 view_state.viewport.left_column = target_col.min(max_scroll);
2210 view_state.viewport.set_skip_ensure_visible();
2211 }
2212 }
2213
2214 return Ok(());
2215 }
2216 }
2217 }
2218
2219 if let Some(popup_idx) = self.mouse_state.selecting_in_popup {
2221 if let Some((_, _, inner_rect, scroll_offset, _, _, _)) = self
2223 .cached_layout
2224 .popup_areas
2225 .iter()
2226 .find(|(idx, _, _, _, _, _, _)| *idx == popup_idx)
2227 {
2228 if col >= inner_rect.x
2230 && col < inner_rect.x + inner_rect.width
2231 && row >= inner_rect.y
2232 && row < inner_rect.y + inner_rect.height
2233 {
2234 let relative_col = (col - inner_rect.x) as usize;
2235 let relative_row = (row - inner_rect.y) as usize;
2236 let line = scroll_offset + relative_row;
2237
2238 let state = self.active_state_mut();
2239 if let Some(popup) = state.popups.get_mut(popup_idx) {
2240 popup.extend_selection(line, relative_col);
2241 }
2242 }
2243 }
2244 return Ok(());
2245 }
2246
2247 if let Some(popup_idx) = self.mouse_state.dragging_popup_scrollbar {
2249 if let Some((_, _, inner_rect, _, _, Some(sb_rect), total_lines)) = self
2251 .cached_layout
2252 .popup_areas
2253 .iter()
2254 .find(|(idx, _, _, _, _, _, _)| *idx == popup_idx)
2255 {
2256 let track_height = sb_rect.height as usize;
2257 let visible_lines = inner_rect.height as usize;
2258
2259 if track_height > 0 && *total_lines > visible_lines {
2260 let relative_row = row.saturating_sub(sb_rect.y) as usize;
2261 let max_scroll = total_lines.saturating_sub(visible_lines);
2262 let target_scroll = if track_height > 1 {
2263 (relative_row * max_scroll) / (track_height.saturating_sub(1))
2264 } else {
2265 0
2266 };
2267
2268 let state = self.active_state_mut();
2269 if let Some(popup) = state.popups.get_mut(popup_idx) {
2270 let current_scroll = popup.scroll_offset as i32;
2271 let delta = target_scroll as i32 - current_scroll;
2272 popup.scroll_by(delta);
2273 }
2274 }
2275 }
2276 return Ok(());
2277 }
2278
2279 if let Some((split_id, direction)) = self.mouse_state.dragging_separator {
2281 self.handle_separator_drag(col, row, split_id, direction)?;
2282 return Ok(());
2283 }
2284
2285 if self.mouse_state.dragging_file_explorer {
2287 self.handle_file_explorer_border_drag(col)?;
2288 return Ok(());
2289 }
2290
2291 if self.mouse_state.dragging_text_selection {
2293 self.handle_text_selection_drag(col, row)?;
2294 return Ok(());
2295 }
2296
2297 if self.mouse_state.dragging_tab.is_some() {
2299 self.handle_tab_drag(col, row)?;
2300 return Ok(());
2301 }
2302
2303 Ok(())
2304 }
2305
2306 fn handle_text_selection_drag(&mut self, col: u16, row: u16) -> AnyhowResult<()> {
2308 use crate::model::event::Event;
2309 use crate::primitives::word_navigation::{find_word_end, find_word_start};
2310
2311 let Some(split_id) = self.mouse_state.drag_selection_split else {
2312 return Ok(());
2313 };
2314 let Some(anchor_position) = self.mouse_state.drag_selection_anchor else {
2315 return Ok(());
2316 };
2317
2318 let buffer_id = self
2320 .cached_layout
2321 .split_areas
2322 .iter()
2323 .find(|(sid, _, _, _, _, _)| *sid == split_id)
2324 .map(|(_, bid, _, _, _, _)| *bid);
2325
2326 let Some(buffer_id) = buffer_id else {
2327 return Ok(());
2328 };
2329
2330 let content_rect = self
2332 .cached_layout
2333 .split_areas
2334 .iter()
2335 .find(|(sid, _, _, _, _, _)| *sid == split_id)
2336 .map(|(_, _, rect, _, _, _)| *rect);
2337
2338 let Some(content_rect) = content_rect else {
2339 return Ok(());
2340 };
2341
2342 let cached_mappings = self
2344 .cached_layout
2345 .view_line_mappings
2346 .get(&split_id)
2347 .cloned();
2348
2349 let leaf_id = split_id;
2350
2351 let fallback = self
2353 .split_view_states
2354 .get(&leaf_id)
2355 .map(|vs| vs.viewport.top_byte)
2356 .unwrap_or(0);
2357
2358 let compose_width = self
2360 .split_view_states
2361 .get(&leaf_id)
2362 .and_then(|vs| vs.compose_width);
2363
2364 if let Some(state) = self.buffers.get_mut(&buffer_id) {
2366 let gutter_width = state.margins.left_total_width() as u16;
2367
2368 let Some(target_position) = super::click_geometry::screen_to_buffer_position(
2369 col,
2370 row,
2371 content_rect,
2372 gutter_width,
2373 &cached_mappings,
2374 fallback,
2375 true, compose_width,
2377 ) else {
2378 return Ok(());
2379 };
2380
2381 let (new_position, anchor_position) = if self.mouse_state.drag_selection_by_words {
2386 if target_position >= anchor_position {
2387 (
2388 find_word_end(&state.buffer, target_position),
2389 anchor_position,
2390 )
2391 } else {
2392 let word_end = self
2393 .mouse_state
2394 .drag_selection_word_end
2395 .unwrap_or(anchor_position);
2396 (find_word_start(&state.buffer, target_position), word_end)
2397 }
2398 } else {
2399 (target_position, anchor_position)
2400 };
2401
2402 let (primary_cursor_id, old_position, old_anchor, old_sticky_column) = self
2403 .split_view_states
2404 .get(&leaf_id)
2405 .map(|vs| {
2406 let cursor = vs.cursors.primary();
2407 (
2408 vs.cursors.primary_id(),
2409 cursor.position,
2410 cursor.anchor,
2411 cursor.sticky_column,
2412 )
2413 })
2414 .unwrap_or((CursorId(0), 0, None, 0));
2415
2416 let new_sticky_column = state
2417 .buffer
2418 .offset_to_position(new_position)
2419 .map(|pos| pos.column)
2420 .unwrap_or(old_sticky_column);
2421 let event = Event::MoveCursor {
2422 cursor_id: primary_cursor_id,
2423 old_position,
2424 new_position,
2425 old_anchor,
2426 new_anchor: Some(anchor_position), old_sticky_column,
2428 new_sticky_column,
2429 };
2430
2431 if let Some(event_log) = self.event_logs.get_mut(&buffer_id) {
2432 event_log.append(event.clone());
2433 }
2434 if let Some(cursors) = self
2435 .split_view_states
2436 .get_mut(&leaf_id)
2437 .map(|vs| &mut vs.cursors)
2438 {
2439 state.apply(cursors, &event);
2440 }
2441 }
2442
2443 Ok(())
2444 }
2445
2446 pub(super) fn handle_file_explorer_border_drag(&mut self, col: u16) -> AnyhowResult<()> {
2448 let Some((start_col, _start_row)) = self.mouse_state.drag_start_position else {
2449 return Ok(());
2450 };
2451 let Some(start_width) = self.mouse_state.drag_start_explorer_width else {
2452 return Ok(());
2453 };
2454
2455 let delta = col as i32 - start_col as i32;
2456 let total_width = self.terminal_width as i32;
2457
2458 if total_width > 0 {
2462 use crate::config::ExplorerWidth;
2463 self.file_explorer_width = match start_width {
2464 ExplorerWidth::Percent(start_pct) => {
2465 let percent_delta = (delta * 100) / total_width;
2466 let new_pct = (start_pct as i32 + percent_delta).clamp(0, 100) as u8;
2467 ExplorerWidth::Percent(new_pct)
2468 }
2469 ExplorerWidth::Columns(start_cols) => {
2470 let new_cols = (start_cols as i32 + delta).clamp(0, total_width) as u16;
2471 ExplorerWidth::Columns(new_cols)
2472 }
2473 };
2474 }
2475
2476 Ok(())
2477 }
2478
2479 pub(super) fn handle_separator_drag(
2481 &mut self,
2482 col: u16,
2483 row: u16,
2484 split_id: ContainerId,
2485 direction: SplitDirection,
2486 ) -> AnyhowResult<()> {
2487 let Some((start_col, start_row)) = self.mouse_state.drag_start_position else {
2488 return Ok(());
2489 };
2490 let Some(start_ratio) = self.mouse_state.drag_start_ratio else {
2491 return Ok(());
2492 };
2493 let Some(editor_area) = self.cached_layout.editor_content_area else {
2494 return Ok(());
2495 };
2496
2497 let (delta, total_size) = match direction {
2499 SplitDirection::Horizontal => {
2500 let delta = row as i32 - start_row as i32;
2502 let total = editor_area.height as i32;
2503 (delta, total)
2504 }
2505 SplitDirection::Vertical => {
2506 let delta = col as i32 - start_col as i32;
2508 let total = editor_area.width as i32;
2509 (delta, total)
2510 }
2511 };
2512
2513 if total_size > 0 {
2516 let ratio_delta = delta as f32 / total_size as f32;
2517 let new_ratio = (start_ratio + ratio_delta).clamp(0.1, 0.9);
2518
2519 if self.split_manager.get_ratio(split_id.into()).is_some() {
2524 self.split_manager.set_ratio(split_id, new_ratio);
2525 } else {
2526 self.set_grouped_split_ratio(split_id, new_ratio);
2527 }
2528 }
2529
2530 Ok(())
2531 }
2532
2533 pub(super) fn handle_right_click(&mut self, col: u16, row: u16) -> AnyhowResult<()> {
2535 if let Some(ref menu) = self.file_explorer_context_menu {
2536 let (menu_x, menu_y) = menu.clamped_position(
2537 self.cached_layout.last_frame_width,
2538 self.cached_layout.last_frame_height,
2539 );
2540 let menu_width = super::types::FILE_EXPLORER_CONTEXT_MENU_WIDTH;
2541 let menu_height = menu.height();
2542 if col >= menu_x
2543 && col < menu_x + menu_width
2544 && row >= menu_y
2545 && row < menu_y + menu_height
2546 {
2547 return Ok(());
2548 }
2549 }
2550
2551 if let Some(ref menu) = self.tab_context_menu {
2553 let menu_x = menu.position.0;
2554 let menu_y = menu.position.1;
2555 let menu_width = 22u16; let menu_height = super::types::TabContextMenuItem::all().len() as u16 + 2; if col >= menu_x
2560 && col < menu_x + menu_width
2561 && row >= menu_y
2562 && row < menu_y + menu_height
2563 {
2564 return Ok(());
2566 }
2567 }
2568
2569 if let Some(explorer_area) = self.cached_layout.file_explorer_area {
2570 if col >= explorer_area.x
2571 && col < explorer_area.x + explorer_area.width
2572 && row < explorer_area.y + explorer_area.height
2573 && row > explorer_area.y
2574 {
2576 let relative_row = row.saturating_sub(explorer_area.y + 1);
2577 let (is_multi, is_root_selected) =
2578 if let Some(ref mut explorer) = self.file_explorer {
2579 let display_nodes = explorer.get_display_nodes();
2580 let scroll_offset = explorer.get_scroll_offset();
2581 let clicked_index = (relative_row as usize) + scroll_offset;
2582 let mut clicked_is_root = false;
2583 if clicked_index < display_nodes.len() {
2584 let (node_id, _) = display_nodes[clicked_index];
2585 explorer.set_selected(Some(node_id));
2586 clicked_is_root = node_id == explorer.tree().root_id();
2587 }
2588 (explorer.has_multi_selection(), clicked_is_root)
2589 } else {
2590 (false, false)
2591 };
2592 self.key_context = crate::input::keybindings::KeyContext::FileExplorer;
2593 self.tab_context_menu = None;
2594 self.file_explorer_context_menu = Some(super::types::FileExplorerContextMenu::new(
2595 col,
2596 row + 1,
2597 is_multi,
2598 is_root_selected,
2599 ));
2600 return Ok(());
2601 }
2602 }
2603
2604 self.file_explorer_context_menu = None;
2605
2606 let tab_hit =
2608 self.cached_layout.tab_layouts.iter().find_map(
2609 |(split_id, tab_layout)| match tab_layout.hit_test(col, row) {
2610 Some(TabHit::TabName(target) | TabHit::CloseButton(target)) => {
2611 target.as_buffer().map(|bid| (*split_id, bid))
2614 }
2615 _ => None,
2616 },
2617 );
2618
2619 if let Some((split_id, buffer_id)) = tab_hit {
2620 self.tab_context_menu = Some(TabContextMenu::new(buffer_id, split_id, col, row + 1));
2622 } else {
2623 self.tab_context_menu = None;
2625 }
2626
2627 Ok(())
2628 }
2629
2630 pub(super) fn handle_tab_context_menu_click(
2632 &mut self,
2633 col: u16,
2634 row: u16,
2635 ) -> Option<AnyhowResult<()>> {
2636 let menu = self.tab_context_menu.as_ref()?;
2637 let menu_x = menu.position.0;
2638 let menu_y = menu.position.1;
2639 let menu_width = 22u16;
2640 let items = super::types::TabContextMenuItem::all();
2641 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
2645 {
2646 self.tab_context_menu = None;
2648 return Some(Ok(()));
2649 }
2650
2651 if row == menu_y || row == menu_y + menu_height - 1 {
2653 return Some(Ok(()));
2654 }
2655
2656 let item_idx = (row - menu_y - 1) as usize;
2658 if item_idx >= items.len() {
2659 return Some(Ok(()));
2660 }
2661
2662 let buffer_id = menu.buffer_id;
2664 let split_id = menu.split_id;
2665 let item = items[item_idx];
2666
2667 self.tab_context_menu = None;
2669
2670 Some(self.execute_tab_context_menu_action(item, buffer_id, split_id))
2672 }
2673
2674 fn execute_tab_context_menu_action(
2676 &mut self,
2677 item: super::types::TabContextMenuItem,
2678 buffer_id: BufferId,
2679 leaf_id: LeafId,
2680 ) -> AnyhowResult<()> {
2681 use super::types::TabContextMenuItem;
2682 match item {
2683 TabContextMenuItem::Close => {
2684 self.close_tab_in_split(buffer_id, leaf_id);
2685 }
2686 TabContextMenuItem::CloseOthers => {
2687 self.close_other_tabs_in_split(buffer_id, leaf_id);
2688 }
2689 TabContextMenuItem::CloseToRight => {
2690 self.close_tabs_to_right_in_split(buffer_id, leaf_id);
2691 }
2692 TabContextMenuItem::CloseToLeft => {
2693 self.close_tabs_to_left_in_split(buffer_id, leaf_id);
2694 }
2695 TabContextMenuItem::CloseAll => {
2696 self.close_all_tabs_in_split(leaf_id);
2697 }
2698 }
2699
2700 Ok(())
2701 }
2702
2703 pub(super) fn handle_file_explorer_context_menu_key(
2706 &mut self,
2707 code: crossterm::event::KeyCode,
2708 modifiers: crossterm::event::KeyModifiers,
2709 ) -> Option<AnyhowResult<()>> {
2710 use crossterm::event::KeyCode;
2711 use crossterm::event::KeyModifiers;
2712
2713 if modifiers != KeyModifiers::NONE {
2714 return None;
2715 }
2716
2717 match code {
2718 KeyCode::Up => {
2719 if let Some(ref mut menu) = self.file_explorer_context_menu {
2720 menu.prev_item();
2721 }
2722 Some(Ok(()))
2723 }
2724 KeyCode::Down => {
2725 if let Some(ref mut menu) = self.file_explorer_context_menu {
2726 menu.next_item();
2727 }
2728 Some(Ok(()))
2729 }
2730 KeyCode::Enter => {
2731 let item = {
2732 let menu = self.file_explorer_context_menu.as_ref()?;
2733 menu.items()[menu.highlighted]
2734 };
2735 self.file_explorer_context_menu = None;
2736 self.execute_file_explorer_context_menu_action(item);
2737 Some(Ok(()))
2738 }
2739 KeyCode::Esc => {
2740 self.file_explorer_context_menu = None;
2741 Some(Ok(()))
2742 }
2743 _ => None,
2744 }
2745 }
2746
2747 pub(super) fn handle_file_explorer_context_menu_click(
2749 &mut self,
2750 col: u16,
2751 row: u16,
2752 ) -> Option<AnyhowResult<()>> {
2753 let clicked_item: Option<super::types::FileExplorerContextMenuItem> = {
2755 let menu = self.file_explorer_context_menu.as_ref()?;
2756 let (menu_x, menu_y) = menu.clamped_position(
2757 self.cached_layout.last_frame_width,
2758 self.cached_layout.last_frame_height,
2759 );
2760 let menu_width = super::types::FILE_EXPLORER_CONTEXT_MENU_WIDTH;
2761 let menu_height = menu.height();
2762
2763 if col < menu_x
2764 || col >= menu_x + menu_width
2765 || row < menu_y
2766 || row >= menu_y + menu_height
2767 {
2768 self.file_explorer_context_menu = None;
2769 return Some(Ok(()));
2770 }
2771
2772 if row == menu_y || row == menu_y + menu_height - 1 {
2773 return Some(Ok(()));
2774 }
2775
2776 let item_idx = (row - menu_y - 1) as usize;
2777 menu.items().get(item_idx).copied()
2778 };
2779
2780 self.file_explorer_context_menu = None;
2781 if let Some(item) = clicked_item {
2782 self.execute_file_explorer_context_menu_action(item);
2783 }
2784 Some(Ok(()))
2785 }
2786
2787 fn execute_file_explorer_context_menu_action(
2788 &mut self,
2789 item: super::types::FileExplorerContextMenuItem,
2790 ) {
2791 use super::types::FileExplorerContextMenuItem;
2792 match item {
2793 FileExplorerContextMenuItem::NewFile => self.file_explorer_new_file(),
2794 FileExplorerContextMenuItem::NewDirectory => self.file_explorer_new_directory(),
2795 FileExplorerContextMenuItem::Rename => self.file_explorer_rename(),
2796 FileExplorerContextMenuItem::Cut => self.file_explorer_cut(),
2797 FileExplorerContextMenuItem::Copy => self.file_explorer_copy(),
2798 FileExplorerContextMenuItem::Paste => self.file_explorer_paste(),
2799 FileExplorerContextMenuItem::Delete => self.file_explorer_delete(),
2800 }
2801 }
2802
2803 fn show_file_explorer_status_tooltip(&mut self, path: std::path::PathBuf, col: u16, row: u16) {
2805 use crate::view::popup::{Popup, PopupPosition};
2806 use ratatui::style::Style;
2807
2808 let is_directory = path.is_dir();
2809
2810 let decoration = self
2812 .file_explorer_decoration_cache
2813 .direct_for_path(&path)
2814 .cloned();
2815
2816 let bubbled_decoration = if is_directory && decoration.is_none() {
2818 self.file_explorer_decoration_cache
2819 .bubbled_for_path(&path)
2820 .cloned()
2821 } else {
2822 None
2823 };
2824
2825 let has_unsaved_changes = if is_directory {
2827 self.buffers.iter().any(|(buffer_id, state)| {
2829 if state.buffer.is_modified() {
2830 if let Some(metadata) = self.buffer_metadata.get(buffer_id) {
2831 if let Some(file_path) = metadata.file_path() {
2832 return file_path.starts_with(&path);
2833 }
2834 }
2835 }
2836 false
2837 })
2838 } else {
2839 self.buffers.iter().any(|(buffer_id, state)| {
2840 if state.buffer.is_modified() {
2841 if let Some(metadata) = self.buffer_metadata.get(buffer_id) {
2842 return metadata.file_path() == Some(&path);
2843 }
2844 }
2845 false
2846 })
2847 };
2848
2849 let mut lines: Vec<String> = Vec::new();
2851
2852 if let Some(decoration) = &decoration {
2853 let symbol = &decoration.symbol;
2854 let explanation = match symbol.as_str() {
2855 "U" => "Untracked - File is not tracked by git",
2856 "M" => "Modified - File has unstaged changes",
2857 "A" => "Added - File is staged for commit",
2858 "D" => "Deleted - File is staged for deletion",
2859 "R" => "Renamed - File has been renamed",
2860 "C" => "Copied - File has been copied",
2861 "!" => "Conflicted - File has merge conflicts",
2862 "●" => "Has changes - Contains modified files",
2863 _ => "Unknown status",
2864 };
2865 lines.push(format!("{} - {}", symbol, explanation));
2866 } else if bubbled_decoration.is_some() {
2867 lines.push("● - Contains modified files".to_string());
2868 } else if has_unsaved_changes {
2869 if is_directory {
2870 lines.push("● - Contains unsaved changes".to_string());
2871 } else {
2872 lines.push("● - Unsaved changes in editor".to_string());
2873 }
2874 } else {
2875 return; }
2877
2878 if is_directory {
2880 if let Some(modified_files) = self.get_modified_files_in_directory(&path) {
2882 lines.push(String::new()); lines.push("Modified files:".to_string());
2884 let resolved_path = path.canonicalize().unwrap_or_else(|_| path.clone());
2886 const MAX_FILES: usize = 8;
2887 for (i, file) in modified_files.iter().take(MAX_FILES).enumerate() {
2888 let display_name = file
2890 .strip_prefix(&resolved_path)
2891 .unwrap_or(file)
2892 .to_string_lossy()
2893 .to_string();
2894 lines.push(format!(" {}", display_name));
2895 if i == MAX_FILES - 1 && modified_files.len() > MAX_FILES {
2896 lines.push(format!(
2897 " ... and {} more",
2898 modified_files.len() - MAX_FILES
2899 ));
2900 break;
2901 }
2902 }
2903 }
2904 } else {
2905 if let Some(stats) = self.get_git_diff_stats(&path) {
2907 lines.push(String::new()); lines.push(stats);
2909 }
2910 }
2911
2912 if lines.is_empty() {
2913 return;
2914 }
2915
2916 let mut popup = Popup::text(lines, &self.theme);
2918 popup.title = Some("Git Status".to_string());
2919 popup.transient = true;
2920 popup.position = PopupPosition::Fixed { x: col, y: row + 1 };
2921 popup.width = 50;
2922 popup.max_height = 15;
2923 popup.border_style = Style::default().fg(self.theme.popup_border_fg);
2924 popup.background_style = Style::default().bg(self.theme.popup_bg);
2925
2926 if let Some(state) = self.buffers.get_mut(&self.active_buffer()) {
2928 state.popups.show(popup);
2929 }
2930 }
2931
2932 fn dismiss_file_explorer_status_tooltip(&mut self) {
2934 if let Some(state) = self.buffers.get_mut(&self.active_buffer()) {
2936 state.popups.dismiss_transient();
2937 }
2938 }
2939
2940 fn get_git_diff_stats(&self, path: &std::path::Path) -> Option<String> {
2942 use std::process::Command;
2943
2944 let output = Command::new("git")
2946 .args(["diff", "--numstat", "--"])
2947 .arg(path)
2948 .current_dir(&self.working_dir)
2949 .output()
2950 .ok()?;
2951
2952 if !output.status.success() {
2953 return None;
2954 }
2955
2956 let stdout = String::from_utf8_lossy(&output.stdout);
2957 let line = stdout.lines().next()?;
2958 let parts: Vec<&str> = line.split('\t').collect();
2959
2960 if parts.len() >= 2 {
2961 let insertions = parts[0];
2962 let deletions = parts[1];
2963
2964 if insertions == "-" && deletions == "-" {
2966 return Some("Binary file changed".to_string());
2967 }
2968
2969 let ins: i32 = insertions.parse().unwrap_or(0);
2970 let del: i32 = deletions.parse().unwrap_or(0);
2971
2972 if ins > 0 || del > 0 {
2973 return Some(format!("+{} -{} lines", ins, del));
2974 }
2975 }
2976
2977 let staged_output = Command::new("git")
2979 .args(["diff", "--numstat", "--cached", "--"])
2980 .arg(path)
2981 .current_dir(&self.working_dir)
2982 .output()
2983 .ok()?;
2984
2985 if staged_output.status.success() {
2986 let staged_stdout = String::from_utf8_lossy(&staged_output.stdout);
2987 if let Some(line) = staged_stdout.lines().next() {
2988 let parts: Vec<&str> = line.split('\t').collect();
2989 if parts.len() >= 2 {
2990 let insertions = parts[0];
2991 let deletions = parts[1];
2992
2993 if insertions == "-" && deletions == "-" {
2994 return Some("Binary file staged".to_string());
2995 }
2996
2997 let ins: i32 = insertions.parse().unwrap_or(0);
2998 let del: i32 = deletions.parse().unwrap_or(0);
2999
3000 if ins > 0 || del > 0 {
3001 return Some(format!("+{} -{} lines (staged)", ins, del));
3002 }
3003 }
3004 }
3005 }
3006
3007 None
3008 }
3009
3010 fn get_modified_files_in_directory(
3012 &self,
3013 dir_path: &std::path::Path,
3014 ) -> Option<Vec<std::path::PathBuf>> {
3015 use std::process::Command;
3016
3017 let resolved_path = dir_path
3019 .canonicalize()
3020 .unwrap_or_else(|_| dir_path.to_path_buf());
3021
3022 let output = Command::new("git")
3024 .args(["status", "--porcelain", "--"])
3025 .arg(&resolved_path)
3026 .current_dir(&self.working_dir)
3027 .output()
3028 .ok()?;
3029
3030 if !output.status.success() {
3031 return None;
3032 }
3033
3034 let stdout = String::from_utf8_lossy(&output.stdout);
3035 let modified_files: Vec<std::path::PathBuf> = stdout
3036 .lines()
3037 .filter_map(|line| {
3038 if line.len() > 3 {
3041 let file_part = &line[3..];
3042 let file_name = if file_part.contains(" -> ") {
3044 file_part.split(" -> ").last().unwrap_or(file_part)
3045 } else {
3046 file_part
3047 };
3048 Some(self.working_dir.join(file_name))
3049 } else {
3050 None
3051 }
3052 })
3053 .collect();
3054
3055 if modified_files.is_empty() {
3056 None
3057 } else {
3058 Some(modified_files)
3059 }
3060 }
3061}