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