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 ratatui::layout::Rect;
19use rust_i18n::t;
20
21fn in_rect(col: u16, row: u16, rect: Rect) -> bool {
23 col >= rect.x && col < rect.x + rect.width && row >= rect.y && row < rect.y + rect.height
24}
25
26impl Editor {
27 pub fn handle_mouse(
30 &mut self,
31 mouse_event: crossterm::event::MouseEvent,
32 ) -> AnyhowResult<bool> {
33 use crossterm::event::{MouseButton, MouseEventKind};
34
35 let col = mouse_event.column;
36 let row = mouse_event.row;
37
38 let (is_double_click, is_triple_click) = self.detect_multi_click(&mouse_event, col, row);
39
40 if self.keybinding_editor.is_some() {
42 return self.handle_keybinding_editor_mouse(mouse_event);
43 }
44
45 if self.settings_state.as_ref().is_some_and(|s| s.visible) {
47 return self.handle_settings_mouse(mouse_event, is_double_click);
48 }
49
50 if self.calibration_wizard.is_some() {
52 return Ok(false);
53 }
54
55 let mut needs_render = false;
57 if let Some(ref prompt) = self.prompt {
58 if matches!(prompt.prompt_type, PromptType::LspRename { .. }) {
59 self.cancel_prompt();
60 needs_render = true;
61 }
62 }
63
64 let cursor_moved = self.mouse_cursor_position != Some((col, row));
67 self.mouse_cursor_position = Some((col, row));
68 if self.gpm_active && cursor_moved {
69 needs_render = true;
70 }
71
72 tracing::trace!(
73 "handle_mouse: kind={:?}, col={}, row={}",
74 mouse_event.kind,
75 col,
76 row
77 );
78
79 if let Some(result) = self.try_forward_mouse_to_terminal(col, row, mouse_event) {
82 return result;
83 }
84
85 if self.theme_info_popup.is_some() {
87 if let MouseEventKind::Down(MouseButton::Left) = mouse_event.kind {
88 if let Some((popup_rect, button_row_offset)) = self.theme_info_popup_rect() {
89 if in_rect(col, row, popup_rect) {
90 let actual_button_row = popup_rect.y + button_row_offset;
92 if row == actual_button_row {
93 let fg_key = self
94 .theme_info_popup
95 .as_ref()
96 .and_then(|p| p.info.fg_key.clone());
97 self.theme_info_popup = None;
98 if let Some(key) = fg_key {
99 self.fire_theme_inspect_hook(key);
100 }
101 return Ok(true);
102 }
103 return Ok(true);
105 }
106 }
107 self.theme_info_popup = None;
109 needs_render = true;
110 }
111 }
112
113 match mouse_event.kind {
114 MouseEventKind::Down(MouseButton::Left) => {
115 if is_double_click || is_triple_click {
116 if let Some((buffer_id, byte_pos)) =
117 self.fold_toggle_line_at_screen_position(col, row)
118 {
119 self.toggle_fold_at_byte(buffer_id, byte_pos);
120 needs_render = true;
121 return Ok(needs_render);
122 }
123 }
124 if is_triple_click {
125 self.handle_mouse_triple_click(col, row)?;
127 needs_render = true;
128 return Ok(needs_render);
129 }
130 if is_double_click {
131 self.handle_mouse_double_click(col, row)?;
133 needs_render = true;
134 return Ok(needs_render);
135 }
136 self.handle_mouse_click(col, row, mouse_event.modifiers)?;
137 needs_render = true;
138 }
139 MouseEventKind::Drag(MouseButton::Left) => {
140 self.handle_mouse_drag(col, row)?;
141 needs_render = true;
142 }
143 MouseEventKind::Up(MouseButton::Left) => {
144 let was_dragging_separator = self.mouse_state.dragging_separator.is_some();
146
147 if let Some(drag_state) = self.mouse_state.dragging_tab.take() {
149 if drag_state.is_dragging() {
150 if let Some(drop_zone) = drag_state.drop_zone {
151 self.execute_tab_drop(
152 drag_state.buffer_id,
153 drag_state.source_split_id,
154 drop_zone,
155 );
156 }
157 }
158 }
159
160 self.mouse_state.dragging_scrollbar = None;
162 self.mouse_state.drag_start_row = None;
163 self.mouse_state.drag_start_top_byte = None;
164 self.mouse_state.dragging_horizontal_scrollbar = None;
165 self.mouse_state.drag_start_hcol = None;
166 self.mouse_state.drag_start_left_column = None;
167 self.mouse_state.dragging_separator = None;
168 self.mouse_state.drag_start_position = None;
169 self.mouse_state.drag_start_ratio = None;
170 self.mouse_state.dragging_file_explorer = false;
171 self.mouse_state.drag_start_explorer_width = None;
172 self.mouse_state.dragging_text_selection = false;
174 self.mouse_state.drag_selection_split = None;
175 self.mouse_state.drag_selection_anchor = None;
176 self.mouse_state.drag_selection_by_words = false;
177 self.mouse_state.drag_selection_word_end = None;
178 self.mouse_state.dragging_popup_scrollbar = None;
180 self.mouse_state.drag_start_popup_scroll = None;
181 self.mouse_state.selecting_in_popup = None;
183
184 if was_dragging_separator {
186 self.resize_visible_terminals();
187 }
188
189 needs_render = true;
190 }
191 MouseEventKind::Moved => {
192 {
194 let content_rect = self
196 .cached_layout
197 .split_areas
198 .iter()
199 .find(|(_, _, content_rect, _, _, _)| in_rect(col, row, *content_rect))
200 .map(|(_, _, rect, _, _, _)| *rect);
201
202 let (content_x, content_y) = content_rect.map(|r| (r.x, r.y)).unwrap_or((0, 0));
203
204 self.plugin_manager.run_hook(
205 "mouse_move",
206 HookArgs::MouseMove {
207 column: col,
208 row,
209 content_x,
210 content_y,
211 },
212 );
213 }
214
215 let hover_changed = self.update_hover_target(col, row);
218 needs_render = needs_render || hover_changed;
219
220 if let Some((popup_rect, button_row_offset)) = self.theme_info_popup_rect() {
222 let button_row = popup_rect.y + button_row_offset;
223 let new_highlighted = row == button_row
224 && col >= popup_rect.x
225 && col < popup_rect.x + popup_rect.width;
226 if let Some(ref mut popup) = self.theme_info_popup {
227 if popup.button_highlighted != new_highlighted {
228 popup.button_highlighted = new_highlighted;
229 needs_render = true;
230 }
231 }
232 }
233
234 self.update_lsp_hover_state(col, row);
236 }
237 MouseEventKind::ScrollUp => {
238 self.handle_vertical_scroll(col, row, mouse_event.modifiers, -3)?;
239 needs_render = true;
240 }
241 MouseEventKind::ScrollDown => {
242 self.handle_vertical_scroll(col, row, mouse_event.modifiers, 3)?;
243 needs_render = true;
244 }
245 MouseEventKind::ScrollLeft => {
246 self.handle_horizontal_scroll(col, row, -3)?;
248 needs_render = true;
249 }
250 MouseEventKind::ScrollRight => {
251 self.handle_horizontal_scroll(col, row, 3)?;
253 needs_render = true;
254 }
255 MouseEventKind::Down(MouseButton::Right) => {
256 if mouse_event
257 .modifiers
258 .contains(crossterm::event::KeyModifiers::CONTROL)
259 {
260 self.show_theme_info_popup(col, row)?;
262 } else {
263 self.handle_right_click(col, row)?;
265 }
266 needs_render = true;
267 }
268 _ => {
269 }
271 }
272
273 self.mouse_state.last_position = Some((col, row));
274 Ok(needs_render)
275 }
276
277 fn detect_multi_click(
279 &mut self,
280 mouse_event: &crossterm::event::MouseEvent,
281 col: u16,
282 row: u16,
283 ) -> (bool, bool) {
284 use crossterm::event::{MouseButton, MouseEventKind};
285 if !matches!(mouse_event.kind, MouseEventKind::Down(MouseButton::Left)) {
286 return (false, false);
287 }
288 let now = self.time_source.now();
289 let threshold = std::time::Duration::from_millis(self.config.editor.double_click_time_ms);
290 let is_consecutive = if let (Some(prev_time), Some(prev_pos)) =
291 (self.previous_click_time, self.previous_click_position)
292 {
293 now.duration_since(prev_time) < threshold && prev_pos == (col, row)
294 } else {
295 false
296 };
297 if is_consecutive {
298 self.click_count += 1;
299 } else {
300 self.click_count = 1;
301 }
302 self.previous_click_time = Some(now);
303 self.previous_click_position = Some((col, row));
304 let is_triple = self.click_count >= 3;
305 let is_double = self.click_count == 2;
306 if is_triple {
307 self.click_count = 0;
308 self.previous_click_time = None;
309 self.previous_click_position = None;
310 }
311 (is_double, is_triple)
312 }
313
314 fn handle_vertical_scroll(
317 &mut self,
318 col: u16,
319 row: u16,
320 modifiers: crossterm::event::KeyModifiers,
321 delta: i32,
322 ) -> AnyhowResult<()> {
323 if modifiers.contains(crossterm::event::KeyModifiers::SHIFT) {
324 self.handle_horizontal_scroll(col, row, delta)?;
325 } else if self.handle_prompt_scroll(delta) {
326 } else if self.is_file_open_active()
328 && self.is_mouse_over_file_browser(col, row)
329 && self.handle_file_open_scroll(delta)
330 {
331 } else if self.is_mouse_over_any_popup(col, row) {
333 self.scroll_popup(delta);
334 } else {
335 if self.terminal_mode && self.is_terminal_buffer(self.active_buffer()) {
336 self.sync_terminal_to_buffer(self.active_buffer());
337 self.terminal_mode = false;
338 self.key_context = crate::input::keybindings::KeyContext::Normal;
339 }
340 self.dismiss_transient_popups();
341 self.handle_mouse_scroll(col, row, delta)?;
342 }
343 Ok(())
344 }
345
346 pub(super) fn update_hover_target(&mut self, col: u16, row: u16) -> bool {
349 let old_target = self.mouse_state.hover_target.clone();
350 let new_target = self.compute_hover_target(col, row);
351 let changed = old_target != new_target;
352 self.mouse_state.hover_target = new_target.clone();
353
354 if let Some(active_menu_idx) = self.menu_state.active_menu {
357 let all_menus: Vec<crate::config::Menu> = self
358 .menus
359 .menus
360 .iter()
361 .chain(self.menu_state.plugin_menus.iter())
362 .cloned()
363 .collect();
364 if let Some(HoverTarget::MenuBarItem(hovered_menu_idx)) = new_target.clone() {
365 if hovered_menu_idx != active_menu_idx {
366 self.menu_state.open_menu(hovered_menu_idx);
367 return true; }
369 }
370
371 if let Some(HoverTarget::MenuDropdownItem(_, item_idx)) = new_target.clone() {
373 if self.menu_state.submenu_path.first() == Some(&item_idx) {
376 tracing::trace!(
377 "menu hover: staying on submenu parent item_idx={}, submenu_path={:?}",
378 item_idx,
379 self.menu_state.submenu_path
380 );
381 return changed;
382 }
383
384 if !self.menu_state.submenu_path.is_empty() {
386 tracing::trace!(
387 "menu hover: clearing submenu_path={:?} for different item_idx={}",
388 self.menu_state.submenu_path,
389 item_idx
390 );
391 self.menu_state.submenu_path.clear();
392 self.menu_state.highlighted_item = Some(item_idx);
393 return true;
394 }
395
396 if let Some(menu) = all_menus.get(active_menu_idx) {
398 if let Some(crate::config::MenuItem::Submenu { items, .. }) =
399 menu.items.get(item_idx)
400 {
401 if !items.is_empty() {
402 tracing::trace!("menu hover: opening submenu at item_idx={}", item_idx);
403 self.menu_state.submenu_path.push(item_idx);
404 self.menu_state.highlighted_item = Some(0);
405 return true;
406 }
407 }
408 }
409 if self.menu_state.highlighted_item != Some(item_idx) {
411 self.menu_state.highlighted_item = Some(item_idx);
412 return true;
413 }
414 }
415
416 if let Some(HoverTarget::SubmenuItem(depth, item_idx)) = new_target {
418 if self.menu_state.submenu_path.len() > depth
422 && self.menu_state.submenu_path.get(depth) == Some(&item_idx)
423 {
424 tracing::trace!(
425 "menu hover: staying on nested submenu parent depth={}, item_idx={}, submenu_path={:?}",
426 depth,
427 item_idx,
428 self.menu_state.submenu_path
429 );
430 return changed;
431 }
432
433 if self.menu_state.submenu_path.len() > depth {
435 tracing::trace!(
436 "menu hover: truncating submenu_path={:?} to depth={} for item_idx={}",
437 self.menu_state.submenu_path,
438 depth,
439 item_idx
440 );
441 self.menu_state.submenu_path.truncate(depth);
442 }
443
444 if let Some(items) = self
446 .menu_state
447 .get_current_items(&all_menus, active_menu_idx)
448 {
449 if let Some(crate::config::MenuItem::Submenu {
451 items: sub_items, ..
452 }) = items.get(item_idx)
453 {
454 if !sub_items.is_empty()
455 && !self.menu_state.submenu_path.contains(&item_idx)
456 {
457 tracing::trace!(
458 "menu hover: opening nested submenu at depth={}, item_idx={}",
459 depth,
460 item_idx
461 );
462 self.menu_state.submenu_path.push(item_idx);
463 self.menu_state.highlighted_item = Some(0);
464 return true;
465 }
466 }
467 if self.menu_state.highlighted_item != Some(item_idx) {
469 self.menu_state.highlighted_item = Some(item_idx);
470 return true;
471 }
472 }
473 }
474 }
475
476 if let Some(HoverTarget::TabContextMenuItem(item_idx)) = new_target.clone() {
478 if let Some(ref mut menu) = self.tab_context_menu {
479 if menu.highlighted != item_idx {
480 menu.highlighted = item_idx;
481 return true;
482 }
483 }
484 }
485
486 if let Some(&HoverTarget::FileExplorerContextMenuItem(item_idx)) = new_target.as_ref() {
487 if let Some(ref mut menu) = self.file_explorer_context_menu {
488 if menu.highlighted != item_idx {
489 menu.highlighted = item_idx;
490 return true;
491 }
492 }
493 }
494
495 if old_target != new_target
498 && matches!(
499 old_target,
500 Some(HoverTarget::FileExplorerStatusIndicator(_))
501 )
502 {
503 self.dismiss_file_explorer_status_tooltip();
504 }
505
506 if let Some(HoverTarget::FileExplorerStatusIndicator(ref path)) = new_target {
507 if old_target != new_target {
509 self.show_file_explorer_status_tooltip(path.clone(), col, row);
510 return true;
511 }
512 }
513
514 changed
515 }
516
517 fn update_lsp_hover_state(&mut self, col: u16, row: u16) {
526 tracing::trace!(col, row, "update_lsp_hover_state: raw mouse position");
527
528 if self.theme_info_popup.is_some()
531 || self.tab_context_menu.is_some()
532 || self.file_explorer_context_menu.is_some()
533 {
534 if self.mouse_state.lsp_hover_state.is_some() {
535 self.mouse_state.lsp_hover_state = None;
536 self.mouse_state.lsp_hover_request_sent = false;
537 self.dismiss_transient_popups();
538 }
539 return;
540 }
541
542 if self.is_mouse_over_transient_popup(col, row) {
544 return;
545 }
546
547 let split_info = self
549 .cached_layout
550 .split_areas
551 .iter()
552 .find(|(_, _, content_rect, _, _, _)| in_rect(col, row, *content_rect))
553 .map(|(split_id, buffer_id, content_rect, _, _, _)| {
554 (*split_id, *buffer_id, *content_rect)
555 });
556
557 let Some((split_id, buffer_id, content_rect)) = split_info else {
558 if self.mouse_state.lsp_hover_state.is_some() {
560 self.mouse_state.lsp_hover_state = None;
561 self.mouse_state.lsp_hover_request_sent = false;
562 self.dismiss_transient_popups();
563 }
564 return;
565 };
566
567 let cached_mappings = self
569 .cached_layout
570 .view_line_mappings
571 .get(&split_id)
572 .cloned();
573 let gutter_width = self
574 .buffers
575 .get(&buffer_id)
576 .map(|s| s.margins.left_total_width() as u16)
577 .unwrap_or(0);
578 let fallback = self
579 .buffers
580 .get(&buffer_id)
581 .map(|s| s.buffer.len())
582 .unwrap_or(0);
583
584 let compose_width = self
586 .split_view_states
587 .get(&split_id)
588 .and_then(|vs| vs.compose_width);
589
590 let Some(byte_pos) = super::click_geometry::screen_to_buffer_position(
592 col,
593 row,
594 content_rect,
595 gutter_width,
596 &cached_mappings,
597 fallback,
598 false, compose_width,
600 ) else {
601 if self.mouse_state.lsp_hover_state.is_some() {
603 self.mouse_state.lsp_hover_state = None;
604 self.mouse_state.lsp_hover_request_sent = false;
605 self.dismiss_transient_popups();
606 }
607 return;
608 };
609
610 let content_col = col.saturating_sub(content_rect.x);
612 let text_col = content_col.saturating_sub(gutter_width) as usize;
613 let visual_row = row.saturating_sub(content_rect.y) as usize;
614
615 let line_info = cached_mappings
616 .as_ref()
617 .and_then(|mappings| mappings.get(visual_row))
618 .map(|line_mapping| {
619 (
620 line_mapping.visual_to_char.len(),
621 line_mapping.line_end_byte,
622 )
623 });
624
625 let is_past_line_end_or_empty = line_info
626 .map(|(line_len, _)| {
627 if line_len <= 1 {
629 return true;
630 }
631 text_col >= line_len
632 })
633 .unwrap_or(true);
635
636 tracing::trace!(
637 col,
638 row,
639 content_col,
640 text_col,
641 visual_row,
642 gutter_width,
643 byte_pos,
644 ?line_info,
645 is_past_line_end_or_empty,
646 "update_lsp_hover_state: position check"
647 );
648
649 if is_past_line_end_or_empty {
650 tracing::trace!(
651 "update_lsp_hover_state: mouse past line end or empty line, clearing hover"
652 );
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 if let Some((start, end)) = self.hover.symbol_range() {
664 if byte_pos >= start && byte_pos < end {
665 return;
667 }
668 }
669
670 if let Some((old_pos, _, _, _)) = self.mouse_state.lsp_hover_state {
672 if old_pos == byte_pos {
673 return;
675 }
676 self.dismiss_transient_popups();
678 }
679
680 self.mouse_state.lsp_hover_state = Some((byte_pos, std::time::Instant::now(), col, row));
682 self.mouse_state.lsp_hover_request_sent = false;
683 }
684
685 fn is_mouse_over_transient_popup(&self, col: u16, row: u16) -> bool {
687 let layouts = popup_areas_to_layout_info(&self.cached_layout.popup_areas);
688 let hit_tester = PopupHitTester::new(&layouts, &self.active_state().popups);
689 hit_tester.is_over_transient_popup(col, row)
690 }
691
692 fn is_mouse_over_any_popup(&self, col: u16, row: u16) -> bool {
694 for (_, popup_area, _, _, _) in &self.cached_layout.global_popup_areas {
697 if in_rect(col, row, *popup_area) {
698 return true;
699 }
700 }
701 if let Some(outer) = self.cached_layout.suggestions_outer_area {
705 if in_rect(col, row, outer) {
706 return true;
707 }
708 }
709 let layouts = popup_areas_to_layout_info(&self.cached_layout.popup_areas);
710 let hit_tester = PopupHitTester::new(&layouts, &self.active_state().popups);
711 hit_tester.is_over_popup(col, row)
712 }
713
714 fn is_mouse_over_file_browser(&self, col: u16, row: u16) -> bool {
716 self.file_browser_layout
717 .as_ref()
718 .is_some_and(|layout| layout.contains(col, row))
719 }
720
721 pub(super) fn split_at_position(&self, col: u16, row: u16) -> Option<(LeafId, BufferId)> {
724 for &(split_id, buffer_id, content_rect, scrollbar_rect, _, _) in
725 &self.cached_layout.split_areas
726 {
727 let in_content = in_rect(col, row, content_rect);
728 let in_scrollbar = scrollbar_rect.width > 0
729 && scrollbar_rect.height > 0
730 && in_rect(col, row, scrollbar_rect);
731 if in_content || in_scrollbar {
732 return Some((split_id, buffer_id));
733 }
734 }
735 None
736 }
737
738 fn compute_hover_target(&self, col: u16, row: u16) -> Option<HoverTarget> {
740 if let Some(ref menu) = self.file_explorer_context_menu {
741 let (menu_x, menu_y) = menu.clamped_position(
742 self.cached_layout.last_frame_width,
743 self.cached_layout.last_frame_height,
744 );
745 let menu_width = super::types::FILE_EXPLORER_CONTEXT_MENU_WIDTH;
746 let menu_height = menu.height();
747
748 if col >= menu_x
749 && col < menu_x + menu_width
750 && row > menu_y
751 && row < menu_y + menu_height - 1
752 {
753 let item_idx = (row - menu_y - 1) as usize;
754 if item_idx < menu.items().len() {
755 return Some(HoverTarget::FileExplorerContextMenuItem(item_idx));
756 }
757 }
758 }
759
760 if let Some(ref menu) = self.tab_context_menu {
762 let menu_x = menu.position.0;
763 let menu_y = menu.position.1;
764 let menu_width = 22u16;
765 let items = super::types::TabContextMenuItem::all();
766 let menu_height = items.len() as u16 + 2;
767
768 if col >= menu_x
769 && col < menu_x + menu_width
770 && row > menu_y
771 && row < menu_y + menu_height - 1
772 {
773 let item_idx = (row - menu_y - 1) as usize;
774 if item_idx < items.len() {
775 return Some(HoverTarget::TabContextMenuItem(item_idx));
776 }
777 }
778 }
779
780 if let Some((inner_rect, start_idx, _visible_count, total_count)) =
782 &self.cached_layout.suggestions_area
783 {
784 if in_rect(col, row, *inner_rect) {
785 let relative_row = (row - inner_rect.y) as usize;
786 let item_idx = start_idx + relative_row;
787
788 if item_idx < *total_count {
789 return Some(HoverTarget::SuggestionItem(item_idx));
790 }
791 }
792 }
793
794 for (popup_idx, _popup_rect, inner_rect, scroll_offset, num_items, _, _) in
797 self.cached_layout.popup_areas.iter().rev()
798 {
799 if in_rect(col, row, *inner_rect) && *num_items > 0 {
800 let relative_row = (row - inner_rect.y) as usize;
802 let item_idx = scroll_offset + relative_row;
803
804 if item_idx < *num_items {
805 return Some(HoverTarget::PopupListItem(*popup_idx, item_idx));
806 }
807 }
808 }
809
810 if self.is_file_open_active() {
812 if let Some(hover) = self.compute_file_browser_hover(col, row) {
813 return Some(hover);
814 }
815 }
816
817 if self.menu_bar_visible {
820 if let Some(ref menu_layout) = self.cached_layout.menu_layout {
821 if let Some(menu_idx) = menu_layout.menu_at(col, row) {
822 return Some(HoverTarget::MenuBarItem(menu_idx));
823 }
824 }
825 }
826
827 if let Some(active_idx) = self.menu_state.active_menu {
829 if let Some(hover) = self.compute_menu_dropdown_hover(col, row, active_idx) {
830 return Some(hover);
831 }
832 }
833
834 if let Some(explorer_area) = self.cached_layout.file_explorer_area {
836 let close_button_x = explorer_area.x + explorer_area.width.saturating_sub(3);
838 if row == explorer_area.y
839 && col >= close_button_x
840 && col < explorer_area.x + explorer_area.width
841 {
842 return Some(HoverTarget::FileExplorerCloseButton);
843 }
844
845 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
852 && row < content_end_y
853 && col >= status_indicator_x
854 && col < explorer_area.x + explorer_area.width.saturating_sub(1)
855 {
856 if let Some(ref explorer) = self.file_explorer {
858 let relative_row = row.saturating_sub(content_start_y) as usize;
859 let scroll_offset = explorer.get_scroll_offset();
860 let item_index = relative_row + scroll_offset;
861 let display_nodes = explorer.get_display_nodes();
862
863 if item_index < display_nodes.len() {
864 let (node_id, _indent) = display_nodes[item_index];
865 if let Some(node) = explorer.tree().get_node(node_id) {
866 return Some(HoverTarget::FileExplorerStatusIndicator(
867 node.entry.path.clone(),
868 ));
869 }
870 }
871 }
872 }
873
874 let border_x = explorer_area.x + explorer_area.width.saturating_sub(1);
877 if col == border_x
878 && row >= explorer_area.y
879 && row < explorer_area.y + explorer_area.height
880 {
881 return Some(HoverTarget::FileExplorerBorder);
882 }
883 }
884
885 for (split_id, direction, sep_x, sep_y, sep_length) in &self.cached_layout.separator_areas {
887 let is_on_separator = match direction {
888 SplitDirection::Horizontal => {
889 row == *sep_y && col >= *sep_x && col < sep_x + sep_length
890 }
891 SplitDirection::Vertical => {
892 col == *sep_x && row >= *sep_y && row < sep_y + sep_length
893 }
894 };
895
896 if is_on_separator {
897 return Some(HoverTarget::SplitSeparator(*split_id, *direction));
898 }
899 }
900
901 for (split_id, btn_row, start_col, end_col) in &self.cached_layout.close_split_areas {
904 if row == *btn_row && col >= *start_col && col < *end_col {
905 return Some(HoverTarget::CloseSplitButton(*split_id));
906 }
907 }
908
909 for (split_id, btn_row, start_col, end_col) in &self.cached_layout.maximize_split_areas {
910 if row == *btn_row && col >= *start_col && col < *end_col {
911 return Some(HoverTarget::MaximizeSplitButton(*split_id));
912 }
913 }
914
915 for (split_id, tab_layout) in &self.cached_layout.tab_layouts {
916 match tab_layout.hit_test(col, row) {
917 Some(TabHit::CloseButton(target)) => {
918 return Some(HoverTarget::TabCloseButton(target, *split_id));
919 }
920 Some(TabHit::TabName(target)) => {
921 return Some(HoverTarget::TabName(target, *split_id));
922 }
923 Some(TabHit::ScrollLeft)
924 | Some(TabHit::ScrollRight)
925 | Some(TabHit::BarBackground)
926 | None => {}
927 }
928 }
929
930 for (split_id, _buffer_id, _content_rect, scrollbar_rect, thumb_start, thumb_end) in
932 &self.cached_layout.split_areas
933 {
934 if in_rect(col, row, *scrollbar_rect) {
935 let relative_row = row.saturating_sub(scrollbar_rect.y) as usize;
936 let is_on_thumb = relative_row >= *thumb_start && relative_row < *thumb_end;
937
938 if is_on_thumb {
939 return Some(HoverTarget::ScrollbarThumb(*split_id));
940 } else {
941 return Some(HoverTarget::ScrollbarTrack(*split_id, relative_row as u16));
942 }
943 }
944 }
945
946 if let Some((status_row, _status_x, _status_width)) = self.cached_layout.status_bar_area {
948 if row == status_row {
949 let indicators = [
950 (
951 self.cached_layout.status_bar_line_ending_area,
952 HoverTarget::StatusBarLineEndingIndicator,
953 ),
954 (
955 self.cached_layout.status_bar_encoding_area,
956 HoverTarget::StatusBarEncodingIndicator,
957 ),
958 (
959 self.cached_layout.status_bar_language_area,
960 HoverTarget::StatusBarLanguageIndicator,
961 ),
962 (
963 self.cached_layout.status_bar_lsp_area,
964 HoverTarget::StatusBarLspIndicator,
965 ),
966 (
967 self.cached_layout.status_bar_remote_area,
968 HoverTarget::StatusBarRemoteIndicator,
969 ),
970 (
971 self.cached_layout.status_bar_warning_area,
972 HoverTarget::StatusBarWarningBadge,
973 ),
974 ];
975 for (area, target) in indicators {
976 if let Some((indicator_row, start, end)) = area {
977 if row == indicator_row && col >= start && col < end {
978 return Some(target);
979 }
980 }
981 }
982 }
983 }
984
985 if let Some(ref layout) = self.cached_layout.search_options_layout {
987 use crate::view::ui::status_bar::SearchOptionsHover;
988 if let Some(hover) = layout.checkbox_at(col, row) {
989 return Some(match hover {
990 SearchOptionsHover::CaseSensitive => HoverTarget::SearchOptionCaseSensitive,
991 SearchOptionsHover::WholeWord => HoverTarget::SearchOptionWholeWord,
992 SearchOptionsHover::Regex => HoverTarget::SearchOptionRegex,
993 SearchOptionsHover::ConfirmEach => HoverTarget::SearchOptionConfirmEach,
994 SearchOptionsHover::None => return None,
995 });
996 }
997 }
998
999 None
1001 }
1002
1003 pub(super) fn handle_mouse_double_click(&mut self, col: u16, row: u16) -> AnyhowResult<()> {
1006 tracing::debug!("handle_mouse_double_click at col={}, row={}", col, row);
1007
1008 if let Some(r) = self.handle_click_suggestions_confirm(col, row) {
1012 return r;
1013 }
1014
1015 if self.is_mouse_over_any_popup(col, row) {
1017 return Ok(());
1019 } else {
1020 self.dismiss_transient_popups();
1022 }
1023
1024 if self.handle_file_open_double_click(col, row) {
1026 return Ok(());
1027 }
1028
1029 if let Some(explorer_area) = self.cached_layout.file_explorer_area {
1031 if col >= explorer_area.x
1032 && col < explorer_area.x + explorer_area.width
1033 && row > explorer_area.y && row < explorer_area.y + explorer_area.height
1035 {
1036 self.file_explorer_open_file()?;
1038 return Ok(());
1039 }
1040 }
1041
1042 let split_areas = self.cached_layout.split_areas.clone();
1044 for (split_id, buffer_id, content_rect, _scrollbar_rect, _thumb_start, _thumb_end) in
1045 &split_areas
1046 {
1047 if in_rect(col, row, *content_rect) {
1048 if self.is_terminal_buffer(*buffer_id) {
1050 self.key_context = crate::input::keybindings::KeyContext::Terminal;
1051 return Ok(());
1053 }
1054
1055 self.key_context = crate::input::keybindings::KeyContext::Normal;
1056
1057 self.handle_editor_double_click(col, row, *split_id, *buffer_id, *content_rect)?;
1059 return Ok(());
1060 }
1061 }
1062
1063 Ok(())
1064 }
1065
1066 fn handle_editor_double_click(
1068 &mut self,
1069 col: u16,
1070 row: u16,
1071 split_id: LeafId,
1072 buffer_id: BufferId,
1073 content_rect: ratatui::layout::Rect,
1074 ) -> AnyhowResult<()> {
1075 use crate::model::event::Event;
1076
1077 if self.is_non_scrollable_buffer(buffer_id) {
1081 return Ok(());
1082 }
1083
1084 self.focus_split(split_id, buffer_id);
1086
1087 let cached_mappings = self
1089 .cached_layout
1090 .view_line_mappings
1091 .get(&split_id)
1092 .cloned();
1093
1094 let leaf_id = split_id;
1096 let fallback = self
1097 .split_view_states
1098 .get(&leaf_id)
1099 .map(|vs| vs.viewport.top_byte)
1100 .unwrap_or(0);
1101
1102 let compose_width = self
1104 .split_view_states
1105 .get(&leaf_id)
1106 .and_then(|vs| vs.compose_width);
1107
1108 if let Some(state) = self.buffers.get_mut(&buffer_id) {
1110 let gutter_width = state.margins.left_total_width() as u16;
1111
1112 let Some(target_position) = super::click_geometry::screen_to_buffer_position(
1113 col,
1114 row,
1115 content_rect,
1116 gutter_width,
1117 &cached_mappings,
1118 fallback,
1119 true, compose_width,
1121 ) else {
1122 return Ok(());
1123 };
1124
1125 let primary_cursor_id = self
1127 .split_view_states
1128 .get(&leaf_id)
1129 .map(|vs| vs.cursors.primary_id())
1130 .unwrap_or(CursorId(0));
1131 let event = Event::MoveCursor {
1132 cursor_id: primary_cursor_id,
1133 old_position: 0,
1134 new_position: target_position,
1135 old_anchor: None,
1136 new_anchor: None,
1137 old_sticky_column: 0,
1138 new_sticky_column: 0,
1139 };
1140
1141 if let Some(event_log) = self.event_logs.get_mut(&buffer_id) {
1142 event_log.append(event.clone());
1143 }
1144 if let Some(cursors) = self
1145 .split_view_states
1146 .get_mut(&leaf_id)
1147 .map(|vs| &mut vs.cursors)
1148 {
1149 state.apply(cursors, &event);
1150 }
1151 }
1152
1153 self.handle_action(Action::SelectWord)?;
1155
1156 if let Some(cursor) = self
1158 .split_view_states
1159 .get(&leaf_id)
1160 .map(|vs| vs.cursors.primary())
1161 {
1162 let sel_start = cursor.selection_start();
1165 let sel_end = cursor.selection_end();
1166 self.mouse_state.dragging_text_selection = true;
1167 self.mouse_state.drag_selection_split = Some(split_id);
1168 self.mouse_state.drag_selection_anchor = Some(sel_start);
1169 self.mouse_state.drag_selection_by_words = true;
1170 self.mouse_state.drag_selection_word_end = Some(sel_end);
1171 }
1172
1173 Ok(())
1174 }
1175 pub(super) fn handle_mouse_triple_click(&mut self, col: u16, row: u16) -> AnyhowResult<()> {
1178 tracing::debug!("handle_mouse_triple_click at col={}, row={}", col, row);
1179
1180 if self.is_mouse_over_any_popup(col, row) {
1182 return Ok(());
1183 } else {
1184 self.dismiss_transient_popups();
1185 }
1186
1187 let split_areas = self.cached_layout.split_areas.clone();
1189 for (split_id, buffer_id, content_rect, _scrollbar_rect, _thumb_start, _thumb_end) in
1190 &split_areas
1191 {
1192 if in_rect(col, row, *content_rect) {
1193 if self.is_terminal_buffer(*buffer_id) {
1194 return Ok(());
1195 }
1196
1197 self.key_context = crate::input::keybindings::KeyContext::Normal;
1198
1199 self.handle_editor_triple_click(col, row, *split_id, *buffer_id, *content_rect)?;
1202 return Ok(());
1203 }
1204 }
1205
1206 Ok(())
1207 }
1208
1209 fn handle_editor_triple_click(
1211 &mut self,
1212 col: u16,
1213 row: u16,
1214 split_id: LeafId,
1215 buffer_id: BufferId,
1216 content_rect: ratatui::layout::Rect,
1217 ) -> AnyhowResult<()> {
1218 use crate::model::event::Event;
1219
1220 if self.is_non_scrollable_buffer(buffer_id) {
1221 return Ok(());
1222 }
1223
1224 self.focus_split(split_id, buffer_id);
1226
1227 let cached_mappings = self
1229 .cached_layout
1230 .view_line_mappings
1231 .get(&split_id)
1232 .cloned();
1233
1234 let leaf_id = split_id;
1235 let fallback = self
1236 .split_view_states
1237 .get(&leaf_id)
1238 .map(|vs| vs.viewport.top_byte)
1239 .unwrap_or(0);
1240
1241 let compose_width = self
1243 .split_view_states
1244 .get(&leaf_id)
1245 .and_then(|vs| vs.compose_width);
1246
1247 if let Some(state) = self.buffers.get_mut(&buffer_id) {
1249 let gutter_width = state.margins.left_total_width() as u16;
1250
1251 let Some(target_position) = super::click_geometry::screen_to_buffer_position(
1252 col,
1253 row,
1254 content_rect,
1255 gutter_width,
1256 &cached_mappings,
1257 fallback,
1258 true,
1259 compose_width,
1260 ) else {
1261 return Ok(());
1262 };
1263
1264 let primary_cursor_id = self
1266 .split_view_states
1267 .get(&leaf_id)
1268 .map(|vs| vs.cursors.primary_id())
1269 .unwrap_or(CursorId(0));
1270 let event = Event::MoveCursor {
1271 cursor_id: primary_cursor_id,
1272 old_position: 0,
1273 new_position: target_position,
1274 old_anchor: None,
1275 new_anchor: None,
1276 old_sticky_column: 0,
1277 new_sticky_column: 0,
1278 };
1279
1280 if let Some(event_log) = self.event_logs.get_mut(&buffer_id) {
1281 event_log.append(event.clone());
1282 }
1283 if let Some(cursors) = self
1284 .split_view_states
1285 .get_mut(&leaf_id)
1286 .map(|vs| &mut vs.cursors)
1287 {
1288 state.apply(cursors, &event);
1289 }
1290 }
1291
1292 self.handle_action(Action::SelectLine)?;
1294
1295 Ok(())
1296 }
1297
1298 pub(super) fn handle_mouse_click(
1300 &mut self,
1301 col: u16,
1302 row: u16,
1303 modifiers: crossterm::event::KeyModifiers,
1304 ) -> AnyhowResult<()> {
1305 if let Some(r) = self.handle_click_context_menus(col, row) {
1306 return r;
1307 }
1308 if !self.is_mouse_over_any_popup(col, row) {
1309 self.dismiss_transient_popups();
1310 }
1311 if let Some(r) = self.handle_click_suggestions(col, row) {
1312 return r;
1313 }
1314 if let Some(r) = self.handle_click_popup_scrollbar(col, row) {
1315 return r;
1316 }
1317 if let Some(r) = self.handle_click_global_popups(col, row) {
1318 return r;
1319 }
1320 if let Some(r) = self.handle_click_buffer_popups(col, row) {
1321 return r;
1322 }
1323 if self.is_mouse_over_any_popup(col, row) {
1324 return Ok(());
1325 }
1326 if self.is_file_open_active() && self.handle_file_open_click(col, row) {
1327 return Ok(());
1328 }
1329 if let Some(r) = self.handle_click_menu_bar(col, row) {
1330 return r;
1331 }
1332 if let Some(r) = self.handle_click_file_explorer_area(col, row) {
1333 return r;
1334 }
1335 if let Some(r) = self.handle_click_scrollbar(col, row) {
1336 return r;
1337 }
1338 if let Some(r) = self.handle_click_horizontal_scrollbar(col, row) {
1339 return r;
1340 }
1341 if let Some(r) = self.handle_click_status_bar(col, row) {
1342 return r;
1343 }
1344 if let Some(r) = self.handle_click_search_options(col, row) {
1345 return r;
1346 }
1347 if let Some(r) = self.handle_click_split_separator(col, row) {
1348 return r;
1349 }
1350 if let Some(r) = self.handle_click_split_controls(col, row) {
1351 return r;
1352 }
1353 if let Some(r) = self.handle_click_tab_bar(col, row) {
1354 return r;
1355 }
1356
1357 tracing::debug!(
1359 "handle_mouse_click: checking {} split_areas for click at ({}, {})",
1360 self.cached_layout.split_areas.len(),
1361 col,
1362 row
1363 );
1364 for (split_id, buffer_id, content_rect, _scrollbar_rect, _thumb_start, _thumb_end) in
1365 &self.cached_layout.split_areas
1366 {
1367 tracing::debug!(
1368 " split_id={:?}, content_rect=({}, {}, {}x{})",
1369 split_id,
1370 content_rect.x,
1371 content_rect.y,
1372 content_rect.width,
1373 content_rect.height
1374 );
1375 if in_rect(col, row, *content_rect) {
1376 tracing::debug!(" -> HIT! calling handle_editor_click");
1378 self.handle_editor_click(
1379 col,
1380 row,
1381 *split_id,
1382 *buffer_id,
1383 *content_rect,
1384 modifiers,
1385 )?;
1386 return Ok(());
1387 }
1388 }
1389 tracing::debug!(" -> No split area hit");
1390
1391 Ok(())
1392 }
1393
1394 fn handle_click_context_menus(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
1398 if self.file_explorer_context_menu.is_some() {
1399 if let Some(result) = self.handle_file_explorer_context_menu_click(col, row) {
1400 return Some(result);
1401 }
1402 }
1403 if self.tab_context_menu.is_some() {
1404 if let Some(result) = self.handle_tab_context_menu_click(col, row) {
1405 return Some(result);
1406 }
1407 }
1408 None
1409 }
1410
1411 fn suggestion_at(&self, col: u16, row: u16) -> Option<usize> {
1415 let (inner_rect, start_idx, _visible_count, total_count) =
1416 self.cached_layout.suggestions_area?;
1417 if col < inner_rect.x
1418 || col >= inner_rect.x + inner_rect.width
1419 || row < inner_rect.y
1420 || row >= inner_rect.y + inner_rect.height
1421 {
1422 return None;
1423 }
1424 let relative_row = (row - inner_rect.y) as usize;
1425 let item_idx = start_idx + relative_row;
1426 if item_idx < total_count {
1427 Some(item_idx)
1428 } else {
1429 None
1430 }
1431 }
1432
1433 fn handle_click_suggestions(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
1434 let item_idx = self.suggestion_at(col, row)?;
1435 let prompt = self.prompt.as_mut()?;
1436 prompt.selected_suggestion = Some(item_idx);
1437 let confirms = prompt.prompt_type.click_confirms();
1438 if !confirms {
1439 if let Some(suggestion) = prompt.suggestions.get(item_idx) {
1443 prompt.input = suggestion.get_value().to_string();
1444 prompt.cursor_pos = prompt.input.len();
1445 }
1446 }
1447 if confirms {
1448 return Some(self.handle_action(Action::PromptConfirm));
1449 }
1450 Some(Ok(()))
1451 }
1452
1453 fn handle_click_suggestions_confirm(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
1457 let item_idx = self.suggestion_at(col, row)?;
1458 let prompt = self.prompt.as_mut()?;
1459 prompt.selected_suggestion = Some(item_idx);
1460 if let Some(suggestion) = prompt.suggestions.get(item_idx) {
1461 prompt.input = suggestion.get_value().to_string();
1462 prompt.cursor_pos = prompt.input.len();
1463 }
1464 Some(self.handle_action(Action::PromptConfirm))
1465 }
1466
1467 fn handle_click_popup_scrollbar(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
1468 let scrollbar_info: Option<(usize, i32)> =
1470 self.cached_layout.popup_areas.iter().rev().find_map(
1471 |(popup_idx, _popup_rect, inner_rect, _scroll, _n, scrollbar_rect, total_lines)| {
1472 let sb_rect = scrollbar_rect.as_ref()?;
1473 if col >= sb_rect.x
1474 && col < sb_rect.x + sb_rect.width
1475 && row >= sb_rect.y
1476 && row < sb_rect.y + sb_rect.height
1477 {
1478 let relative_row = (row - sb_rect.y) as usize;
1479 let track_height = sb_rect.height as usize;
1480 let visible_lines = inner_rect.height as usize;
1481 if track_height > 0 && *total_lines > visible_lines {
1482 let max_scroll = total_lines.saturating_sub(visible_lines);
1483 let target = if track_height > 1 {
1484 (relative_row * max_scroll) / (track_height.saturating_sub(1))
1485 } else {
1486 0
1487 };
1488 Some((*popup_idx, target as i32))
1489 } else {
1490 Some((*popup_idx, 0))
1491 }
1492 } else {
1493 None
1494 }
1495 },
1496 );
1497 let (popup_idx, target_scroll) = scrollbar_info?;
1498 self.mouse_state.dragging_popup_scrollbar = Some(popup_idx);
1499 self.mouse_state.drag_start_row = Some(row);
1500 let current_scroll = self
1501 .active_state()
1502 .popups
1503 .get(popup_idx)
1504 .map(|p| p.scroll_offset)
1505 .unwrap_or(0);
1506 self.mouse_state.drag_start_popup_scroll = Some(current_scroll);
1507 let state = self.active_state_mut();
1508 if let Some(popup) = state.popups.get_mut(popup_idx) {
1509 popup.scroll_by(target_scroll - current_scroll as i32);
1510 }
1511 Some(Ok(()))
1512 }
1513
1514 fn handle_click_global_popups(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
1515 for (popup_idx, popup_rect, inner_rect, scroll_offset, num_items) in self
1516 .cached_layout
1517 .global_popup_areas
1518 .clone()
1519 .into_iter()
1520 .rev()
1521 {
1522 if popup_rect.width >= 5 {
1523 let cb_x = popup_rect.x + popup_rect.width - 4;
1524 if row == popup_rect.y && col >= cb_x && col < cb_x + 3 {
1525 return Some(self.handle_action(Action::PopupCancel));
1526 }
1527 }
1528 if in_rect(col, row, inner_rect) && num_items > 0 {
1529 let relative_row = (row - inner_rect.y) as usize;
1530 let item_idx = scroll_offset + relative_row;
1531 if item_idx < num_items {
1532 if let Some(popup) = self.global_popups.get_mut(popup_idx) {
1533 if let crate::view::popup::PopupContent::List { items: _, selected } =
1534 &mut popup.content
1535 {
1536 *selected = item_idx;
1537 }
1538 }
1539 return Some(self.handle_action(Action::PopupConfirm));
1540 }
1541 }
1542 }
1543 None
1544 }
1545
1546 fn handle_click_buffer_popups(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
1547 let close_hit = self.cached_layout.popup_areas.iter().rev().find_map(
1549 |(_idx, popup_rect, _inner, _scroll, _n, _sb, _tl)| {
1550 if popup_rect.width < 5 {
1551 return None;
1552 }
1553 let cb_x = popup_rect.x + popup_rect.width - 4;
1554 if row == popup_rect.y && col >= cb_x && col < cb_x + 3 {
1555 Some(())
1556 } else {
1557 None
1558 }
1559 },
1560 );
1561 if close_hit.is_some() {
1562 return Some(self.handle_action(Action::PopupCancel));
1563 }
1564
1565 let popup_areas = self.cached_layout.popup_areas.clone();
1567 for (popup_idx, _popup_rect, inner_rect, scroll_offset, num_items, _, _) in
1568 popup_areas.iter().rev()
1569 {
1570 if !in_rect(col, row, *inner_rect) {
1571 continue;
1572 }
1573 let relative_col = (col - inner_rect.x) as usize;
1574 let relative_row = (row - inner_rect.y) as usize;
1575
1576 let link_url = {
1577 let state = self.active_state();
1578 state
1579 .popups
1580 .top()
1581 .and_then(|p| p.link_at_position(relative_col, relative_row))
1582 };
1583 if let Some(url) = link_url {
1584 #[cfg(feature = "runtime")]
1585 if let Err(e) = open::that(&url) {
1586 self.set_status_message(format!("Failed to open URL: {}", e));
1587 } else {
1588 self.set_status_message(format!("Opening: {}", url));
1589 }
1590 return Some(Ok(()));
1591 }
1592
1593 if *num_items > 0 {
1594 let item_idx = scroll_offset + relative_row;
1595 if item_idx < *num_items {
1596 let state = self.active_state_mut();
1597 if let Some(popup) = state.popups.top_mut() {
1598 if let crate::view::popup::PopupContent::List { items: _, selected } =
1599 &mut popup.content
1600 {
1601 *selected = item_idx;
1602 }
1603 }
1604 return Some(self.handle_action(Action::PopupConfirm));
1605 }
1606 }
1607
1608 let is_text_popup = {
1609 let state = self.active_state();
1610 state.popups.top().is_some_and(|p| {
1611 matches!(
1612 p.content,
1613 crate::view::popup::PopupContent::Text(_)
1614 | crate::view::popup::PopupContent::Markdown(_)
1615 )
1616 })
1617 };
1618 if is_text_popup {
1619 let line = scroll_offset + relative_row;
1620 let popup_idx_copy = *popup_idx;
1621 let state = self.active_state_mut();
1622 if let Some(popup) = state.popups.top_mut() {
1623 popup.start_selection(line, relative_col);
1624 }
1625 self.mouse_state.selecting_in_popup = Some(popup_idx_copy);
1626 return Some(Ok(()));
1627 }
1628 }
1629 None
1630 }
1631
1632 fn handle_click_menu_bar(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
1633 if self.menu_bar_visible {
1634 let hit = self
1636 .cached_layout
1637 .menu_layout
1638 .as_ref()
1639 .and_then(|ml| ml.menu_at(col, row));
1640 let layout_exists = self.cached_layout.menu_layout.is_some();
1641 if layout_exists {
1642 if let Some(menu_idx) = hit {
1643 if self.menu_state.active_menu == Some(menu_idx) {
1644 self.close_menu_with_auto_hide();
1645 } else {
1646 self.on_editor_focus_lost();
1647 self.menu_state.open_menu(menu_idx);
1648 }
1649 return Some(Ok(()));
1650 } else if row == 0 {
1651 self.close_menu_with_auto_hide();
1652 return Some(Ok(()));
1653 }
1654 }
1655 }
1656
1657 if let Some(active_idx) = self.menu_state.active_menu {
1658 let all_menus: Vec<crate::config::Menu> = self
1659 .menus
1660 .menus
1661 .iter()
1662 .chain(self.menu_state.plugin_menus.iter())
1663 .cloned()
1664 .collect();
1665 if let Some(menu) = all_menus.get(active_idx) {
1666 match self.handle_menu_dropdown_click(col, row, menu) {
1667 Ok(Some(click_result)) => return Some(click_result),
1668 Ok(None) => {}
1669 Err(e) => return Some(Err(e)),
1670 }
1671 }
1672 self.close_menu_with_auto_hide();
1673 return Some(Ok(()));
1674 }
1675
1676 None
1677 }
1678
1679 fn handle_click_file_explorer_area(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
1680 let 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 && row >= explorer_area.y && row < explorer_area.y + explorer_area.height
1683 {
1684 self.mouse_state.dragging_file_explorer = true;
1685 self.mouse_state.drag_start_position = Some((col, row));
1686 self.mouse_state.drag_start_explorer_width = Some(self.file_explorer_width);
1687 return Some(Ok(()));
1688 }
1689 if in_rect(col, row, explorer_area) {
1690 return Some(self.handle_file_explorer_click(col, row, explorer_area));
1691 }
1692 None
1693 }
1694
1695 fn handle_click_scrollbar(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
1696 let (split_id, buffer_id, scrollbar_rect, is_on_thumb) =
1697 self.cached_layout.split_areas.iter().find_map(
1698 |(split_id, buffer_id, _content, scrollbar_rect, thumb_start, thumb_end)| {
1699 if in_rect(col, row, *scrollbar_rect) {
1700 let relative_row = row.saturating_sub(scrollbar_rect.y) as usize;
1701 let on_thumb = relative_row >= *thumb_start && relative_row < *thumb_end;
1702 Some((*split_id, *buffer_id, *scrollbar_rect, on_thumb))
1703 } else {
1704 None
1705 }
1706 },
1707 )?;
1708
1709 self.focus_split(split_id, buffer_id);
1710 if is_on_thumb {
1711 self.mouse_state.dragging_scrollbar = Some(split_id);
1712 self.mouse_state.drag_start_row = Some(row);
1713 if self.is_composite_buffer(buffer_id) {
1714 if let Some(vs) = self.composite_view_states.get(&(split_id, buffer_id)) {
1715 self.mouse_state.drag_start_composite_scroll_row = Some(vs.scroll_row);
1716 }
1717 } else if let Some(vs) = self.split_view_states.get(&split_id) {
1718 self.mouse_state.drag_start_top_byte = Some(vs.viewport.top_byte);
1719 self.mouse_state.drag_start_view_line_offset =
1720 Some(vs.viewport.top_view_line_offset);
1721 }
1722 } else {
1723 self.mouse_state.dragging_scrollbar = Some(split_id);
1724 if let Err(e) =
1725 self.handle_scrollbar_jump(col, row, split_id, buffer_id, scrollbar_rect)
1726 {
1727 return Some(Err(e));
1728 }
1729 self.mouse_state.hover_target = Some(HoverTarget::ScrollbarThumb(split_id));
1730 }
1731 Some(Ok(()))
1732 }
1733
1734 fn handle_click_horizontal_scrollbar(
1735 &mut self,
1736 col: u16,
1737 row: u16,
1738 ) -> Option<AnyhowResult<()>> {
1739 let (split_id, buffer_id, hscrollbar_rect, max_content_width, is_on_thumb) = self
1740 .cached_layout
1741 .horizontal_scrollbar_areas
1742 .iter()
1743 .find_map(
1744 |(
1745 split_id,
1746 buffer_id,
1747 hscrollbar_rect,
1748 max_content_width,
1749 thumb_start,
1750 thumb_end,
1751 )| {
1752 if col >= hscrollbar_rect.x
1753 && col < hscrollbar_rect.x + hscrollbar_rect.width
1754 && row >= hscrollbar_rect.y
1755 && row < hscrollbar_rect.y + hscrollbar_rect.height
1756 {
1757 let relative_col = col.saturating_sub(hscrollbar_rect.x) as usize;
1758 let on_thumb = relative_col >= *thumb_start && relative_col < *thumb_end;
1759 Some((
1760 *split_id,
1761 *buffer_id,
1762 *hscrollbar_rect,
1763 *max_content_width,
1764 on_thumb,
1765 ))
1766 } else {
1767 None
1768 }
1769 },
1770 )?;
1771
1772 self.focus_split(split_id, buffer_id);
1773 self.mouse_state.dragging_horizontal_scrollbar = Some(split_id);
1774 if is_on_thumb {
1775 self.mouse_state.drag_start_hcol = Some(col);
1776 if let Some(vs) = self.split_view_states.get(&split_id) {
1777 self.mouse_state.drag_start_left_column = Some(vs.viewport.left_column);
1778 }
1779 } else {
1780 self.mouse_state.drag_start_hcol = None;
1781 self.mouse_state.drag_start_left_column = None;
1782 let relative_col = col.saturating_sub(hscrollbar_rect.x) as f64;
1783 let track_width = hscrollbar_rect.width as f64;
1784 let ratio = if track_width > 1.0 {
1785 (relative_col / (track_width - 1.0)).clamp(0.0, 1.0)
1786 } else {
1787 0.0
1788 };
1789 if let Some(vs) = self.split_view_states.get_mut(&split_id) {
1790 let visible_width = vs.viewport.width as usize;
1791 let max_scroll = max_content_width.saturating_sub(visible_width);
1792 let target_col = (ratio * max_scroll as f64).round() as usize;
1793 vs.viewport.left_column = target_col.min(max_scroll);
1794 vs.viewport.set_skip_ensure_visible();
1795 }
1796 }
1797 Some(Ok(()))
1798 }
1799
1800 fn handle_click_status_bar(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
1801 let (status_row, _status_x, _status_width) = self.cached_layout.status_bar_area?;
1802 if row != status_row {
1803 return None;
1804 }
1805 if let Some((r, s, e)) = self.cached_layout.status_bar_line_ending_area {
1806 if row == r && col >= s && col < e {
1807 return Some(self.handle_action(Action::SetLineEnding));
1808 }
1809 }
1810 if let Some((r, s, e)) = self.cached_layout.status_bar_encoding_area {
1811 if row == r && col >= s && col < e {
1812 return Some(self.handle_action(Action::SetEncoding));
1813 }
1814 }
1815 if let Some((r, s, e)) = self.cached_layout.status_bar_language_area {
1816 if row == r && col >= s && col < e {
1817 return Some(self.handle_action(Action::SetLanguage));
1818 }
1819 }
1820 if let Some((r, s, e)) = self.cached_layout.status_bar_lsp_area {
1821 if row == r && col >= s && col < e {
1822 return Some(self.handle_action(Action::ShowLspStatus));
1823 }
1824 }
1825 if let Some((r, s, e)) = self.cached_layout.status_bar_remote_area {
1826 if row == r && col >= s && col < e {
1827 return Some(self.handle_action(Action::ShowRemoteIndicatorMenu));
1828 }
1829 }
1830 if let Some((r, s, e)) = self.cached_layout.status_bar_warning_area {
1831 if row == r && col >= s && col < e {
1832 return Some(self.handle_action(Action::ShowWarnings));
1833 }
1834 }
1835 if let Some((r, s, e)) = self.cached_layout.status_bar_message_area {
1836 if row == r && col >= s && col < e {
1837 return Some(self.handle_action(Action::ShowStatusLog));
1838 }
1839 }
1840 None
1841 }
1842
1843 fn handle_click_search_options(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
1844 use crate::view::ui::status_bar::SearchOptionsHover;
1845 let layout = self.cached_layout.search_options_layout.clone()?;
1846 match layout.checkbox_at(col, row)? {
1847 SearchOptionsHover::CaseSensitive => {
1848 Some(self.handle_action(Action::ToggleSearchCaseSensitive))
1849 }
1850 SearchOptionsHover::WholeWord => {
1851 Some(self.handle_action(Action::ToggleSearchWholeWord))
1852 }
1853 SearchOptionsHover::Regex => Some(self.handle_action(Action::ToggleSearchRegex)),
1854 SearchOptionsHover::ConfirmEach => {
1855 Some(self.handle_action(Action::ToggleSearchConfirmEach))
1856 }
1857 SearchOptionsHover::None => None,
1858 }
1859 }
1860
1861 fn handle_click_split_separator(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
1862 let separator_areas = self.cached_layout.separator_areas.clone();
1863 for (split_id, direction, sep_x, sep_y, sep_length) in &separator_areas {
1864 let is_on_separator = match direction {
1865 SplitDirection::Horizontal => {
1866 row == *sep_y && col >= *sep_x && col < sep_x + sep_length
1867 }
1868 SplitDirection::Vertical => {
1869 col == *sep_x && row >= *sep_y && row < sep_y + sep_length
1870 }
1871 };
1872 if is_on_separator {
1873 self.mouse_state.dragging_separator = Some((*split_id, *direction));
1874 self.mouse_state.drag_start_position = Some((col, row));
1875 let ratio = self
1876 .split_manager
1877 .get_ratio((*split_id).into())
1878 .or_else(|| self.grouped_split_ratio(*split_id));
1879 if let Some(ratio) = ratio {
1880 self.mouse_state.drag_start_ratio = Some(ratio);
1881 }
1882 return Some(Ok(()));
1883 }
1884 }
1885 None
1886 }
1887
1888 fn handle_click_split_controls(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
1889 let close_split_id = self
1890 .cached_layout
1891 .close_split_areas
1892 .iter()
1893 .find(|(_, btn_row, start_col, end_col)| {
1894 row == *btn_row && col >= *start_col && col < *end_col
1895 })
1896 .map(|(split_id, _, _, _)| *split_id);
1897 if let Some(split_id) = close_split_id {
1898 if let Err(e) = self.split_manager.close_split(split_id) {
1899 self.set_status_message(
1900 t!("error.cannot_close_split", error = e.to_string()).to_string(),
1901 );
1902 } else {
1903 let new_active = self.split_manager.active_split();
1904 if let Some(buffer_id) = self.split_manager.buffer_for_split(new_active) {
1905 self.set_active_buffer(buffer_id);
1906 }
1907 self.set_status_message(t!("split.closed").to_string());
1908 }
1909 return Some(Ok(()));
1910 }
1911
1912 let maximize_hit = self.cached_layout.maximize_split_areas.iter().any(
1913 |(_, btn_row, start_col, end_col)| {
1914 row == *btn_row && col >= *start_col && col < *end_col
1915 },
1916 );
1917 if maximize_hit {
1918 match self.split_manager.toggle_maximize() {
1919 Ok(maximized) => {
1920 let msg = if maximized {
1921 t!("split.maximized").to_string()
1922 } else {
1923 t!("split.restored").to_string()
1924 };
1925 self.set_status_message(msg);
1926 }
1927 Err(e) => self.set_status_message(e),
1928 }
1929 return Some(Ok(()));
1930 }
1931
1932 None
1933 }
1934
1935 fn handle_click_tab_bar(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
1936 for (split_id, tab_layout) in &self.cached_layout.tab_layouts {
1937 tracing::debug!(
1938 "Tab layout for split {:?}: bar_area={:?}, left_scroll={:?}, right_scroll={:?}",
1939 split_id,
1940 tab_layout.bar_area,
1941 tab_layout.left_scroll_area,
1942 tab_layout.right_scroll_area
1943 );
1944 }
1945 let tab_hit = self
1946 .cached_layout
1947 .tab_layouts
1948 .iter()
1949 .find_map(|(split_id, tab_layout)| {
1950 let hit = tab_layout.hit_test(col, row);
1951 tracing::debug!(
1952 "Tab hit_test at ({}, {}) for split {:?} returned {:?}",
1953 col,
1954 row,
1955 split_id,
1956 hit
1957 );
1958 hit.map(|h| (*split_id, h))
1959 });
1960 let (split_id, hit) = tab_hit?;
1961 match hit {
1962 TabHit::CloseButton(target) => {
1963 match target {
1964 crate::view::split::TabTarget::Buffer(buffer_id) => {
1965 self.focus_split(split_id, buffer_id);
1966 self.close_tab_in_split(buffer_id, split_id);
1967 }
1968 crate::view::split::TabTarget::Group(group_leaf) => {
1969 self.close_buffer_group_by_leaf(group_leaf);
1970 }
1971 }
1972 Some(Ok(()))
1973 }
1974 TabHit::TabName(target) => {
1975 let direction = self
1976 .split_view_states
1977 .get(&split_id)
1978 .map(|vs| {
1979 let open = &vs.open_buffers;
1980 let cur = vs.active_target();
1981 let cur_idx = open.iter().position(|t| *t == cur);
1982 let new_idx = open.iter().position(|t| *t == target);
1983 match (cur_idx, new_idx) {
1984 (Some(c), Some(n)) if n > c => 1,
1985 (Some(c), Some(n)) if n < c => -1,
1986 _ => 0,
1987 }
1988 })
1989 .unwrap_or(0);
1990 self.animate_tab_switch(split_id, direction);
1991 match target {
1992 crate::view::split::TabTarget::Buffer(buffer_id) => {
1993 self.focus_split(split_id, buffer_id);
1994 self.promote_buffer_from_preview(buffer_id);
1995 self.mouse_state.dragging_tab = Some(super::types::TabDragState::new(
1996 buffer_id,
1997 split_id,
1998 (col, row),
1999 ));
2000 }
2001 crate::view::split::TabTarget::Group(group_leaf) => {
2002 self.activate_group_tab(split_id, group_leaf);
2003 }
2004 }
2005 Some(Ok(()))
2006 }
2007 TabHit::ScrollLeft => {
2008 self.set_status_message("ScrollLeft clicked!".to_string());
2009 if let Some(vs) = self.split_view_states.get_mut(&split_id) {
2010 vs.tab_scroll_offset = vs.tab_scroll_offset.saturating_sub(10);
2011 }
2012 Some(Ok(()))
2013 }
2014 TabHit::ScrollRight => {
2015 self.set_status_message("ScrollRight clicked!".to_string());
2016 if let Some(vs) = self.split_view_states.get_mut(&split_id) {
2017 vs.tab_scroll_offset = vs.tab_scroll_offset.saturating_add(10);
2018 }
2019 Some(Ok(()))
2020 }
2021 TabHit::BarBackground => None,
2022 }
2023 }
2024
2025 pub(super) fn handle_mouse_drag(&mut self, col: u16, row: u16) -> AnyhowResult<()> {
2027 if let Some(dragging_split_id) = self.mouse_state.dragging_scrollbar {
2029 for (split_id, buffer_id, _content_rect, scrollbar_rect, _thumb_start, _thumb_end) in
2031 &self.cached_layout.split_areas
2032 {
2033 if *split_id == dragging_split_id {
2034 if self.mouse_state.drag_start_row.is_some() {
2036 self.handle_scrollbar_drag_relative(
2038 row,
2039 *split_id,
2040 *buffer_id,
2041 *scrollbar_rect,
2042 )?;
2043 } else {
2044 self.handle_scrollbar_jump(
2046 col,
2047 row,
2048 *split_id,
2049 *buffer_id,
2050 *scrollbar_rect,
2051 )?;
2052 }
2053 return Ok(());
2054 }
2055 }
2056 }
2057
2058 if let Some(dragging_split_id) = self.mouse_state.dragging_horizontal_scrollbar {
2060 for (
2061 split_id,
2062 _buffer_id,
2063 hscrollbar_rect,
2064 max_content_width,
2065 thumb_start,
2066 thumb_end,
2067 ) in &self.cached_layout.horizontal_scrollbar_areas
2068 {
2069 if *split_id == dragging_split_id {
2070 let track_width = hscrollbar_rect.width as f64;
2071 if track_width <= 1.0 {
2072 break;
2073 }
2074
2075 if let (Some(drag_start_hcol), Some(drag_start_left_column)) = (
2076 self.mouse_state.drag_start_hcol,
2077 self.mouse_state.drag_start_left_column,
2078 ) {
2079 let col_offset = (col as i32) - (drag_start_hcol as i32);
2082 if let Some(view_state) = self.split_view_states.get_mut(&dragging_split_id)
2083 {
2084 let visible_width = view_state.viewport.width as usize;
2085 let max_scroll = max_content_width.saturating_sub(visible_width);
2086 if max_scroll > 0 {
2087 let thumb_size = thumb_end.saturating_sub(*thumb_start).max(1);
2088 let track_travel = (track_width - thumb_size as f64).max(1.0);
2089 let scroll_per_pixel = max_scroll as f64 / track_travel;
2090 let scroll_offset =
2091 (col_offset as f64 * scroll_per_pixel).round() as i64;
2092 let new_left =
2093 (drag_start_left_column as i64 + scroll_offset).max(0) as usize;
2094 view_state.viewport.left_column = new_left.min(max_scroll);
2095 view_state.viewport.set_skip_ensure_visible();
2096 }
2097 }
2098 } else {
2099 let relative_col = col.saturating_sub(hscrollbar_rect.x) as f64;
2101 let ratio = (relative_col / (track_width - 1.0)).clamp(0.0, 1.0);
2102
2103 if let Some(view_state) = self.split_view_states.get_mut(&dragging_split_id)
2104 {
2105 let visible_width = view_state.viewport.width as usize;
2106 let max_scroll = max_content_width.saturating_sub(visible_width);
2107 let target_col = (ratio * max_scroll as f64).round() as usize;
2108 view_state.viewport.left_column = target_col.min(max_scroll);
2109 view_state.viewport.set_skip_ensure_visible();
2110 }
2111 }
2112
2113 return Ok(());
2114 }
2115 }
2116 }
2117
2118 if let Some(popup_idx) = self.mouse_state.selecting_in_popup {
2120 if let Some((_, _, inner_rect, scroll_offset, _, _, _)) = self
2122 .cached_layout
2123 .popup_areas
2124 .iter()
2125 .find(|(idx, _, _, _, _, _, _)| *idx == popup_idx)
2126 {
2127 if col >= inner_rect.x
2129 && col < inner_rect.x + inner_rect.width
2130 && row >= inner_rect.y
2131 && row < inner_rect.y + inner_rect.height
2132 {
2133 let relative_col = (col - inner_rect.x) as usize;
2134 let relative_row = (row - inner_rect.y) as usize;
2135 let line = scroll_offset + relative_row;
2136
2137 let state = self.active_state_mut();
2138 if let Some(popup) = state.popups.get_mut(popup_idx) {
2139 popup.extend_selection(line, relative_col);
2140 }
2141 }
2142 }
2143 return Ok(());
2144 }
2145
2146 if let Some(popup_idx) = self.mouse_state.dragging_popup_scrollbar {
2148 if let Some((_, _, inner_rect, _, _, Some(sb_rect), total_lines)) = self
2150 .cached_layout
2151 .popup_areas
2152 .iter()
2153 .find(|(idx, _, _, _, _, _, _)| *idx == popup_idx)
2154 {
2155 let track_height = sb_rect.height as usize;
2156 let visible_lines = inner_rect.height as usize;
2157
2158 if track_height > 0 && *total_lines > visible_lines {
2159 let relative_row = row.saturating_sub(sb_rect.y) as usize;
2160 let max_scroll = total_lines.saturating_sub(visible_lines);
2161 let target_scroll = if track_height > 1 {
2162 (relative_row * max_scroll) / (track_height.saturating_sub(1))
2163 } else {
2164 0
2165 };
2166
2167 let state = self.active_state_mut();
2168 if let Some(popup) = state.popups.get_mut(popup_idx) {
2169 let current_scroll = popup.scroll_offset as i32;
2170 let delta = target_scroll as i32 - current_scroll;
2171 popup.scroll_by(delta);
2172 }
2173 }
2174 }
2175 return Ok(());
2176 }
2177
2178 if let Some((split_id, direction)) = self.mouse_state.dragging_separator {
2180 self.handle_separator_drag(col, row, split_id, direction)?;
2181 return Ok(());
2182 }
2183
2184 if self.mouse_state.dragging_file_explorer {
2186 self.handle_file_explorer_border_drag(col)?;
2187 return Ok(());
2188 }
2189
2190 if self.mouse_state.dragging_text_selection {
2192 self.handle_text_selection_drag(col, row)?;
2193 return Ok(());
2194 }
2195
2196 if self.mouse_state.dragging_tab.is_some() {
2198 self.handle_tab_drag(col, row)?;
2199 return Ok(());
2200 }
2201
2202 Ok(())
2203 }
2204
2205 fn handle_text_selection_drag(&mut self, col: u16, row: u16) -> AnyhowResult<()> {
2207 use crate::model::event::Event;
2208 use crate::primitives::word_navigation::{find_word_end, find_word_start};
2209
2210 let Some(split_id) = self.mouse_state.drag_selection_split else {
2211 return Ok(());
2212 };
2213 let Some(anchor_position) = self.mouse_state.drag_selection_anchor else {
2214 return Ok(());
2215 };
2216
2217 let Some((buffer_id, content_rect)) = self
2219 .cached_layout
2220 .split_areas
2221 .iter()
2222 .find(|(sid, _, _, _, _, _)| *sid == split_id)
2223 .map(|(_, bid, rect, _, _, _)| (*bid, *rect))
2224 else {
2225 return Ok(());
2226 };
2227
2228 let cached_mappings = self
2230 .cached_layout
2231 .view_line_mappings
2232 .get(&split_id)
2233 .cloned();
2234
2235 let leaf_id = split_id;
2236
2237 let fallback = self
2239 .split_view_states
2240 .get(&leaf_id)
2241 .map(|vs| vs.viewport.top_byte)
2242 .unwrap_or(0);
2243
2244 let compose_width = self
2246 .split_view_states
2247 .get(&leaf_id)
2248 .and_then(|vs| vs.compose_width);
2249
2250 if let Some(state) = self.buffers.get_mut(&buffer_id) {
2252 let gutter_width = state.margins.left_total_width() as u16;
2253
2254 let Some(target_position) = super::click_geometry::screen_to_buffer_position(
2255 col,
2256 row,
2257 content_rect,
2258 gutter_width,
2259 &cached_mappings,
2260 fallback,
2261 true, compose_width,
2263 ) else {
2264 return Ok(());
2265 };
2266
2267 let (new_position, anchor_position) = if self.mouse_state.drag_selection_by_words {
2272 if target_position >= anchor_position {
2273 (
2274 find_word_end(&state.buffer, target_position),
2275 anchor_position,
2276 )
2277 } else {
2278 let word_end = self
2279 .mouse_state
2280 .drag_selection_word_end
2281 .unwrap_or(anchor_position);
2282 (find_word_start(&state.buffer, target_position), word_end)
2283 }
2284 } else {
2285 (target_position, anchor_position)
2286 };
2287
2288 let (primary_cursor_id, old_position, old_anchor, old_sticky_column) = self
2289 .split_view_states
2290 .get(&leaf_id)
2291 .map(|vs| {
2292 let cursor = vs.cursors.primary();
2293 (
2294 vs.cursors.primary_id(),
2295 cursor.position,
2296 cursor.anchor,
2297 cursor.sticky_column,
2298 )
2299 })
2300 .unwrap_or((CursorId(0), 0, None, 0));
2301
2302 let new_sticky_column = state
2303 .buffer
2304 .offset_to_position(new_position)
2305 .map(|pos| pos.column)
2306 .unwrap_or(old_sticky_column);
2307 let event = Event::MoveCursor {
2308 cursor_id: primary_cursor_id,
2309 old_position,
2310 new_position,
2311 old_anchor,
2312 new_anchor: Some(anchor_position), old_sticky_column,
2314 new_sticky_column,
2315 };
2316
2317 if let Some(event_log) = self.event_logs.get_mut(&buffer_id) {
2318 event_log.append(event.clone());
2319 }
2320 if let Some(cursors) = self
2321 .split_view_states
2322 .get_mut(&leaf_id)
2323 .map(|vs| &mut vs.cursors)
2324 {
2325 state.apply(cursors, &event);
2326 }
2327 }
2328
2329 Ok(())
2330 }
2331
2332 pub(super) fn handle_file_explorer_border_drag(&mut self, col: u16) -> AnyhowResult<()> {
2334 let Some((start_col, _start_row)) = self.mouse_state.drag_start_position else {
2335 return Ok(());
2336 };
2337 let Some(start_width) = self.mouse_state.drag_start_explorer_width else {
2338 return Ok(());
2339 };
2340
2341 let delta = col as i32 - start_col as i32;
2342 let total_width = self.terminal_width as i32;
2343
2344 if total_width > 0 {
2348 use crate::config::ExplorerWidth;
2349 self.file_explorer_width = match start_width {
2350 ExplorerWidth::Percent(start_pct) => {
2351 let percent_delta = (delta * 100) / total_width;
2352 let new_pct = (start_pct as i32 + percent_delta).clamp(0, 100) as u8;
2353 ExplorerWidth::Percent(new_pct)
2354 }
2355 ExplorerWidth::Columns(start_cols) => {
2356 let new_cols = (start_cols as i32 + delta).clamp(0, total_width) as u16;
2357 ExplorerWidth::Columns(new_cols)
2358 }
2359 };
2360 }
2361
2362 Ok(())
2363 }
2364
2365 pub(super) fn handle_separator_drag(
2367 &mut self,
2368 col: u16,
2369 row: u16,
2370 split_id: ContainerId,
2371 direction: SplitDirection,
2372 ) -> AnyhowResult<()> {
2373 let Some((start_col, start_row)) = self.mouse_state.drag_start_position else {
2374 return Ok(());
2375 };
2376 let Some(start_ratio) = self.mouse_state.drag_start_ratio else {
2377 return Ok(());
2378 };
2379 let Some(editor_area) = self.cached_layout.editor_content_area else {
2380 return Ok(());
2381 };
2382
2383 let (delta, total_size) = match direction {
2385 SplitDirection::Horizontal => {
2386 let delta = row as i32 - start_row as i32;
2388 let total = editor_area.height as i32;
2389 (delta, total)
2390 }
2391 SplitDirection::Vertical => {
2392 let delta = col as i32 - start_col as i32;
2394 let total = editor_area.width as i32;
2395 (delta, total)
2396 }
2397 };
2398
2399 if total_size > 0 {
2402 let ratio_delta = delta as f32 / total_size as f32;
2403 let new_ratio = (start_ratio + ratio_delta).clamp(0.1, 0.9);
2404
2405 if self.split_manager.get_ratio(split_id.into()).is_some() {
2410 self.split_manager.set_ratio(split_id, new_ratio);
2411 } else {
2412 self.set_grouped_split_ratio(split_id, new_ratio);
2413 }
2414 }
2415
2416 Ok(())
2417 }
2418
2419 pub(super) fn handle_right_click(&mut self, col: u16, row: u16) -> AnyhowResult<()> {
2421 if let Some(ref menu) = self.file_explorer_context_menu {
2422 let (menu_x, menu_y) = menu.clamped_position(
2423 self.cached_layout.last_frame_width,
2424 self.cached_layout.last_frame_height,
2425 );
2426 let menu_width = super::types::FILE_EXPLORER_CONTEXT_MENU_WIDTH;
2427 let menu_height = menu.height();
2428 if col >= menu_x
2429 && col < menu_x + menu_width
2430 && row >= menu_y
2431 && row < menu_y + menu_height
2432 {
2433 return Ok(());
2434 }
2435 }
2436
2437 if let Some(ref menu) = self.tab_context_menu {
2439 let menu_x = menu.position.0;
2440 let menu_y = menu.position.1;
2441 let menu_width = 22u16; let menu_height = super::types::TabContextMenuItem::all().len() as u16 + 2; if col >= menu_x
2446 && col < menu_x + menu_width
2447 && row >= menu_y
2448 && row < menu_y + menu_height
2449 {
2450 return Ok(());
2452 }
2453 }
2454
2455 if let Some(explorer_area) = self.cached_layout.file_explorer_area {
2456 if col >= explorer_area.x
2457 && col < explorer_area.x + explorer_area.width
2458 && row < explorer_area.y + explorer_area.height
2459 && row > explorer_area.y
2460 {
2462 let relative_row = row.saturating_sub(explorer_area.y + 1);
2463 let (is_multi, is_root_selected) =
2464 if let Some(ref mut explorer) = self.file_explorer {
2465 let display_nodes = explorer.get_display_nodes();
2466 let scroll_offset = explorer.get_scroll_offset();
2467 let clicked_index = (relative_row as usize) + scroll_offset;
2468 let mut clicked_is_root = false;
2469 if clicked_index < display_nodes.len() {
2470 let (node_id, _) = display_nodes[clicked_index];
2471 explorer.set_selected(Some(node_id));
2472 clicked_is_root = node_id == explorer.tree().root_id();
2473 }
2474 (explorer.has_multi_selection(), clicked_is_root)
2475 } else {
2476 (false, false)
2477 };
2478 self.key_context = crate::input::keybindings::KeyContext::FileExplorer;
2479 self.tab_context_menu = None;
2480 self.file_explorer_context_menu = Some(super::types::FileExplorerContextMenu::new(
2481 col,
2482 row + 1,
2483 is_multi,
2484 is_root_selected,
2485 ));
2486 return Ok(());
2487 }
2488 }
2489
2490 self.file_explorer_context_menu = None;
2491
2492 let tab_hit =
2494 self.cached_layout.tab_layouts.iter().find_map(
2495 |(split_id, tab_layout)| match tab_layout.hit_test(col, row) {
2496 Some(TabHit::TabName(target) | TabHit::CloseButton(target)) => {
2497 target.as_buffer().map(|bid| (*split_id, bid))
2500 }
2501 _ => None,
2502 },
2503 );
2504
2505 if let Some((split_id, buffer_id)) = tab_hit {
2506 self.tab_context_menu = Some(TabContextMenu::new(buffer_id, split_id, col, row + 1));
2508 } else {
2509 self.tab_context_menu = None;
2511 }
2512
2513 Ok(())
2514 }
2515
2516 pub(super) fn handle_tab_context_menu_click(
2518 &mut self,
2519 col: u16,
2520 row: u16,
2521 ) -> Option<AnyhowResult<()>> {
2522 let menu = self.tab_context_menu.as_ref()?;
2523 let menu_x = menu.position.0;
2524 let menu_y = menu.position.1;
2525 let menu_width = 22u16;
2526 let items = super::types::TabContextMenuItem::all();
2527 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
2531 {
2532 self.tab_context_menu = None;
2534 return Some(Ok(()));
2535 }
2536
2537 if row == menu_y || row == menu_y + menu_height - 1 {
2539 return Some(Ok(()));
2540 }
2541
2542 let item_idx = (row - menu_y - 1) as usize;
2544 if item_idx >= items.len() {
2545 return Some(Ok(()));
2546 }
2547
2548 let buffer_id = menu.buffer_id;
2550 let split_id = menu.split_id;
2551 let item = items[item_idx];
2552
2553 self.tab_context_menu = None;
2555
2556 Some(self.execute_tab_context_menu_action(item, buffer_id, split_id))
2558 }
2559
2560 fn execute_tab_context_menu_action(
2562 &mut self,
2563 item: super::types::TabContextMenuItem,
2564 buffer_id: BufferId,
2565 leaf_id: LeafId,
2566 ) -> AnyhowResult<()> {
2567 use super::types::TabContextMenuItem;
2568 match item {
2569 TabContextMenuItem::Close => {
2570 self.close_tab_in_split(buffer_id, leaf_id);
2571 }
2572 TabContextMenuItem::CloseOthers => {
2573 self.close_other_tabs_in_split(buffer_id, leaf_id);
2574 }
2575 TabContextMenuItem::CloseToRight => {
2576 self.close_tabs_to_right_in_split(buffer_id, leaf_id);
2577 }
2578 TabContextMenuItem::CloseToLeft => {
2579 self.close_tabs_to_left_in_split(buffer_id, leaf_id);
2580 }
2581 TabContextMenuItem::CloseAll => {
2582 self.close_all_tabs_in_split(leaf_id);
2583 }
2584 TabContextMenuItem::CopyRelativePath => {
2585 self.copy_buffer_path(buffer_id, true);
2586 }
2587 TabContextMenuItem::CopyFullPath => {
2588 self.copy_buffer_path(buffer_id, false);
2589 }
2590 }
2591
2592 Ok(())
2593 }
2594
2595 pub(super) fn handle_file_explorer_context_menu_key(
2598 &mut self,
2599 code: crossterm::event::KeyCode,
2600 modifiers: crossterm::event::KeyModifiers,
2601 ) -> Option<AnyhowResult<()>> {
2602 use crossterm::event::KeyCode;
2603 use crossterm::event::KeyModifiers;
2604
2605 if modifiers != KeyModifiers::NONE {
2606 return None;
2607 }
2608
2609 match code {
2610 KeyCode::Up => {
2611 if let Some(ref mut menu) = self.file_explorer_context_menu {
2612 menu.prev_item();
2613 }
2614 Some(Ok(()))
2615 }
2616 KeyCode::Down => {
2617 if let Some(ref mut menu) = self.file_explorer_context_menu {
2618 menu.next_item();
2619 }
2620 Some(Ok(()))
2621 }
2622 KeyCode::Enter => {
2623 let item = {
2624 let menu = self.file_explorer_context_menu.as_ref()?;
2625 menu.items()[menu.highlighted]
2626 };
2627 self.file_explorer_context_menu = None;
2628 self.execute_file_explorer_context_menu_action(item);
2629 Some(Ok(()))
2630 }
2631 KeyCode::Esc => {
2632 self.file_explorer_context_menu = None;
2633 Some(Ok(()))
2634 }
2635 _ => None,
2636 }
2637 }
2638
2639 pub(super) fn handle_file_explorer_context_menu_click(
2641 &mut self,
2642 col: u16,
2643 row: u16,
2644 ) -> Option<AnyhowResult<()>> {
2645 let clicked_item: Option<super::types::FileExplorerContextMenuItem> = {
2647 let menu = self.file_explorer_context_menu.as_ref()?;
2648 let (menu_x, menu_y) = menu.clamped_position(
2649 self.cached_layout.last_frame_width,
2650 self.cached_layout.last_frame_height,
2651 );
2652 let menu_width = super::types::FILE_EXPLORER_CONTEXT_MENU_WIDTH;
2653 let menu_height = menu.height();
2654
2655 if col < menu_x
2656 || col >= menu_x + menu_width
2657 || row < menu_y
2658 || row >= menu_y + menu_height
2659 {
2660 self.file_explorer_context_menu = None;
2661 return Some(Ok(()));
2662 }
2663
2664 if row == menu_y || row == menu_y + menu_height - 1 {
2665 return Some(Ok(()));
2666 }
2667
2668 let item_idx = (row - menu_y - 1) as usize;
2669 menu.items().get(item_idx).copied()
2670 };
2671
2672 self.file_explorer_context_menu = None;
2673 if let Some(item) = clicked_item {
2674 self.execute_file_explorer_context_menu_action(item);
2675 }
2676 Some(Ok(()))
2677 }
2678
2679 fn execute_file_explorer_context_menu_action(
2680 &mut self,
2681 item: super::types::FileExplorerContextMenuItem,
2682 ) {
2683 use super::types::FileExplorerContextMenuItem;
2684 match item {
2685 FileExplorerContextMenuItem::NewFile => self.file_explorer_new_file(),
2686 FileExplorerContextMenuItem::NewDirectory => self.file_explorer_new_directory(),
2687 FileExplorerContextMenuItem::Rename => self.file_explorer_rename(),
2688 FileExplorerContextMenuItem::Cut => self.file_explorer_cut(),
2689 FileExplorerContextMenuItem::Copy => self.file_explorer_copy(),
2690 FileExplorerContextMenuItem::Paste => self.file_explorer_paste(),
2691 FileExplorerContextMenuItem::Delete => self.file_explorer_delete(),
2692 }
2693 }
2694
2695 fn show_file_explorer_status_tooltip(&mut self, path: std::path::PathBuf, col: u16, row: u16) {
2697 use crate::view::popup::{Popup, PopupPosition};
2698 use ratatui::style::Style;
2699
2700 let is_directory = path.is_dir();
2701
2702 let decoration = self
2704 .file_explorer_decoration_cache
2705 .direct_for_path(&path)
2706 .cloned();
2707
2708 let bubbled_decoration = if is_directory && decoration.is_none() {
2710 self.file_explorer_decoration_cache
2711 .bubbled_for_path(&path)
2712 .cloned()
2713 } else {
2714 None
2715 };
2716
2717 let has_unsaved_changes = if is_directory {
2719 self.buffers.iter().any(|(buffer_id, state)| {
2721 if state.buffer.is_modified() {
2722 if let Some(metadata) = self.buffer_metadata.get(buffer_id) {
2723 if let Some(file_path) = metadata.file_path() {
2724 return file_path.starts_with(&path);
2725 }
2726 }
2727 }
2728 false
2729 })
2730 } else {
2731 self.buffers.iter().any(|(buffer_id, state)| {
2732 if state.buffer.is_modified() {
2733 if let Some(metadata) = self.buffer_metadata.get(buffer_id) {
2734 return metadata.file_path() == Some(&path);
2735 }
2736 }
2737 false
2738 })
2739 };
2740
2741 let mut lines: Vec<String> = Vec::new();
2743
2744 if let Some(decoration) = &decoration {
2745 let symbol = &decoration.symbol;
2746 let explanation = match symbol.as_str() {
2747 "U" => "Untracked - File is not tracked by git",
2748 "M" => "Modified - File has unstaged changes",
2749 "A" => "Added - File is staged for commit",
2750 "D" => "Deleted - File is staged for deletion",
2751 "R" => "Renamed - File has been renamed",
2752 "C" => "Copied - File has been copied",
2753 "!" => "Conflicted - File has merge conflicts",
2754 "●" => "Has changes - Contains modified files",
2755 _ => "Unknown status",
2756 };
2757 lines.push(format!("{} - {}", symbol, explanation));
2758 } else if bubbled_decoration.is_some() {
2759 lines.push("● - Contains modified files".to_string());
2760 } else if has_unsaved_changes {
2761 if is_directory {
2762 lines.push("● - Contains unsaved changes".to_string());
2763 } else {
2764 lines.push("● - Unsaved changes in editor".to_string());
2765 }
2766 } else {
2767 return; }
2769
2770 if is_directory {
2772 if let Some(modified_files) = self.get_modified_files_in_directory(&path) {
2774 lines.push(String::new()); lines.push("Modified files:".to_string());
2776 let resolved_path = path.canonicalize().unwrap_or_else(|_| path.clone());
2778 const MAX_FILES: usize = 8;
2779 for (i, file) in modified_files.iter().take(MAX_FILES).enumerate() {
2780 let display_name = file
2782 .strip_prefix(&resolved_path)
2783 .unwrap_or(file)
2784 .to_string_lossy()
2785 .to_string();
2786 lines.push(format!(" {}", display_name));
2787 if i == MAX_FILES - 1 && modified_files.len() > MAX_FILES {
2788 lines.push(format!(
2789 " ... and {} more",
2790 modified_files.len() - MAX_FILES
2791 ));
2792 break;
2793 }
2794 }
2795 }
2796 } else {
2797 if let Some(stats) = self.get_git_diff_stats(&path) {
2799 lines.push(String::new()); lines.push(stats);
2801 }
2802 }
2803
2804 if lines.is_empty() {
2805 return;
2806 }
2807
2808 let mut popup = Popup::text(lines, &self.theme);
2810 popup.title = Some("Git Status".to_string());
2811 popup.transient = true;
2812 popup.position = PopupPosition::Fixed { x: col, y: row + 1 };
2813 popup.width = 50;
2814 popup.max_height = 15;
2815 popup.border_style = Style::default().fg(self.theme.popup_border_fg);
2816 popup.background_style = Style::default().bg(self.theme.popup_bg);
2817
2818 if let Some(state) = self.buffers.get_mut(&self.active_buffer()) {
2820 state.popups.show(popup);
2821 }
2822 }
2823
2824 fn dismiss_file_explorer_status_tooltip(&mut self) {
2826 if let Some(state) = self.buffers.get_mut(&self.active_buffer()) {
2828 state.popups.dismiss_transient();
2829 }
2830 }
2831
2832 fn get_git_diff_stats(&self, path: &std::path::Path) -> Option<String> {
2834 use crate::services::process_hidden::HideWindow;
2835 use std::process::Command;
2836
2837 let output = Command::new("git")
2839 .args(["diff", "--numstat", "--"])
2840 .arg(path)
2841 .current_dir(&self.working_dir)
2842 .hide_window()
2843 .output()
2844 .ok()?;
2845
2846 if !output.status.success() {
2847 return None;
2848 }
2849
2850 let stdout = String::from_utf8_lossy(&output.stdout);
2851 let line = stdout.lines().next()?;
2852 let parts: Vec<&str> = line.split('\t').collect();
2853
2854 if parts.len() >= 2 {
2855 let insertions = parts[0];
2856 let deletions = parts[1];
2857
2858 if insertions == "-" && deletions == "-" {
2860 return Some("Binary file changed".to_string());
2861 }
2862
2863 let ins: i32 = insertions.parse().unwrap_or(0);
2864 let del: i32 = deletions.parse().unwrap_or(0);
2865
2866 if ins > 0 || del > 0 {
2867 return Some(format!("+{} -{} lines", ins, del));
2868 }
2869 }
2870
2871 let staged_output = Command::new("git")
2873 .args(["diff", "--numstat", "--cached", "--"])
2874 .arg(path)
2875 .current_dir(&self.working_dir)
2876 .hide_window()
2877 .output()
2878 .ok()?;
2879
2880 if staged_output.status.success() {
2881 let staged_stdout = String::from_utf8_lossy(&staged_output.stdout);
2882 if let Some(line) = staged_stdout.lines().next() {
2883 let parts: Vec<&str> = line.split('\t').collect();
2884 if parts.len() >= 2 {
2885 let insertions = parts[0];
2886 let deletions = parts[1];
2887
2888 if insertions == "-" && deletions == "-" {
2889 return Some("Binary file staged".to_string());
2890 }
2891
2892 let ins: i32 = insertions.parse().unwrap_or(0);
2893 let del: i32 = deletions.parse().unwrap_or(0);
2894
2895 if ins > 0 || del > 0 {
2896 return Some(format!("+{} -{} lines (staged)", ins, del));
2897 }
2898 }
2899 }
2900 }
2901
2902 None
2903 }
2904
2905 fn get_modified_files_in_directory(
2907 &self,
2908 dir_path: &std::path::Path,
2909 ) -> Option<Vec<std::path::PathBuf>> {
2910 use crate::services::process_hidden::HideWindow;
2911 use std::process::Command;
2912
2913 let resolved_path = dir_path
2915 .canonicalize()
2916 .unwrap_or_else(|_| dir_path.to_path_buf());
2917
2918 let output = Command::new("git")
2920 .args(["status", "--porcelain", "--"])
2921 .arg(&resolved_path)
2922 .current_dir(&self.working_dir)
2923 .hide_window()
2924 .output()
2925 .ok()?;
2926
2927 if !output.status.success() {
2928 return None;
2929 }
2930
2931 let stdout = String::from_utf8_lossy(&output.stdout);
2932 let modified_files: Vec<std::path::PathBuf> = stdout
2933 .lines()
2934 .filter_map(|line| {
2935 if line.len() > 3 {
2938 let file_part = &line[3..];
2939 let file_name = if file_part.contains(" -> ") {
2941 file_part.split(" -> ").last().unwrap_or(file_part)
2942 } else {
2943 file_part
2944 };
2945 Some(self.working_dir.join(file_name))
2946 } else {
2947 None
2948 }
2949 })
2950 .collect();
2951
2952 if modified_files.is_empty() {
2953 None
2954 } else {
2955 Some(modified_files)
2956 }
2957 }
2958}