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