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.active_window_mut().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.active_window_mut().mouse_cursor_position != Some((col, row));
67 self.active_window_mut().mouse_cursor_position = Some((col, row));
68 if self.active_window_mut().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) =
82 self.active_window_mut()
83 .try_forward_mouse_to_terminal(col, row, mouse_event)
84 {
85 return result;
86 }
87
88 if self.active_window_mut().theme_info_popup.is_some() {
90 if let MouseEventKind::Down(MouseButton::Left) = mouse_event.kind {
91 if let Some((popup_rect, button_row_offset)) = self.theme_info_popup_rect() {
92 if in_rect(col, row, popup_rect) {
93 let actual_button_row = popup_rect.y + button_row_offset;
95 if row == actual_button_row {
96 let fg_key = self
97 .active_window_mut()
98 .theme_info_popup
99 .as_ref()
100 .and_then(|p| p.info.fg_key.clone());
101 self.active_window_mut().theme_info_popup = None;
102 if let Some(key) = fg_key {
103 self.fire_theme_inspect_hook(key);
104 }
105 return Ok(true);
106 }
107 return Ok(true);
109 }
110 }
111 self.active_window_mut().theme_info_popup = None;
113 needs_render = true;
114 }
115 }
116
117 match mouse_event.kind {
118 MouseEventKind::Down(MouseButton::Left) => {
119 if is_double_click || is_triple_click {
120 if let Some((buffer_id, byte_pos)) =
121 self.fold_toggle_line_at_screen_position(col, row)
122 {
123 self.active_window_mut()
124 .toggle_fold_at_byte(buffer_id, byte_pos);
125 needs_render = true;
126 return Ok(needs_render);
127 }
128 }
129 if is_triple_click {
130 self.handle_mouse_triple_click(col, row)?;
132 needs_render = true;
133 return Ok(needs_render);
134 }
135 if is_double_click {
136 self.handle_mouse_double_click(col, row)?;
138 needs_render = true;
139 return Ok(needs_render);
140 }
141 self.handle_mouse_click(col, row, mouse_event.modifiers)?;
142 needs_render = true;
143 }
144 MouseEventKind::Drag(MouseButton::Left) => {
145 self.handle_mouse_drag(col, row)?;
146 needs_render = true;
147 }
148 MouseEventKind::Up(MouseButton::Left) => {
149 let was_dragging_separator = self
151 .active_window_mut()
152 .mouse_state
153 .dragging_separator
154 .is_some();
155
156 if let Some(drag_state) = self.active_window_mut().mouse_state.dragging_tab.take() {
158 if drag_state.is_dragging() {
159 if let Some(drop_zone) = drag_state.drop_zone {
160 self.execute_tab_drop(
161 drag_state.buffer_id,
162 drag_state.source_split_id,
163 drop_zone,
164 );
165 }
166 }
167 }
168
169 self.active_window_mut().mouse_state.dragging_scrollbar = None;
171 self.active_window_mut().mouse_state.drag_start_row = None;
172 self.active_window_mut().mouse_state.drag_start_top_byte = None;
173 self.active_window_mut()
174 .mouse_state
175 .dragging_horizontal_scrollbar = None;
176 self.active_window_mut().mouse_state.drag_start_hcol = None;
177 self.active_window_mut().mouse_state.drag_start_left_column = None;
178 self.active_window_mut().mouse_state.dragging_separator = None;
179 self.active_window_mut().mouse_state.drag_start_position = None;
180 self.active_window_mut().mouse_state.drag_start_ratio = None;
181 self.active_window_mut().mouse_state.dragging_file_explorer = false;
182 self.active_window_mut()
183 .mouse_state
184 .drag_start_explorer_width = None;
185 self.active_window_mut().mouse_state.dragging_text_selection = false;
187 self.active_window_mut().mouse_state.drag_selection_split = None;
188 self.active_window_mut().mouse_state.drag_selection_anchor = None;
189 self.active_window_mut().mouse_state.drag_selection_by_words = false;
190 self.active_window_mut().mouse_state.drag_selection_word_end = None;
191 self.active_window_mut()
193 .mouse_state
194 .dragging_popup_scrollbar = None;
195 self.active_window_mut().mouse_state.drag_start_popup_scroll = None;
196 self.active_window_mut()
198 .mouse_state
199 .dragging_prompt_scrollbar = false;
200 self.active_window_mut().mouse_state.selecting_in_popup = None;
202
203 if was_dragging_separator {
205 self.active_window_mut().resize_visible_terminals();
206 }
207
208 needs_render = true;
209 }
210 MouseEventKind::Moved => {
211 {
213 let content_rect = self
215 .active_layout()
216 .split_areas
217 .iter()
218 .find(|(_, _, content_rect, _, _, _)| in_rect(col, row, *content_rect))
219 .map(|(_, _, rect, _, _, _)| *rect);
220
221 let (content_x, content_y) = content_rect.map(|r| (r.x, r.y)).unwrap_or((0, 0));
222
223 self.plugin_manager.read().unwrap().run_hook(
224 "mouse_move",
225 HookArgs::MouseMove {
226 column: col,
227 row,
228 content_x,
229 content_y,
230 },
231 );
232 }
233
234 let hover_changed = self.update_hover_target(col, row);
237 needs_render = needs_render || hover_changed;
238
239 if let Some((popup_rect, button_row_offset)) = self.theme_info_popup_rect() {
241 let button_row = popup_rect.y + button_row_offset;
242 let new_highlighted = row == button_row
243 && col >= popup_rect.x
244 && col < popup_rect.x + popup_rect.width;
245 if let Some(ref mut popup) = self.active_window_mut().theme_info_popup {
246 if popup.button_highlighted != new_highlighted {
247 popup.button_highlighted = new_highlighted;
248 needs_render = true;
249 }
250 }
251 }
252
253 self.update_lsp_hover_state(col, row);
255 }
256 MouseEventKind::ScrollUp => {
257 self.handle_vertical_scroll(col, row, mouse_event.modifiers, -3)?;
258 needs_render = true;
259 }
260 MouseEventKind::ScrollDown => {
261 self.handle_vertical_scroll(col, row, mouse_event.modifiers, 3)?;
262 needs_render = true;
263 }
264 MouseEventKind::ScrollLeft => {
265 self.active_window_mut()
267 .handle_horizontal_scroll(col, row, -3)?;
268 needs_render = true;
269 }
270 MouseEventKind::ScrollRight => {
271 self.active_window_mut()
273 .handle_horizontal_scroll(col, row, 3)?;
274 needs_render = true;
275 }
276 MouseEventKind::Down(MouseButton::Right) => {
277 if mouse_event
278 .modifiers
279 .contains(crossterm::event::KeyModifiers::CONTROL)
280 {
281 self.show_theme_info_popup(col, row)?;
283 } else {
284 self.handle_right_click(col, row)?;
286 }
287 needs_render = true;
288 }
289 _ => {
290 }
292 }
293
294 self.active_window_mut().mouse_state.last_position = Some((col, row));
295 Ok(needs_render)
296 }
297
298 fn detect_multi_click(
300 &mut self,
301 mouse_event: &crossterm::event::MouseEvent,
302 col: u16,
303 row: u16,
304 ) -> (bool, bool) {
305 use crossterm::event::{MouseButton, MouseEventKind};
306 if !matches!(mouse_event.kind, MouseEventKind::Down(MouseButton::Left)) {
307 return (false, false);
308 }
309 let now = self.time_source.now();
310 let threshold = std::time::Duration::from_millis(self.config.editor.double_click_time_ms);
311 let is_consecutive = if let (Some(prev_time), Some(prev_pos)) = (
312 self.active_window_mut().previous_click_time,
313 self.active_window_mut().previous_click_position,
314 ) {
315 now.duration_since(prev_time) < threshold && prev_pos == (col, row)
316 } else {
317 false
318 };
319 if is_consecutive {
320 self.active_window_mut().click_count += 1;
321 } else {
322 self.active_window_mut().click_count = 1;
323 }
324 self.active_window_mut().previous_click_time = Some(now);
325 self.active_window_mut().previous_click_position = Some((col, row));
326 let is_triple = self.active_window_mut().click_count >= 3;
327 let is_double = self.active_window_mut().click_count == 2;
328 if is_triple {
329 self.active_window_mut().click_count = 0;
330 self.active_window_mut().previous_click_time = None;
331 self.active_window_mut().previous_click_position = None;
332 }
333 (is_double, is_triple)
334 }
335
336 fn handle_vertical_scroll(
339 &mut self,
340 col: u16,
341 row: u16,
342 modifiers: crossterm::event::KeyModifiers,
343 delta: i32,
344 ) -> AnyhowResult<()> {
345 if modifiers.contains(crossterm::event::KeyModifiers::SHIFT) {
346 self.active_window_mut()
347 .handle_horizontal_scroll(col, row, delta)?;
348 } else if self.handle_prompt_scroll(delta) {
349 } else if self.is_file_open_active()
351 && self.is_mouse_over_file_browser(col, row)
352 && self.handle_file_open_scroll(delta)
353 {
354 } else if self.is_mouse_over_any_popup(col, row) {
356 self.scroll_popup(delta);
357 } else if self
358 .active_window()
359 .split_at_position(col, row)
360 .map(|(_, buffer_id)| self.handle_widget_panel_wheel(buffer_id, delta))
361 .unwrap_or(false)
362 {
363 } else {
365 if self.active_window().terminal_mode
366 && self
367 .active_window()
368 .is_terminal_buffer(self.active_buffer())
369 {
370 {
371 let __b = self.active_buffer();
372 self.active_window_mut().sync_terminal_to_buffer(__b);
373 };
374 self.active_window_mut().terminal_mode = false;
375 self.active_window_mut().key_context =
376 crate::input::keybindings::KeyContext::Normal;
377 }
378 self.dismiss_transient_popups();
379 self.active_window_mut()
380 .handle_mouse_scroll(col, row, delta)?;
381 }
382 Ok(())
383 }
384
385 pub(super) fn update_hover_target(&mut self, col: u16, row: u16) -> bool {
388 let old_target = self.active_window_mut().mouse_state.hover_target.clone();
389 let new_target = self.compute_hover_target(col, row);
390 let changed = old_target != new_target;
391 self.active_window_mut().mouse_state.hover_target = new_target.clone();
392
393 if let Some(active_menu_idx) = self.menu_state.active_menu {
396 let all_menus: Vec<crate::config::Menu> = self
397 .menus
398 .menus
399 .iter()
400 .chain(self.menu_state.plugin_menus.iter())
401 .cloned()
402 .collect();
403 if let Some(HoverTarget::MenuBarItem(hovered_menu_idx)) = new_target.clone() {
404 if hovered_menu_idx != active_menu_idx {
405 self.menu_state.open_menu(hovered_menu_idx);
406 return true; }
408 }
409
410 if let Some(HoverTarget::MenuDropdownItem(_, item_idx)) = new_target.clone() {
412 if self.menu_state.submenu_path.first() == Some(&item_idx) {
415 tracing::trace!(
416 "menu hover: staying on submenu parent item_idx={}, submenu_path={:?}",
417 item_idx,
418 self.menu_state.submenu_path
419 );
420 return changed;
421 }
422
423 if !self.menu_state.submenu_path.is_empty() {
425 tracing::trace!(
426 "menu hover: clearing submenu_path={:?} for different item_idx={}",
427 self.menu_state.submenu_path,
428 item_idx
429 );
430 self.menu_state.submenu_path.clear();
431 self.menu_state.highlighted_item = Some(item_idx);
432 return true;
433 }
434
435 if let Some(menu) = all_menus.get(active_menu_idx) {
437 if let Some(crate::config::MenuItem::Submenu { items, .. }) =
438 menu.items.get(item_idx)
439 {
440 if !items.is_empty() {
441 tracing::trace!("menu hover: opening submenu at item_idx={}", item_idx);
442 self.menu_state.submenu_path.push(item_idx);
443 self.menu_state.highlighted_item = Some(0);
444 return true;
445 }
446 }
447 }
448 if self.menu_state.highlighted_item != Some(item_idx) {
450 self.menu_state.highlighted_item = Some(item_idx);
451 return true;
452 }
453 }
454
455 if let Some(HoverTarget::SubmenuItem(depth, item_idx)) = new_target {
457 if self.menu_state.submenu_path.len() > depth
461 && self.menu_state.submenu_path.get(depth) == Some(&item_idx)
462 {
463 tracing::trace!(
464 "menu hover: staying on nested submenu parent depth={}, item_idx={}, submenu_path={:?}",
465 depth,
466 item_idx,
467 self.menu_state.submenu_path
468 );
469 return changed;
470 }
471
472 if self.menu_state.submenu_path.len() > depth {
474 tracing::trace!(
475 "menu hover: truncating submenu_path={:?} to depth={} for item_idx={}",
476 self.menu_state.submenu_path,
477 depth,
478 item_idx
479 );
480 self.menu_state.submenu_path.truncate(depth);
481 }
482
483 if let Some(items) = self
485 .menu_state
486 .get_current_items(&all_menus, active_menu_idx)
487 {
488 if let Some(crate::config::MenuItem::Submenu {
490 items: sub_items, ..
491 }) = items.get(item_idx)
492 {
493 if !sub_items.is_empty()
494 && !self.menu_state.submenu_path.contains(&item_idx)
495 {
496 tracing::trace!(
497 "menu hover: opening nested submenu at depth={}, item_idx={}",
498 depth,
499 item_idx
500 );
501 self.menu_state.submenu_path.push(item_idx);
502 self.menu_state.highlighted_item = Some(0);
503 return true;
504 }
505 }
506 if self.menu_state.highlighted_item != Some(item_idx) {
508 self.menu_state.highlighted_item = Some(item_idx);
509 return true;
510 }
511 }
512 }
513 }
514
515 if let Some(HoverTarget::TabContextMenuItem(item_idx)) = new_target.clone() {
517 if let Some(ref mut menu) = self.active_window_mut().tab_context_menu {
518 if menu.highlighted != item_idx {
519 menu.highlighted = item_idx;
520 return true;
521 }
522 }
523 }
524
525 if let Some(&HoverTarget::FileExplorerContextMenuItem(item_idx)) = new_target.as_ref() {
526 if let Some(ref mut menu) = self.active_window_mut().file_explorer_context_menu {
527 if menu.highlighted != item_idx {
528 menu.highlighted = item_idx;
529 return true;
530 }
531 }
532 }
533
534 if old_target != new_target
537 && matches!(
538 old_target,
539 Some(HoverTarget::FileExplorerStatusIndicator(_))
540 )
541 {
542 self.dismiss_file_explorer_status_tooltip();
543 }
544
545 if let Some(HoverTarget::FileExplorerStatusIndicator(ref path)) = new_target {
546 if old_target != new_target {
548 self.show_file_explorer_status_tooltip(path.clone(), col, row);
549 return true;
550 }
551 }
552
553 changed
554 }
555
556 fn update_lsp_hover_state(&mut self, col: u16, row: u16) {
565 tracing::trace!(col, row, "update_lsp_hover_state: raw mouse position");
566
567 if self.active_window_mut().theme_info_popup.is_some()
570 || self.active_window_mut().tab_context_menu.is_some()
571 || self
572 .active_window_mut()
573 .file_explorer_context_menu
574 .is_some()
575 {
576 if self
577 .active_window_mut()
578 .mouse_state
579 .lsp_hover_state
580 .is_some()
581 {
582 self.active_window_mut().mouse_state.lsp_hover_state = None;
583 self.active_window_mut().mouse_state.lsp_hover_request_sent = false;
584 self.dismiss_transient_popups();
585 }
586 return;
587 }
588
589 if self.is_mouse_over_transient_popup(col, row) {
591 return;
592 }
593
594 let split_info = self
596 .active_layout()
597 .split_areas
598 .iter()
599 .find(|(_, _, content_rect, _, _, _)| in_rect(col, row, *content_rect))
600 .map(|(split_id, buffer_id, content_rect, _, _, _)| {
601 (*split_id, *buffer_id, *content_rect)
602 });
603
604 let Some((split_id, buffer_id, content_rect)) = split_info else {
605 if self
607 .active_window_mut()
608 .mouse_state
609 .lsp_hover_state
610 .is_some()
611 {
612 self.active_window_mut().mouse_state.lsp_hover_state = None;
613 self.active_window_mut().mouse_state.lsp_hover_request_sent = false;
614 self.dismiss_transient_popups();
615 }
616 return;
617 };
618
619 let cached_mappings = self
621 .active_layout()
622 .view_line_mappings
623 .get(&split_id)
624 .cloned();
625 let gutter_width = self
626 .buffers()
627 .get(&buffer_id)
628 .map(|s| s.margins.left_total_width() as u16)
629 .unwrap_or(0);
630 let fallback = self
631 .buffers()
632 .get(&buffer_id)
633 .map(|s| s.buffer.len())
634 .unwrap_or(0);
635
636 let compose_width = self
638 .windows
639 .get(&self.active_window)
640 .and_then(|w| w.buffers.splits())
641 .map(|(_, vs)| vs)
642 .expect("active window must have a populated split layout")
643 .get(&split_id)
644 .and_then(|vs| vs.compose_width);
645
646 let Some(byte_pos) = super::click_geometry::screen_to_buffer_position(
648 col,
649 row,
650 content_rect,
651 gutter_width,
652 &cached_mappings,
653 fallback,
654 false, compose_width,
656 ) else {
657 if self
661 .active_window_mut()
662 .mouse_state
663 .lsp_hover_state
664 .is_some()
665 {
666 self.active_window_mut().mouse_state.lsp_hover_state = None;
667 self.active_window_mut().mouse_state.lsp_hover_request_sent = false;
668 }
669 return;
670 };
671
672 let content_col = col.saturating_sub(content_rect.x);
674 let text_col = content_col.saturating_sub(gutter_width) as usize;
675 let visual_row = row.saturating_sub(content_rect.y) as usize;
676
677 let line_info = cached_mappings
678 .as_ref()
679 .and_then(|mappings| mappings.get(visual_row))
680 .map(|line_mapping| {
681 (
682 line_mapping.visual_to_char.len(),
683 line_mapping.line_end_byte,
684 )
685 });
686
687 let is_past_line_end_or_empty = line_info
688 .map(|(line_len, _)| {
689 if line_len <= 1 {
691 return true;
692 }
693 text_col >= line_len
694 })
695 .unwrap_or(true);
697
698 tracing::trace!(
699 col,
700 row,
701 content_col,
702 text_col,
703 visual_row,
704 gutter_width,
705 byte_pos,
706 ?line_info,
707 is_past_line_end_or_empty,
708 "update_lsp_hover_state: position check"
709 );
710
711 if is_past_line_end_or_empty {
712 tracing::trace!(
713 "update_lsp_hover_state: mouse past line end or empty line, clearing hover"
714 );
715 if self
720 .active_window_mut()
721 .mouse_state
722 .lsp_hover_state
723 .is_some()
724 {
725 self.active_window_mut().mouse_state.lsp_hover_state = None;
726 self.active_window_mut().mouse_state.lsp_hover_request_sent = false;
727 }
728 return;
729 }
730
731 if let Some((start, end)) = self.active_window_mut().hover.symbol_range() {
733 if byte_pos >= start && byte_pos < end {
734 return;
736 }
737 }
738
739 if let Some((old_pos, _, _, _)) = self.active_window_mut().mouse_state.lsp_hover_state {
741 if old_pos == byte_pos {
742 return;
744 }
745 }
751
752 self.active_window_mut().mouse_state.lsp_hover_state =
754 Some((byte_pos, std::time::Instant::now(), col, row));
755 self.active_window_mut().mouse_state.lsp_hover_request_sent = false;
756 }
757
758 fn is_mouse_over_transient_popup(&self, col: u16, row: u16) -> bool {
760 let layouts = popup_areas_to_layout_info(&self.active_chrome().popup_areas);
761 let hit_tester = PopupHitTester::new(&layouts, &self.active_state().popups);
762 hit_tester.is_over_transient_popup(col, row)
763 }
764
765 fn is_mouse_over_any_popup(&self, col: u16, row: u16) -> bool {
767 for (_, popup_area, _, _, _) in &self.active_chrome().global_popup_areas {
770 if in_rect(col, row, *popup_area) {
771 return true;
772 }
773 }
774 if let Some(outer) = self.active_chrome().suggestions_outer_area {
778 if in_rect(col, row, outer) {
779 return true;
780 }
781 }
782 let layouts = popup_areas_to_layout_info(&self.active_chrome().popup_areas);
783 let hit_tester = PopupHitTester::new(&layouts, &self.active_state().popups);
784 hit_tester.is_over_popup(col, row)
785 }
786
787 fn is_mouse_over_file_browser(&self, col: u16, row: u16) -> bool {
789 self.active_window()
790 .file_browser_layout
791 .as_ref()
792 .is_some_and(|layout| layout.contains(col, row))
793 }
794
795 fn compute_hover_target(&self, col: u16, row: u16) -> Option<HoverTarget> {
800 if let Some(ref menu) = self.active_window().file_explorer_context_menu {
801 let (menu_x, menu_y) = menu.clamped_position(
802 self.active_chrome().last_frame_width,
803 self.active_chrome().last_frame_height,
804 );
805 let menu_width = super::types::FILE_EXPLORER_CONTEXT_MENU_WIDTH;
806 let menu_height = menu.height();
807
808 if col >= menu_x
809 && col < menu_x + menu_width
810 && row > menu_y
811 && row < menu_y + menu_height - 1
812 {
813 let item_idx = (row - menu_y - 1) as usize;
814 if item_idx < menu.items().len() {
815 return Some(HoverTarget::FileExplorerContextMenuItem(item_idx));
816 }
817 }
818 }
819
820 if let Some(ref menu) = self.active_window().tab_context_menu {
822 let menu_x = menu.position.0;
823 let menu_y = menu.position.1;
824 let menu_width = 22u16;
825 let items = super::types::TabContextMenuItem::all();
826 let menu_height = items.len() as u16 + 2;
827
828 if col >= menu_x
829 && col < menu_x + menu_width
830 && row > menu_y
831 && row < menu_y + menu_height - 1
832 {
833 let item_idx = (row - menu_y - 1) as usize;
834 if item_idx < items.len() {
835 return Some(HoverTarget::TabContextMenuItem(item_idx));
836 }
837 }
838 }
839
840 if let Some((inner_rect, start_idx, _visible_count, total_count)) =
842 &self.active_chrome().suggestions_area
843 {
844 if in_rect(col, row, *inner_rect) {
845 let relative_row = (row - inner_rect.y) as usize;
846 let item_idx = start_idx + relative_row;
847
848 if item_idx < *total_count {
849 return Some(HoverTarget::SuggestionItem(item_idx));
850 }
851 }
852 }
853
854 for (popup_idx, _popup_rect, inner_rect, scroll_offset, num_items, _, _) in
857 self.active_chrome().popup_areas.iter().rev()
858 {
859 if in_rect(col, row, *inner_rect) && *num_items > 0 {
860 let relative_row = (row - inner_rect.y) as usize;
862 let item_idx = scroll_offset + relative_row;
863
864 if item_idx < *num_items {
865 return Some(HoverTarget::PopupListItem(*popup_idx, item_idx));
866 }
867 }
868 }
869
870 if self.is_file_open_active() {
872 if let Some(hover) = self.compute_file_browser_hover(col, row) {
873 return Some(hover);
874 }
875 }
876
877 if self.active_window().menu_bar_visible {
880 if let Some(ref menu_layout) = self.active_chrome().menu_layout {
881 if let Some(menu_idx) = menu_layout.menu_at(col, row) {
882 return Some(HoverTarget::MenuBarItem(menu_idx));
883 }
884 }
885 }
886
887 if let Some(active_idx) = self.menu_state.active_menu {
889 if let Some(hover) = self.compute_menu_dropdown_hover(col, row, active_idx) {
890 return Some(hover);
891 }
892 }
893
894 if let Some(explorer_area) = self.active_layout().file_explorer_area {
896 let close_button_x = explorer_area.x + explorer_area.width.saturating_sub(3);
898 if row == explorer_area.y
899 && col >= close_button_x
900 && col < explorer_area.x + explorer_area.width
901 {
902 return Some(HoverTarget::FileExplorerCloseButton);
903 }
904
905 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
912 && row < content_end_y
913 && col >= status_indicator_x
914 && col < explorer_area.x + explorer_area.width.saturating_sub(1)
915 {
916 if let Some(explorer) = self.file_explorer().as_ref() {
918 let relative_row = row.saturating_sub(content_start_y) as usize;
919 let scroll_offset = explorer.get_scroll_offset();
920 let item_index = relative_row + scroll_offset;
921 let display_nodes = explorer.get_display_nodes();
922
923 if item_index < display_nodes.len() {
924 let (node_id, _indent) = display_nodes[item_index];
925 if let Some(node) = explorer.tree().get_node(node_id) {
926 return Some(HoverTarget::FileExplorerStatusIndicator(
927 node.entry.path.clone(),
928 ));
929 }
930 }
931 }
932 }
933
934 let border_x = explorer_area.x + explorer_area.width.saturating_sub(1);
937 if col == border_x
938 && row >= explorer_area.y
939 && row < explorer_area.y + explorer_area.height
940 {
941 return Some(HoverTarget::FileExplorerBorder);
942 }
943 }
944
945 for (split_id, direction, sep_x, sep_y, sep_length) in &self.active_layout().separator_areas
947 {
948 let is_on_separator = match direction {
949 SplitDirection::Horizontal => {
950 row == *sep_y && col >= *sep_x && col < sep_x + sep_length
951 }
952 SplitDirection::Vertical => {
953 col == *sep_x && row >= *sep_y && row < sep_y + sep_length
954 }
955 };
956
957 if is_on_separator {
958 return Some(HoverTarget::SplitSeparator(*split_id, *direction));
959 }
960 }
961
962 for (split_id, btn_row, start_col, end_col) in &self.active_layout().close_split_areas {
965 if row == *btn_row && col >= *start_col && col < *end_col {
966 return Some(HoverTarget::CloseSplitButton(*split_id));
967 }
968 }
969
970 for (split_id, btn_row, start_col, end_col) in &self.active_layout().maximize_split_areas {
971 if row == *btn_row && col >= *start_col && col < *end_col {
972 return Some(HoverTarget::MaximizeSplitButton(*split_id));
973 }
974 }
975
976 for (split_id, tab_layout) in &self.active_layout().tab_layouts {
977 match tab_layout.hit_test(col, row) {
978 Some(TabHit::CloseButton(target)) => {
979 return Some(HoverTarget::TabCloseButton(target, *split_id));
980 }
981 Some(TabHit::TabName(target)) => {
982 return Some(HoverTarget::TabName(target, *split_id));
983 }
984 Some(TabHit::ScrollLeft)
985 | Some(TabHit::ScrollRight)
986 | Some(TabHit::BarBackground)
987 | None => {}
988 }
989 }
990
991 for (split_id, _buffer_id, _content_rect, scrollbar_rect, thumb_start, thumb_end) in
993 &self.active_layout().split_areas
994 {
995 if in_rect(col, row, *scrollbar_rect) {
996 let relative_row = row.saturating_sub(scrollbar_rect.y) as usize;
997 let is_on_thumb = relative_row >= *thumb_start && relative_row < *thumb_end;
998
999 if is_on_thumb {
1000 return Some(HoverTarget::ScrollbarThumb(*split_id));
1001 } else {
1002 return Some(HoverTarget::ScrollbarTrack(*split_id, relative_row as u16));
1003 }
1004 }
1005 }
1006
1007 if let Some((status_row, _status_x, _status_width)) = self.active_chrome().status_bar_area {
1009 if row == status_row {
1010 let indicators = [
1011 (
1012 self.active_chrome().status_bar_line_ending_area,
1013 HoverTarget::StatusBarLineEndingIndicator,
1014 ),
1015 (
1016 self.active_chrome().status_bar_encoding_area,
1017 HoverTarget::StatusBarEncodingIndicator,
1018 ),
1019 (
1020 self.active_chrome().status_bar_language_area,
1021 HoverTarget::StatusBarLanguageIndicator,
1022 ),
1023 (
1024 self.active_chrome().status_bar_lsp_area,
1025 HoverTarget::StatusBarLspIndicator,
1026 ),
1027 (
1028 self.active_chrome().status_bar_remote_area,
1029 HoverTarget::StatusBarRemoteIndicator,
1030 ),
1031 (
1032 self.active_chrome().status_bar_warning_area,
1033 HoverTarget::StatusBarWarningBadge,
1034 ),
1035 ];
1036 for (area, target) in indicators {
1037 if let Some((indicator_row, start, end)) = area {
1038 if row == indicator_row && col >= start && col < end {
1039 return Some(target);
1040 }
1041 }
1042 }
1043 }
1044 }
1045
1046 if let Some(ref layout) = self.active_chrome().search_options_layout {
1048 use crate::view::ui::status_bar::SearchOptionsHover;
1049 if let Some(hover) = layout.checkbox_at(col, row) {
1050 return Some(match hover {
1051 SearchOptionsHover::CaseSensitive => HoverTarget::SearchOptionCaseSensitive,
1052 SearchOptionsHover::WholeWord => HoverTarget::SearchOptionWholeWord,
1053 SearchOptionsHover::Regex => HoverTarget::SearchOptionRegex,
1054 SearchOptionsHover::ConfirmEach => HoverTarget::SearchOptionConfirmEach,
1055 SearchOptionsHover::None => return None,
1056 });
1057 }
1058 }
1059
1060 None
1062 }
1063
1064 pub(super) fn handle_mouse_double_click(&mut self, col: u16, row: u16) -> AnyhowResult<()> {
1067 tracing::debug!("handle_mouse_double_click at col={}, row={}", col, row);
1068
1069 if let Some(r) = self.handle_click_suggestions_confirm(col, row) {
1073 return r;
1074 }
1075
1076 if self.is_mouse_over_any_popup(col, row) {
1078 return Ok(());
1080 } else {
1081 self.dismiss_transient_popups();
1083 }
1084
1085 if self.handle_file_open_double_click(col, row) {
1087 return Ok(());
1088 }
1089
1090 if let Some(explorer_area) = self.active_layout().file_explorer_area {
1092 if col >= explorer_area.x
1093 && col < explorer_area.x + explorer_area.width
1094 && row > explorer_area.y && row < explorer_area.y + explorer_area.height
1096 {
1097 self.file_explorer_open_file()?;
1099 return Ok(());
1100 }
1101 }
1102
1103 let split_areas = self.active_layout().split_areas.clone();
1105 for (split_id, buffer_id, content_rect, _scrollbar_rect, _thumb_start, _thumb_end) in
1106 &split_areas
1107 {
1108 if in_rect(col, row, *content_rect) {
1109 if self.active_window().is_terminal_buffer(*buffer_id) {
1111 self.active_window_mut().key_context =
1112 crate::input::keybindings::KeyContext::Terminal;
1113 return Ok(());
1115 }
1116
1117 self.active_window_mut().key_context =
1118 crate::input::keybindings::KeyContext::Normal;
1119
1120 self.handle_editor_double_click(col, row, *split_id, *buffer_id, *content_rect)?;
1122 return Ok(());
1123 }
1124 }
1125
1126 Ok(())
1127 }
1128
1129 fn handle_editor_double_click(
1131 &mut self,
1132 col: u16,
1133 row: u16,
1134 split_id: LeafId,
1135 buffer_id: BufferId,
1136 content_rect: ratatui::layout::Rect,
1137 ) -> AnyhowResult<()> {
1138 use crate::model::event::Event;
1139
1140 if self.active_window().is_non_scrollable_buffer(buffer_id) {
1144 return Ok(());
1145 }
1146
1147 self.focus_split(split_id, buffer_id);
1149
1150 let cached_mappings = self
1152 .active_layout()
1153 .view_line_mappings
1154 .get(&split_id)
1155 .cloned();
1156
1157 let leaf_id = split_id;
1159 let fallback = self
1160 .windows
1161 .get(&self.active_window)
1162 .and_then(|w| w.buffers.splits())
1163 .map(|(_, vs)| vs)
1164 .expect("active window must have a populated split layout")
1165 .get(&leaf_id)
1166 .map(|vs| vs.viewport.top_byte)
1167 .unwrap_or(0);
1168
1169 let compose_width = self
1171 .windows
1172 .get(&self.active_window)
1173 .and_then(|w| w.buffers.splits())
1174 .map(|(_, vs)| vs)
1175 .expect("active window must have a populated split layout")
1176 .get(&leaf_id)
1177 .and_then(|vs| vs.compose_width);
1178
1179 let gutter_width = self
1183 .active_window()
1184 .buffers
1185 .get(&buffer_id)
1186 .map(|s| s.margins.left_total_width() as u16)
1187 .unwrap_or(0);
1188
1189 let Some(target_position) = super::click_geometry::screen_to_buffer_position(
1190 col,
1191 row,
1192 content_rect,
1193 gutter_width,
1194 &cached_mappings,
1195 fallback,
1196 true, compose_width,
1198 ) else {
1199 return Ok(());
1200 };
1201
1202 let primary_cursor_id = self
1203 .active_window()
1204 .buffers
1205 .splits()
1206 .and_then(|(_, vs)| vs.get(&leaf_id))
1207 .map(|vs| vs.cursors.primary_id())
1208 .unwrap_or(CursorId(0));
1209 let event = Event::MoveCursor {
1210 cursor_id: primary_cursor_id,
1211 old_position: 0,
1212 new_position: target_position,
1213 old_anchor: None,
1214 new_anchor: None,
1215 old_sticky_column: 0,
1216 new_sticky_column: 0,
1217 };
1218
1219 if let Some(event_log) = self.active_window_mut().event_logs.get_mut(&buffer_id) {
1220 event_log.append(event.clone());
1221 }
1222 self.active_window_mut()
1223 .apply_event_to_buffer(buffer_id, leaf_id, &event);
1224
1225 self.handle_action(Action::SelectWord)?;
1227
1228 if let Some(cursor) = self
1230 .windows
1231 .get(&self.active_window)
1232 .and_then(|w| w.buffers.splits())
1233 .map(|(_, vs)| vs)
1234 .expect("active window must have a populated split layout")
1235 .get(&leaf_id)
1236 .map(|vs| vs.cursors.primary())
1237 {
1238 let sel_start = cursor.selection_start();
1241 let sel_end = cursor.selection_end();
1242 self.active_window_mut().mouse_state.dragging_text_selection = true;
1243 self.active_window_mut().mouse_state.drag_selection_split = Some(split_id);
1244 self.active_window_mut().mouse_state.drag_selection_anchor = Some(sel_start);
1245 self.active_window_mut().mouse_state.drag_selection_by_words = true;
1246 self.active_window_mut().mouse_state.drag_selection_word_end = Some(sel_end);
1247 }
1248
1249 Ok(())
1250 }
1251 pub(super) fn handle_mouse_triple_click(&mut self, col: u16, row: u16) -> AnyhowResult<()> {
1254 tracing::debug!("handle_mouse_triple_click at col={}, row={}", col, row);
1255
1256 if self.is_mouse_over_any_popup(col, row) {
1258 return Ok(());
1259 } else {
1260 self.dismiss_transient_popups();
1261 }
1262
1263 let split_areas = self.active_layout().split_areas.clone();
1265 for (split_id, buffer_id, content_rect, _scrollbar_rect, _thumb_start, _thumb_end) in
1266 &split_areas
1267 {
1268 if in_rect(col, row, *content_rect) {
1269 if self.active_window().is_terminal_buffer(*buffer_id) {
1270 return Ok(());
1271 }
1272
1273 self.active_window_mut().key_context =
1274 crate::input::keybindings::KeyContext::Normal;
1275
1276 self.handle_editor_triple_click(col, row, *split_id, *buffer_id, *content_rect)?;
1279 return Ok(());
1280 }
1281 }
1282
1283 Ok(())
1284 }
1285
1286 fn handle_editor_triple_click(
1288 &mut self,
1289 col: u16,
1290 row: u16,
1291 split_id: LeafId,
1292 buffer_id: BufferId,
1293 content_rect: ratatui::layout::Rect,
1294 ) -> AnyhowResult<()> {
1295 use crate::model::event::Event;
1296
1297 if self.active_window().is_non_scrollable_buffer(buffer_id) {
1298 return Ok(());
1299 }
1300
1301 self.focus_split(split_id, buffer_id);
1303
1304 let cached_mappings = self
1306 .active_layout()
1307 .view_line_mappings
1308 .get(&split_id)
1309 .cloned();
1310
1311 let leaf_id = split_id;
1312 let fallback = self
1313 .windows
1314 .get(&self.active_window)
1315 .and_then(|w| w.buffers.splits())
1316 .map(|(_, vs)| vs)
1317 .expect("active window must have a populated split layout")
1318 .get(&leaf_id)
1319 .map(|vs| vs.viewport.top_byte)
1320 .unwrap_or(0);
1321
1322 let compose_width = self
1324 .windows
1325 .get(&self.active_window)
1326 .and_then(|w| w.buffers.splits())
1327 .map(|(_, vs)| vs)
1328 .expect("active window must have a populated split layout")
1329 .get(&leaf_id)
1330 .and_then(|vs| vs.compose_width);
1331
1332 let gutter_width = self
1336 .active_window()
1337 .buffers
1338 .get(&buffer_id)
1339 .map(|s| s.margins.left_total_width() as u16)
1340 .unwrap_or(0);
1341
1342 let Some(target_position) = super::click_geometry::screen_to_buffer_position(
1343 col,
1344 row,
1345 content_rect,
1346 gutter_width,
1347 &cached_mappings,
1348 fallback,
1349 true,
1350 compose_width,
1351 ) else {
1352 return Ok(());
1353 };
1354
1355 let primary_cursor_id = self
1356 .active_window()
1357 .buffers
1358 .splits()
1359 .and_then(|(_, vs)| vs.get(&leaf_id))
1360 .map(|vs| vs.cursors.primary_id())
1361 .unwrap_or(CursorId(0));
1362 let event = Event::MoveCursor {
1363 cursor_id: primary_cursor_id,
1364 old_position: 0,
1365 new_position: target_position,
1366 old_anchor: None,
1367 new_anchor: None,
1368 old_sticky_column: 0,
1369 new_sticky_column: 0,
1370 };
1371
1372 if let Some(event_log) = self.active_window_mut().event_logs.get_mut(&buffer_id) {
1373 event_log.append(event.clone());
1374 }
1375 self.active_window_mut()
1376 .apply_event_to_buffer(buffer_id, leaf_id, &event);
1377
1378 self.handle_action(Action::SelectLine)?;
1380
1381 Ok(())
1382 }
1383
1384 pub(super) fn handle_mouse_click(
1386 &mut self,
1387 col: u16,
1388 row: u16,
1389 modifiers: crossterm::event::KeyModifiers,
1390 ) -> AnyhowResult<()> {
1391 if self.floating_widget_panel.is_some() {
1397 self.handle_floating_widget_click(col, row);
1398 return Ok(());
1399 }
1400 if let Some(r) = self.handle_click_context_menus(col, row) {
1401 return r;
1402 }
1403 if !self.is_mouse_over_any_popup(col, row) {
1404 self.dismiss_transient_popups();
1405 }
1406 if let Some(r) = self.handle_click_suggestions(col, row) {
1407 return r;
1408 }
1409 if let Some(r) = self.handle_click_prompt_scrollbar(col, row) {
1410 return r;
1411 }
1412 if let Some(r) = self.handle_click_popup_scrollbar(col, row) {
1413 return r;
1414 }
1415 if let Some(r) = self.handle_click_global_popups(col, row) {
1416 return r;
1417 }
1418 if let Some(r) = self.handle_click_buffer_popups(col, row) {
1419 return r;
1420 }
1421 if self.is_mouse_over_any_popup(col, row) {
1422 return Ok(());
1423 }
1424 if self.is_file_open_active() && self.handle_file_open_click(col, row) {
1425 return Ok(());
1426 }
1427 if let Some(r) = self.handle_click_menu_bar(col, row) {
1428 return r;
1429 }
1430 if let Some(r) = self.handle_click_file_explorer_area(col, row) {
1431 return r;
1432 }
1433 if let Some(r) = self.handle_click_scrollbar(col, row) {
1434 return r;
1435 }
1436 if let Some(r) = self.handle_click_horizontal_scrollbar(col, row) {
1437 return r;
1438 }
1439 if let Some(r) = self.handle_click_status_bar(col, row) {
1440 return r;
1441 }
1442 if let Some(r) = self.handle_click_search_options(col, row) {
1443 return r;
1444 }
1445 if let Some(r) = self.handle_click_split_separator(col, row) {
1446 return r;
1447 }
1448 if let Some(r) = self.handle_click_split_controls(col, row) {
1449 return r;
1450 }
1451 if let Some(r) = self.handle_click_tab_bar(col, row) {
1452 return r;
1453 }
1454
1455 tracing::debug!(
1457 "handle_mouse_click: checking {} split_areas for click at ({}, {})",
1458 self.active_layout().split_areas.len(),
1459 col,
1460 row
1461 );
1462 for (split_id, buffer_id, content_rect, _scrollbar_rect, _thumb_start, _thumb_end) in
1463 &self.active_layout().split_areas
1464 {
1465 tracing::debug!(
1466 " split_id={:?}, content_rect=({}, {}, {}x{})",
1467 split_id,
1468 content_rect.x,
1469 content_rect.y,
1470 content_rect.width,
1471 content_rect.height
1472 );
1473 if in_rect(col, row, *content_rect) {
1474 tracing::debug!(" -> HIT! calling handle_editor_click");
1476 self.handle_editor_click(
1477 col,
1478 row,
1479 *split_id,
1480 *buffer_id,
1481 *content_rect,
1482 modifiers,
1483 )?;
1484 return Ok(());
1485 }
1486 }
1487 tracing::debug!(" -> No split area hit");
1488
1489 Ok(())
1490 }
1491
1492 fn handle_click_context_menus(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
1496 if self
1497 .active_window_mut()
1498 .file_explorer_context_menu
1499 .is_some()
1500 {
1501 if let Some(result) = self.handle_file_explorer_context_menu_click(col, row) {
1502 return Some(result);
1503 }
1504 }
1505 if self.active_window_mut().tab_context_menu.is_some() {
1506 if let Some(result) = self.handle_tab_context_menu_click(col, row) {
1507 return Some(result);
1508 }
1509 }
1510 None
1511 }
1512
1513 fn suggestion_at(&self, col: u16, row: u16) -> Option<usize> {
1517 let (inner_rect, start_idx, _visible_count, total_count) =
1518 self.active_chrome().suggestions_area?;
1519 if col < inner_rect.x
1520 || col >= inner_rect.x + inner_rect.width
1521 || row < inner_rect.y
1522 || row >= inner_rect.y + inner_rect.height
1523 {
1524 return None;
1525 }
1526 let relative_row = (row - inner_rect.y) as usize;
1527 let item_idx = start_idx + relative_row;
1528 if item_idx < total_count {
1529 Some(item_idx)
1530 } else {
1531 None
1532 }
1533 }
1534
1535 fn handle_click_suggestions(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
1536 let item_idx = self.suggestion_at(col, row)?;
1537 let prompt = self.active_window_mut().prompt.as_mut()?;
1538 prompt.selected_suggestion = Some(item_idx);
1539 let confirms = prompt.prompt_type.click_confirms();
1540 if !confirms {
1541 if let Some(suggestion) = prompt.suggestions.get(item_idx) {
1545 prompt.input = suggestion.get_value().to_string();
1546 prompt.cursor_pos = prompt.input.len();
1547 }
1548 }
1549 if confirms {
1550 return Some(self.handle_action(Action::PromptConfirm));
1551 }
1552 Some(Ok(()))
1553 }
1554
1555 fn handle_click_suggestions_confirm(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
1559 let item_idx = self.suggestion_at(col, row)?;
1560 let prompt = self.active_window_mut().prompt.as_mut()?;
1561 prompt.selected_suggestion = Some(item_idx);
1562 if let Some(suggestion) = prompt.suggestions.get(item_idx) {
1563 prompt.input = suggestion.get_value().to_string();
1564 prompt.cursor_pos = prompt.input.len();
1565 }
1566 Some(self.handle_action(Action::PromptConfirm))
1567 }
1568
1569 fn handle_click_prompt_scrollbar(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
1575 use crate::view::ui::scrollbar::ScrollbarState;
1576 let sb_rect = self.active_chrome().suggestions_scrollbar_rect?;
1577 if col < sb_rect.x
1578 || col >= sb_rect.x + sb_rect.width
1579 || row < sb_rect.y
1580 || row >= sb_rect.y + sb_rect.height
1581 {
1582 return None;
1583 }
1584 let suggestions_area_visible = self.active_chrome().suggestions_area.map(|(_, _, v, _)| v);
1592 let active_window_id = self.active_window;
1593 let prompt = self
1594 .windows
1595 .get_mut(&active_window_id)
1596 .and_then(|w| w.prompt.as_mut())?;
1597 let visible = suggestions_area_visible.unwrap_or(prompt.suggestions.len().min(10));
1598 let total = prompt.suggestions.len();
1599 let track_height = sb_rect.height as usize;
1600 let click_row = row.saturating_sub(sb_rect.y) as usize;
1601 let state = ScrollbarState::new(total, visible, prompt.scroll_offset);
1602 prompt.scroll_offset = state.click_to_offset(track_height, click_row);
1603 self.active_window_mut()
1606 .mouse_state
1607 .dragging_prompt_scrollbar = true;
1608 Some(Ok(()))
1609 }
1610
1611 fn handle_click_popup_scrollbar(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
1612 let scrollbar_info: Option<(usize, i32)> =
1614 self.active_chrome().popup_areas.iter().rev().find_map(
1615 |(popup_idx, _popup_rect, inner_rect, _scroll, _n, scrollbar_rect, total_lines)| {
1616 let sb_rect = scrollbar_rect.as_ref()?;
1617 if col >= sb_rect.x
1618 && col < sb_rect.x + sb_rect.width
1619 && row >= sb_rect.y
1620 && row < sb_rect.y + sb_rect.height
1621 {
1622 let relative_row = (row - sb_rect.y) as usize;
1623 let track_height = sb_rect.height as usize;
1624 let visible_lines = inner_rect.height as usize;
1625 if track_height > 0 && *total_lines > visible_lines {
1626 let max_scroll = total_lines.saturating_sub(visible_lines);
1627 let target = if track_height > 1 {
1628 (relative_row * max_scroll) / (track_height.saturating_sub(1))
1629 } else {
1630 0
1631 };
1632 Some((*popup_idx, target as i32))
1633 } else {
1634 Some((*popup_idx, 0))
1635 }
1636 } else {
1637 None
1638 }
1639 },
1640 );
1641 let (popup_idx, target_scroll) = scrollbar_info?;
1642 self.active_window_mut()
1643 .mouse_state
1644 .dragging_popup_scrollbar = Some(popup_idx);
1645 self.active_window_mut().mouse_state.drag_start_row = Some(row);
1646 let current_scroll = self
1647 .active_state()
1648 .popups
1649 .get(popup_idx)
1650 .map(|p| p.scroll_offset)
1651 .unwrap_or(0);
1652 self.active_window_mut().mouse_state.drag_start_popup_scroll = Some(current_scroll);
1653 let state = self.active_state_mut();
1654 if let Some(popup) = state.popups.get_mut(popup_idx) {
1655 popup.scroll_by(target_scroll - current_scroll as i32);
1656 }
1657 Some(Ok(()))
1658 }
1659
1660 fn handle_click_global_popups(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
1661 for (popup_idx, popup_rect, inner_rect, scroll_offset, num_items) in self
1662 .active_chrome()
1663 .global_popup_areas
1664 .clone()
1665 .into_iter()
1666 .rev()
1667 {
1668 if popup_rect.width >= 5 {
1669 let cb_x = popup_rect.x + popup_rect.width - 4;
1670 if row == popup_rect.y && col >= cb_x && col < cb_x + 3 {
1671 return Some(self.handle_action(Action::PopupCancel));
1672 }
1673 }
1674 if in_rect(col, row, inner_rect) && num_items > 0 {
1675 let relative_row = (row - inner_rect.y) as usize;
1676 let item_idx = scroll_offset + relative_row;
1677 if item_idx < num_items {
1678 if let Some(popup) = self.global_popups.get_mut(popup_idx) {
1679 if let crate::view::popup::PopupContent::List { items: _, selected } =
1680 &mut popup.content
1681 {
1682 *selected = item_idx;
1683 }
1684 }
1685 return Some(self.handle_action(Action::PopupConfirm));
1686 }
1687 }
1688 }
1689 None
1690 }
1691
1692 fn handle_click_buffer_popups(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
1693 let close_hit = self.active_chrome().popup_areas.iter().rev().find_map(
1695 |(_idx, popup_rect, _inner, _scroll, _n, _sb, _tl)| {
1696 if popup_rect.width < 5 {
1697 return None;
1698 }
1699 let cb_x = popup_rect.x + popup_rect.width - 4;
1700 if row == popup_rect.y && col >= cb_x && col < cb_x + 3 {
1701 Some(())
1702 } else {
1703 None
1704 }
1705 },
1706 );
1707 if close_hit.is_some() {
1708 return Some(self.handle_action(Action::PopupCancel));
1709 }
1710
1711 let popup_areas = self.active_chrome().popup_areas.clone();
1713 for (popup_idx, _popup_rect, inner_rect, scroll_offset, num_items, _, _) in
1714 popup_areas.iter().rev()
1715 {
1716 if !in_rect(col, row, *inner_rect) {
1717 continue;
1718 }
1719 let relative_col = (col - inner_rect.x) as usize;
1720 let relative_row = (row - inner_rect.y) as usize;
1721
1722 let link_url = {
1723 let state = self.active_state();
1724 state
1725 .popups
1726 .top()
1727 .and_then(|p| p.link_at_position(relative_col, relative_row))
1728 };
1729 if let Some(url) = link_url {
1730 #[cfg(feature = "runtime")]
1731 if let Err(e) = open::that(&url) {
1732 self.set_status_message(format!("Failed to open URL: {}", e));
1733 } else {
1734 self.set_status_message(format!("Opening: {}", url));
1735 }
1736 return Some(Ok(()));
1737 }
1738
1739 if *num_items > 0 {
1740 let item_idx = scroll_offset + relative_row;
1741 if item_idx < *num_items {
1742 let state = self.active_state_mut();
1743 if let Some(popup) = state.popups.top_mut() {
1744 if let crate::view::popup::PopupContent::List { items: _, selected } =
1745 &mut popup.content
1746 {
1747 *selected = item_idx;
1748 }
1749 }
1750 return Some(self.handle_action(Action::PopupConfirm));
1751 }
1752 }
1753
1754 let is_text_popup = {
1755 let state = self.active_state();
1756 state.popups.top().is_some_and(|p| {
1757 matches!(
1758 p.content,
1759 crate::view::popup::PopupContent::Text(_)
1760 | crate::view::popup::PopupContent::Markdown(_)
1761 )
1762 })
1763 };
1764 if is_text_popup {
1765 let line = scroll_offset + relative_row;
1766 let popup_idx_copy = *popup_idx;
1767 let state = self.active_state_mut();
1768 if let Some(popup) = state.popups.top_mut() {
1769 popup.start_selection(line, relative_col);
1770 }
1771 self.active_window_mut().mouse_state.selecting_in_popup = Some(popup_idx_copy);
1772 return Some(Ok(()));
1773 }
1774 }
1775 None
1776 }
1777
1778 fn handle_click_menu_bar(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
1779 if self.active_window_mut().menu_bar_visible {
1780 let hit = self
1782 .active_chrome()
1783 .menu_layout
1784 .as_ref()
1785 .and_then(|ml| ml.menu_at(col, row));
1786 let layout_exists = self.active_chrome().menu_layout.is_some();
1787 if layout_exists {
1788 if let Some(menu_idx) = hit {
1789 if self.menu_state.active_menu == Some(menu_idx) {
1790 self.close_menu_with_auto_hide();
1791 } else {
1792 self.active_window_mut().on_editor_focus_lost();
1793 self.menu_state.open_menu(menu_idx);
1794 }
1795 return Some(Ok(()));
1796 } else if row == 0 {
1797 self.close_menu_with_auto_hide();
1798 return Some(Ok(()));
1799 }
1800 }
1801 }
1802
1803 if let Some(active_idx) = self.menu_state.active_menu {
1804 let all_menus: Vec<crate::config::Menu> = self
1805 .menus
1806 .menus
1807 .iter()
1808 .chain(self.menu_state.plugin_menus.iter())
1809 .cloned()
1810 .collect();
1811 if let Some(menu) = all_menus.get(active_idx) {
1812 match self.handle_menu_dropdown_click(col, row, menu) {
1813 Ok(Some(click_result)) => return Some(click_result),
1814 Ok(None) => {}
1815 Err(e) => return Some(Err(e)),
1816 }
1817 }
1818 self.close_menu_with_auto_hide();
1819 return Some(Ok(()));
1820 }
1821
1822 None
1823 }
1824
1825 fn handle_click_file_explorer_area(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
1826 let explorer_area = self.active_layout().file_explorer_area?;
1827 let border_x = explorer_area.x + explorer_area.width.saturating_sub(1);
1828 if col == border_x && row >= explorer_area.y && row < explorer_area.y + explorer_area.height
1829 {
1830 self.active_window_mut().mouse_state.dragging_file_explorer = true;
1831 self.active_window_mut().mouse_state.drag_start_position = Some((col, row));
1832 self.active_window_mut()
1833 .mouse_state
1834 .drag_start_explorer_width = Some(self.active_window().file_explorer_width);
1835 return Some(Ok(()));
1836 }
1837 if in_rect(col, row, explorer_area) {
1838 return Some(self.handle_file_explorer_click(col, row, explorer_area));
1839 }
1840 None
1841 }
1842
1843 fn handle_click_scrollbar(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
1844 let (split_id, buffer_id, scrollbar_rect, is_on_thumb) =
1845 self.active_layout().split_areas.iter().find_map(
1846 |(split_id, buffer_id, _content, scrollbar_rect, thumb_start, thumb_end)| {
1847 if in_rect(col, row, *scrollbar_rect) {
1848 let relative_row = row.saturating_sub(scrollbar_rect.y) as usize;
1849 let on_thumb = relative_row >= *thumb_start && relative_row < *thumb_end;
1850 Some((*split_id, *buffer_id, *scrollbar_rect, on_thumb))
1851 } else {
1852 None
1853 }
1854 },
1855 )?;
1856
1857 self.focus_split(split_id, buffer_id);
1858 if is_on_thumb {
1859 self.active_window_mut().mouse_state.dragging_scrollbar = Some(split_id);
1860 self.active_window_mut().mouse_state.drag_start_row = Some(row);
1861 if self.active_window().is_composite_buffer(buffer_id) {
1862 if let Some(vs) = self
1863 .active_window()
1864 .composite_view_states
1865 .get(&(split_id, buffer_id))
1866 {
1867 self.active_window_mut()
1868 .mouse_state
1869 .drag_start_composite_scroll_row = Some(vs.scroll_row);
1870 }
1871 } else {
1872 let snap = self
1873 .windows
1874 .get(&self.active_window)
1875 .and_then(|w| w.buffers.splits())
1876 .map(|(_, vs)| vs)
1877 .expect("active window must have a populated split layout")
1878 .get(&split_id)
1879 .map(|vs| (vs.viewport.top_byte, vs.viewport.top_view_line_offset));
1880 if let Some((top_byte, top_view_line_offset)) = snap {
1881 let ms = &mut self.active_window_mut().mouse_state;
1882 ms.drag_start_top_byte = Some(top_byte);
1883 ms.drag_start_view_line_offset = Some(top_view_line_offset);
1884 }
1885 }
1886 } else {
1887 self.active_window_mut().mouse_state.dragging_scrollbar = Some(split_id);
1888 if let Err(e) = self.active_window_mut().handle_scrollbar_jump(
1889 col,
1890 row,
1891 split_id,
1892 buffer_id,
1893 scrollbar_rect,
1894 ) {
1895 return Some(Err(e));
1896 }
1897 self.active_window_mut().mouse_state.hover_target =
1898 Some(HoverTarget::ScrollbarThumb(split_id));
1899 }
1900 Some(Ok(()))
1901 }
1902
1903 fn handle_click_horizontal_scrollbar(
1904 &mut self,
1905 col: u16,
1906 row: u16,
1907 ) -> Option<AnyhowResult<()>> {
1908 let (split_id, buffer_id, hscrollbar_rect, max_content_width, is_on_thumb) = self
1909 .active_layout()
1910 .horizontal_scrollbar_areas
1911 .iter()
1912 .find_map(
1913 |(
1914 split_id,
1915 buffer_id,
1916 hscrollbar_rect,
1917 max_content_width,
1918 thumb_start,
1919 thumb_end,
1920 )| {
1921 if col >= hscrollbar_rect.x
1922 && col < hscrollbar_rect.x + hscrollbar_rect.width
1923 && row >= hscrollbar_rect.y
1924 && row < hscrollbar_rect.y + hscrollbar_rect.height
1925 {
1926 let relative_col = col.saturating_sub(hscrollbar_rect.x) as usize;
1927 let on_thumb = relative_col >= *thumb_start && relative_col < *thumb_end;
1928 Some((
1929 *split_id,
1930 *buffer_id,
1931 *hscrollbar_rect,
1932 *max_content_width,
1933 on_thumb,
1934 ))
1935 } else {
1936 None
1937 }
1938 },
1939 )?;
1940
1941 self.focus_split(split_id, buffer_id);
1942 self.active_window_mut()
1943 .mouse_state
1944 .dragging_horizontal_scrollbar = Some(split_id);
1945 if is_on_thumb {
1946 self.active_window_mut().mouse_state.drag_start_hcol = Some(col);
1947 if let Some(vs) = self
1948 .windows
1949 .get(&self.active_window)
1950 .and_then(|w| w.buffers.splits())
1951 .map(|(_, vs)| vs)
1952 .expect("active window must have a populated split layout")
1953 .get(&split_id)
1954 {
1955 self.active_window_mut().mouse_state.drag_start_left_column =
1956 Some(vs.viewport.left_column);
1957 }
1958 } else {
1959 self.active_window_mut().mouse_state.drag_start_hcol = None;
1960 self.active_window_mut().mouse_state.drag_start_left_column = None;
1961 let relative_col = col.saturating_sub(hscrollbar_rect.x) as f64;
1962 let track_width = hscrollbar_rect.width as f64;
1963 let ratio = if track_width > 1.0 {
1964 (relative_col / (track_width - 1.0)).clamp(0.0, 1.0)
1965 } else {
1966 0.0
1967 };
1968 if let Some(vs) = self
1969 .windows
1970 .get_mut(&self.active_window)
1971 .and_then(|w| w.split_view_states_mut())
1972 .expect("active window must have a populated split layout")
1973 .get_mut(&split_id)
1974 {
1975 let visible_width = vs.viewport.width as usize;
1976 let max_scroll = max_content_width.saturating_sub(visible_width);
1977 let target_col = (ratio * max_scroll as f64).round() as usize;
1978 vs.viewport.left_column = target_col.min(max_scroll);
1979 vs.viewport.set_skip_ensure_visible();
1980 }
1981 }
1982 Some(Ok(()))
1983 }
1984
1985 fn handle_click_status_bar(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
1986 let (status_row, _status_x, _status_width) = self.active_chrome().status_bar_area?;
1987 if row != status_row {
1988 return None;
1989 }
1990 if let Some((r, s, e)) = self.active_chrome().status_bar_line_ending_area {
2000 if row == r && col >= s && col < e {
2001 self.dismiss_menu_popups_for_prompt();
2002 return Some(self.handle_action(Action::SetLineEnding));
2003 }
2004 }
2005 if let Some((r, s, e)) = self.active_chrome().status_bar_encoding_area {
2006 if row == r && col >= s && col < e {
2007 self.dismiss_menu_popups_for_prompt();
2008 return Some(self.handle_action(Action::SetEncoding));
2009 }
2010 }
2011 if let Some((r, s, e)) = self.active_chrome().status_bar_language_area {
2012 if row == r && col >= s && col < e {
2013 self.dismiss_menu_popups_for_prompt();
2014 return Some(self.handle_action(Action::SetLanguage));
2015 }
2016 }
2017 if let Some((r, s, e)) = self.active_chrome().status_bar_lsp_area {
2018 if row == r && col >= s && col < e {
2019 return Some(self.handle_action(Action::ShowLspStatus));
2022 }
2023 }
2024 if let Some((r, s, e)) = self.active_chrome().status_bar_remote_area {
2025 if row == r && col >= s && col < e {
2026 self.dismiss_menu_popups_for_prompt();
2027 return Some(self.handle_action(Action::ShowRemoteIndicatorMenu));
2028 }
2029 }
2030 if let Some((r, s, e)) = self.active_chrome().status_bar_warning_area {
2031 if row == r && col >= s && col < e {
2032 self.dismiss_menu_popups_for_prompt();
2033 return Some(self.handle_action(Action::ShowWarnings));
2034 }
2035 }
2036 if let Some((r, s, e)) = self.active_chrome().status_bar_message_area {
2037 if row == r && col >= s && col < e {
2038 return Some(self.handle_action(Action::ShowStatusLog));
2039 }
2040 }
2041 None
2042 }
2043
2044 fn handle_click_search_options(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
2045 use crate::view::ui::status_bar::SearchOptionsHover;
2046 let layout = self.active_chrome().search_options_layout.clone()?;
2047 match layout.checkbox_at(col, row)? {
2048 SearchOptionsHover::CaseSensitive => {
2049 Some(self.handle_action(Action::ToggleSearchCaseSensitive))
2050 }
2051 SearchOptionsHover::WholeWord => {
2052 Some(self.handle_action(Action::ToggleSearchWholeWord))
2053 }
2054 SearchOptionsHover::Regex => Some(self.handle_action(Action::ToggleSearchRegex)),
2055 SearchOptionsHover::ConfirmEach => {
2056 Some(self.handle_action(Action::ToggleSearchConfirmEach))
2057 }
2058 SearchOptionsHover::None => None,
2059 }
2060 }
2061
2062 fn handle_click_split_separator(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
2063 let separator_areas = self.active_layout().separator_areas.clone();
2064 for (split_id, direction, sep_x, sep_y, sep_length) in &separator_areas {
2065 let is_on_separator = match direction {
2066 SplitDirection::Horizontal => {
2067 row == *sep_y && col >= *sep_x && col < sep_x + sep_length
2068 }
2069 SplitDirection::Vertical => {
2070 col == *sep_x && row >= *sep_y && row < sep_y + sep_length
2071 }
2072 };
2073 if is_on_separator {
2074 self.active_window_mut().mouse_state.dragging_separator =
2075 Some((*split_id, *direction));
2076 self.active_window_mut().mouse_state.drag_start_position = Some((col, row));
2077 let ratio = self
2078 .split_manager_mut()
2079 .get_ratio((*split_id).into())
2080 .or_else(|| self.grouped_split_ratio(*split_id));
2081 if let Some(ratio) = ratio {
2082 self.active_window_mut().mouse_state.drag_start_ratio = Some(ratio);
2083 }
2084 return Some(Ok(()));
2085 }
2086 }
2087 None
2088 }
2089
2090 fn handle_click_split_controls(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
2091 let close_split_id = self
2092 .active_layout()
2093 .close_split_areas
2094 .iter()
2095 .find(|(_, btn_row, start_col, end_col)| {
2096 row == *btn_row && col >= *start_col && col < *end_col
2097 })
2098 .map(|(split_id, _, _, _)| *split_id);
2099 if let Some(split_id) = close_split_id {
2100 if let Err(e) = self
2101 .windows
2102 .get_mut(&self.active_window)
2103 .and_then(|w| w.split_manager_mut())
2104 .expect("active window must have a populated split layout")
2105 .close_split(split_id)
2106 {
2107 self.set_status_message(
2108 t!("error.cannot_close_split", error = e.to_string()).to_string(),
2109 );
2110 } else {
2111 let new_active = self
2112 .windows
2113 .get(&self.active_window)
2114 .and_then(|w| w.buffers.splits())
2115 .map(|(mgr, _)| mgr)
2116 .expect("active window must have a populated split layout")
2117 .active_split();
2118 if let Some(buffer_id) = self
2119 .windows
2120 .get(&self.active_window)
2121 .and_then(|w| w.buffers.splits())
2122 .map(|(mgr, _)| mgr)
2123 .expect("active window must have a populated split layout")
2124 .buffer_for_split(new_active)
2125 {
2126 self.set_active_buffer(buffer_id);
2127 }
2128 self.set_status_message(t!("split.closed").to_string());
2129 }
2130 return Some(Ok(()));
2131 }
2132
2133 let maximize_target = self
2134 .active_layout()
2135 .maximize_split_areas
2136 .iter()
2137 .find(|(_, btn_row, start_col, end_col)| {
2138 row == *btn_row && col >= *start_col && col < *end_col
2139 })
2140 .map(|(split_id, _, _, _)| *split_id);
2141 if let Some(target) = maximize_target {
2142 let already_maximized = self
2149 .windows
2150 .get(&self.active_window)
2151 .and_then(|w| w.buffers.splits())
2152 .map(|(mgr, _)| mgr.is_maximized())
2153 .unwrap_or(false);
2154 if !already_maximized {
2155 if let Some(buffer_id) = self
2156 .windows
2157 .get(&self.active_window)
2158 .and_then(|w| w.buffers.splits())
2159 .map(|(mgr, _)| mgr)
2160 .expect("active window must have a populated split layout")
2161 .buffer_for_split(target)
2162 {
2163 self.focus_split(target, buffer_id);
2164 }
2165 }
2166 match self
2167 .windows
2168 .get_mut(&self.active_window)
2169 .and_then(|w| w.split_manager_mut())
2170 .expect("active window must have a populated split layout")
2171 .toggle_maximize_for(target)
2172 {
2173 Ok(maximized) => {
2174 let msg = if maximized {
2175 t!("split.maximized").to_string()
2176 } else {
2177 t!("split.restored").to_string()
2178 };
2179 self.set_status_message(msg);
2180 }
2181 Err(e) => self.set_status_message(e),
2182 }
2183 return Some(Ok(()));
2184 }
2185
2186 None
2187 }
2188
2189 fn handle_click_tab_bar(&mut self, col: u16, row: u16) -> Option<AnyhowResult<()>> {
2190 for (split_id, tab_layout) in &self.active_layout().tab_layouts {
2191 tracing::debug!(
2192 "Tab layout for split {:?}: bar_area={:?}, left_scroll={:?}, right_scroll={:?}",
2193 split_id,
2194 tab_layout.bar_area,
2195 tab_layout.left_scroll_area,
2196 tab_layout.right_scroll_area
2197 );
2198 }
2199 let tab_hit = self
2200 .active_layout()
2201 .tab_layouts
2202 .iter()
2203 .find_map(|(split_id, tab_layout)| {
2204 let hit = tab_layout.hit_test(col, row);
2205 tracing::debug!(
2206 "Tab hit_test at ({}, {}) for split {:?} returned {:?}",
2207 col,
2208 row,
2209 split_id,
2210 hit
2211 );
2212 hit.map(|h| (*split_id, h))
2213 });
2214 let (split_id, hit) = tab_hit?;
2215 match hit {
2216 TabHit::CloseButton(target) => {
2217 match target {
2218 crate::view::split::TabTarget::Buffer(buffer_id) => {
2219 self.focus_split(split_id, buffer_id);
2220 self.close_tab_in_split(buffer_id, split_id);
2221 }
2222 crate::view::split::TabTarget::Group(group_leaf) => {
2223 self.close_buffer_group_by_leaf(group_leaf);
2224 }
2225 }
2226 Some(Ok(()))
2227 }
2228 TabHit::TabName(target) => {
2229 let direction = self
2230 .windows
2231 .get(&self.active_window)
2232 .and_then(|w| w.buffers.splits())
2233 .map(|(_, vs)| vs)
2234 .expect("active window must have a populated split layout")
2235 .get(&split_id)
2236 .map(|vs| {
2237 let open = &vs.open_buffers;
2238 let cur = vs.active_target();
2239 let cur_idx = open.iter().position(|t| *t == cur);
2240 let new_idx = open.iter().position(|t| *t == target);
2241 match (cur_idx, new_idx) {
2242 (Some(c), Some(n)) if n > c => 1,
2243 (Some(c), Some(n)) if n < c => -1,
2244 _ => 0,
2245 }
2246 })
2247 .unwrap_or(0);
2248 self.active_window_mut()
2249 .animate_tab_switch(split_id, direction);
2250 match target {
2251 crate::view::split::TabTarget::Buffer(buffer_id) => {
2252 self.focus_split(split_id, buffer_id);
2253 self.active_window_mut()
2254 .promote_buffer_from_preview(buffer_id);
2255 self.active_window_mut().mouse_state.dragging_tab = Some(
2256 super::types::TabDragState::new(buffer_id, split_id, (col, row)),
2257 );
2258 }
2259 crate::view::split::TabTarget::Group(group_leaf) => {
2260 self.activate_group_tab(split_id, group_leaf);
2261 }
2262 }
2263 Some(Ok(()))
2264 }
2265 TabHit::ScrollLeft => {
2266 self.set_status_message("ScrollLeft clicked!".to_string());
2267 if let Some(vs) = self
2268 .windows
2269 .get_mut(&self.active_window)
2270 .and_then(|w| w.split_view_states_mut())
2271 .expect("active window must have a populated split layout")
2272 .get_mut(&split_id)
2273 {
2274 vs.tab_scroll_offset = vs.tab_scroll_offset.saturating_sub(10);
2275 }
2276 Some(Ok(()))
2277 }
2278 TabHit::ScrollRight => {
2279 self.set_status_message("ScrollRight clicked!".to_string());
2280 if let Some(vs) = self
2281 .windows
2282 .get_mut(&self.active_window)
2283 .and_then(|w| w.split_view_states_mut())
2284 .expect("active window must have a populated split layout")
2285 .get_mut(&split_id)
2286 {
2287 vs.tab_scroll_offset = vs.tab_scroll_offset.saturating_add(10);
2288 }
2289 Some(Ok(()))
2290 }
2291 TabHit::BarBackground => None,
2292 }
2293 }
2294
2295 pub(super) fn handle_mouse_drag(&mut self, col: u16, row: u16) -> AnyhowResult<()> {
2297 if let Some(dragging_split_id) = self.active_window_mut().mouse_state.dragging_scrollbar {
2299 let split_areas = self.active_layout().split_areas.clone();
2302 for (split_id, buffer_id, _content_rect, scrollbar_rect, _thumb_start, _thumb_end) in
2303 &split_areas
2304 {
2305 if *split_id == dragging_split_id {
2306 if self.active_window().mouse_state.drag_start_row.is_some() {
2308 self.active_window_mut().handle_scrollbar_drag_relative(
2310 row,
2311 *split_id,
2312 *buffer_id,
2313 *scrollbar_rect,
2314 )?;
2315 } else {
2316 self.active_window_mut().handle_scrollbar_jump(
2318 col,
2319 row,
2320 *split_id,
2321 *buffer_id,
2322 *scrollbar_rect,
2323 )?;
2324 }
2325 return Ok(());
2326 }
2327 }
2328 }
2329
2330 if let Some(dragging_split_id) = self
2332 .active_window_mut()
2333 .mouse_state
2334 .dragging_horizontal_scrollbar
2335 {
2336 let hscrollbar_areas = self.active_layout().horizontal_scrollbar_areas.clone();
2341 for (
2342 split_id,
2343 _buffer_id,
2344 hscrollbar_rect,
2345 max_content_width,
2346 thumb_start,
2347 thumb_end,
2348 ) in &hscrollbar_areas
2349 {
2350 if *split_id == dragging_split_id {
2351 let track_width = hscrollbar_rect.width as f64;
2352 if track_width <= 1.0 {
2353 break;
2354 }
2355
2356 if let (Some(drag_start_hcol), Some(drag_start_left_column)) = (
2357 self.active_window_mut().mouse_state.drag_start_hcol,
2358 self.active_window_mut().mouse_state.drag_start_left_column,
2359 ) {
2360 let col_offset = (col as i32) - (drag_start_hcol as i32);
2363 if let Some(view_state) = self
2364 .windows
2365 .get_mut(&self.active_window)
2366 .and_then(|w| w.split_view_states_mut())
2367 .expect("active window must have a populated split layout")
2368 .get_mut(&dragging_split_id)
2369 {
2370 let visible_width = view_state.viewport.width as usize;
2371 let max_scroll = max_content_width.saturating_sub(visible_width);
2372 if max_scroll > 0 {
2373 let thumb_size = thumb_end.saturating_sub(*thumb_start).max(1);
2374 let track_travel = (track_width - thumb_size as f64).max(1.0);
2375 let scroll_per_pixel = max_scroll as f64 / track_travel;
2376 let scroll_offset =
2377 (col_offset as f64 * scroll_per_pixel).round() as i64;
2378 let new_left =
2379 (drag_start_left_column as i64 + scroll_offset).max(0) as usize;
2380 view_state.viewport.left_column = new_left.min(max_scroll);
2381 view_state.viewport.set_skip_ensure_visible();
2382 }
2383 }
2384 } else {
2385 let relative_col = col.saturating_sub(hscrollbar_rect.x) as f64;
2387 let ratio = (relative_col / (track_width - 1.0)).clamp(0.0, 1.0);
2388
2389 if let Some(view_state) = self
2390 .windows
2391 .get_mut(&self.active_window)
2392 .and_then(|w| w.split_view_states_mut())
2393 .expect("active window must have a populated split layout")
2394 .get_mut(&dragging_split_id)
2395 {
2396 let visible_width = view_state.viewport.width as usize;
2397 let max_scroll = max_content_width.saturating_sub(visible_width);
2398 let target_col = (ratio * max_scroll as f64).round() as usize;
2399 view_state.viewport.left_column = target_col.min(max_scroll);
2400 view_state.viewport.set_skip_ensure_visible();
2401 }
2402 }
2403
2404 return Ok(());
2405 }
2406 }
2407 }
2408
2409 if let Some(popup_idx) = self.active_window_mut().mouse_state.selecting_in_popup {
2411 if let Some((_, _, inner_rect, scroll_offset, _, _, _)) = self
2413 .active_chrome()
2414 .popup_areas
2415 .iter()
2416 .find(|(idx, _, _, _, _, _, _)| *idx == popup_idx)
2417 {
2418 if col >= inner_rect.x
2420 && col < inner_rect.x + inner_rect.width
2421 && row >= inner_rect.y
2422 && row < inner_rect.y + inner_rect.height
2423 {
2424 let relative_col = (col - inner_rect.x) as usize;
2425 let relative_row = (row - inner_rect.y) as usize;
2426 let line = scroll_offset + relative_row;
2427
2428 let state = self.active_state_mut();
2429 if let Some(popup) = state.popups.get_mut(popup_idx) {
2430 popup.extend_selection(line, relative_col);
2431 }
2432 }
2433 }
2434 return Ok(());
2435 }
2436
2437 if self
2442 .active_window_mut()
2443 .mouse_state
2444 .dragging_prompt_scrollbar
2445 {
2446 use crate::view::ui::scrollbar::ScrollbarState;
2447 let sb_rect = self.active_chrome().suggestions_scrollbar_rect;
2450 let suggestions_area_visible =
2451 self.active_chrome().suggestions_area.map(|(_, _, v, _)| v);
2452 let active_window_id = self.active_window;
2453 if let (Some(sb_rect), Some(prompt)) = (
2454 sb_rect,
2455 self.windows
2456 .get_mut(&active_window_id)
2457 .and_then(|w| w.prompt.as_mut()),
2458 ) {
2459 let visible = suggestions_area_visible.unwrap_or(prompt.suggestions.len().min(10));
2460 let total = prompt.suggestions.len();
2461 let track_height = sb_rect.height as usize;
2462 let clamped_row =
2466 row.clamp(sb_rect.y, sb_rect.y + sb_rect.height.saturating_sub(1));
2467 let click_row = clamped_row.saturating_sub(sb_rect.y) as usize;
2468 let state = ScrollbarState::new(total, visible, prompt.scroll_offset);
2469 prompt.scroll_offset = state.click_to_offset(track_height, click_row);
2470 }
2471 return Ok(());
2472 }
2473
2474 if let Some(popup_idx) = self
2476 .active_window_mut()
2477 .mouse_state
2478 .dragging_popup_scrollbar
2479 {
2480 if let Some((_, _, inner_rect, _, _, Some(sb_rect), total_lines)) = self
2482 .active_chrome()
2483 .popup_areas
2484 .iter()
2485 .find(|(idx, _, _, _, _, _, _)| *idx == popup_idx)
2486 {
2487 let track_height = sb_rect.height as usize;
2488 let visible_lines = inner_rect.height as usize;
2489
2490 if track_height > 0 && *total_lines > visible_lines {
2491 let relative_row = row.saturating_sub(sb_rect.y) as usize;
2492 let max_scroll = total_lines.saturating_sub(visible_lines);
2493 let target_scroll = if track_height > 1 {
2494 (relative_row * max_scroll) / (track_height.saturating_sub(1))
2495 } else {
2496 0
2497 };
2498
2499 let state = self.active_state_mut();
2500 if let Some(popup) = state.popups.get_mut(popup_idx) {
2501 let current_scroll = popup.scroll_offset as i32;
2502 let delta = target_scroll as i32 - current_scroll;
2503 popup.scroll_by(delta);
2504 }
2505 }
2506 }
2507 return Ok(());
2508 }
2509
2510 if let Some((split_id, direction)) = self.active_window_mut().mouse_state.dragging_separator
2512 {
2513 self.handle_separator_drag(col, row, split_id, direction)?;
2514 return Ok(());
2515 }
2516
2517 if self.active_window_mut().mouse_state.dragging_file_explorer {
2519 self.handle_file_explorer_border_drag(col)?;
2520 return Ok(());
2521 }
2522
2523 if self.active_window_mut().mouse_state.dragging_text_selection {
2525 self.handle_text_selection_drag(col, row)?;
2526 return Ok(());
2527 }
2528
2529 if self.active_window_mut().mouse_state.dragging_tab.is_some() {
2531 self.handle_tab_drag(col, row)?;
2532 return Ok(());
2533 }
2534
2535 Ok(())
2536 }
2537
2538 fn handle_text_selection_drag(&mut self, col: u16, row: u16) -> AnyhowResult<()> {
2540 use crate::model::event::Event;
2541 use crate::primitives::word_navigation::{find_word_end, find_word_start};
2542
2543 let Some(split_id) = self.active_window_mut().mouse_state.drag_selection_split else {
2544 return Ok(());
2545 };
2546 let Some(anchor_position) = self.active_window_mut().mouse_state.drag_selection_anchor
2547 else {
2548 return Ok(());
2549 };
2550
2551 let Some((buffer_id, content_rect)) = self
2553 .active_layout()
2554 .split_areas
2555 .iter()
2556 .find(|(sid, _, _, _, _, _)| *sid == split_id)
2557 .map(|(_, bid, rect, _, _, _)| (*bid, *rect))
2558 else {
2559 return Ok(());
2560 };
2561
2562 let cached_mappings = self
2564 .active_layout()
2565 .view_line_mappings
2566 .get(&split_id)
2567 .cloned();
2568
2569 let leaf_id = split_id;
2570
2571 let fallback = self
2573 .windows
2574 .get(&self.active_window)
2575 .and_then(|w| w.buffers.splits())
2576 .map(|(_, vs)| vs)
2577 .expect("active window must have a populated split layout")
2578 .get(&leaf_id)
2579 .map(|vs| vs.viewport.top_byte)
2580 .unwrap_or(0);
2581
2582 let compose_width = self
2584 .windows
2585 .get(&self.active_window)
2586 .and_then(|w| w.buffers.splits())
2587 .map(|(_, vs)| vs)
2588 .expect("active window must have a populated split layout")
2589 .get(&leaf_id)
2590 .and_then(|vs| vs.compose_width);
2591
2592 let drag_by_words = self.active_window_mut().mouse_state.drag_selection_by_words;
2596 let drag_word_end = self.active_window_mut().mouse_state.drag_selection_word_end;
2597
2598 let Some((target_position, new_position, anchor_position, new_sticky_column)) = self
2599 .active_window()
2600 .buffers
2601 .get(&buffer_id)
2602 .and_then(|state| {
2603 let gutter_width = state.margins.left_total_width() as u16;
2604 let target_position = super::click_geometry::screen_to_buffer_position(
2605 col,
2606 row,
2607 content_rect,
2608 gutter_width,
2609 &cached_mappings,
2610 fallback,
2611 true, compose_width,
2613 )?;
2614 let (new_position, anchor_pos) = if drag_by_words {
2615 if target_position >= anchor_position {
2616 (
2617 find_word_end(&state.buffer, target_position),
2618 anchor_position,
2619 )
2620 } else {
2621 let word_end = drag_word_end.unwrap_or(anchor_position);
2622 (find_word_start(&state.buffer, target_position), word_end)
2623 }
2624 } else {
2625 (target_position, anchor_position)
2626 };
2627 let new_sticky_column = state
2628 .buffer
2629 .offset_to_position(new_position)
2630 .map(|pos| pos.column);
2631 Some((target_position, new_position, anchor_pos, new_sticky_column))
2632 })
2633 else {
2634 return Ok(());
2635 };
2636 let _ = target_position;
2637
2638 let (primary_cursor_id, old_position, old_anchor, old_sticky_column) = self
2639 .active_window()
2640 .buffers
2641 .splits()
2642 .and_then(|(_, vs)| vs.get(&leaf_id))
2643 .map(|vs| {
2644 let cursor = vs.cursors.primary();
2645 (
2646 vs.cursors.primary_id(),
2647 cursor.position,
2648 cursor.anchor,
2649 cursor.sticky_column,
2650 )
2651 })
2652 .unwrap_or((CursorId(0), 0, None, 0));
2653
2654 let event = Event::MoveCursor {
2655 cursor_id: primary_cursor_id,
2656 old_position,
2657 new_position,
2658 old_anchor,
2659 new_anchor: Some(anchor_position),
2660 old_sticky_column,
2661 new_sticky_column: new_sticky_column.unwrap_or(old_sticky_column),
2662 };
2663
2664 if let Some(event_log) = self.active_window_mut().event_logs.get_mut(&buffer_id) {
2665 event_log.append(event.clone());
2666 }
2667 self.active_window_mut()
2668 .apply_event_to_buffer(buffer_id, leaf_id, &event);
2669
2670 Ok(())
2671 }
2672
2673 pub(super) fn handle_file_explorer_border_drag(&mut self, col: u16) -> AnyhowResult<()> {
2675 let Some((start_col, _start_row)) =
2676 self.active_window_mut().mouse_state.drag_start_position
2677 else {
2678 return Ok(());
2679 };
2680 let Some(start_width) = self
2681 .active_window_mut()
2682 .mouse_state
2683 .drag_start_explorer_width
2684 else {
2685 return Ok(());
2686 };
2687
2688 let delta = col as i32 - start_col as i32;
2689 let total_width = self.terminal_width as i32;
2690
2691 if total_width > 0 {
2695 use crate::config::ExplorerWidth;
2696 self.active_window_mut().file_explorer_width = match start_width {
2697 ExplorerWidth::Percent(start_pct) => {
2698 let percent_delta = (delta * 100) / total_width;
2699 let new_pct = (start_pct as i32 + percent_delta).clamp(0, 100) as u8;
2700 ExplorerWidth::Percent(new_pct)
2701 }
2702 ExplorerWidth::Columns(start_cols) => {
2703 let new_cols = (start_cols as i32 + delta).clamp(0, total_width) as u16;
2704 ExplorerWidth::Columns(new_cols)
2705 }
2706 };
2707 }
2708
2709 Ok(())
2710 }
2711
2712 pub(super) fn handle_separator_drag(
2714 &mut self,
2715 col: u16,
2716 row: u16,
2717 split_id: ContainerId,
2718 direction: SplitDirection,
2719 ) -> AnyhowResult<()> {
2720 let Some((start_col, start_row)) = self.active_window_mut().mouse_state.drag_start_position
2721 else {
2722 return Ok(());
2723 };
2724 let Some(start_ratio) = self.active_window_mut().mouse_state.drag_start_ratio else {
2725 return Ok(());
2726 };
2727 let Some(editor_area) = self.active_layout().editor_content_area else {
2728 return Ok(());
2729 };
2730
2731 let (delta, total_size) = match direction {
2733 SplitDirection::Horizontal => {
2734 let delta = row as i32 - start_row as i32;
2736 let total = editor_area.height as i32;
2737 (delta, total)
2738 }
2739 SplitDirection::Vertical => {
2740 let delta = col as i32 - start_col as i32;
2742 let total = editor_area.width as i32;
2743 (delta, total)
2744 }
2745 };
2746
2747 if total_size > 0 {
2750 let ratio_delta = delta as f32 / total_size as f32;
2751 let new_ratio = (start_ratio + ratio_delta).clamp(0.1, 0.9);
2752
2753 if self
2758 .windows
2759 .get(&self.active_window)
2760 .and_then(|w| w.buffers.splits())
2761 .map(|(mgr, _)| mgr)
2762 .expect("active window must have a populated split layout")
2763 .get_ratio(split_id.into())
2764 .is_some()
2765 {
2766 self.windows
2767 .get_mut(&self.active_window)
2768 .and_then(|w| w.split_manager_mut())
2769 .expect("active window must have a populated split layout")
2770 .set_ratio(split_id, new_ratio);
2771 } else {
2772 self.set_grouped_split_ratio(split_id, new_ratio);
2773 }
2774 }
2775
2776 Ok(())
2777 }
2778
2779 pub(super) fn handle_right_click(&mut self, col: u16, row: u16) -> AnyhowResult<()> {
2781 let frame_w = self.active_chrome().last_frame_width;
2782 let frame_h = self.active_chrome().last_frame_height;
2783 if let Some(ref menu) = self.active_window().file_explorer_context_menu {
2784 let (menu_x, menu_y) = menu.clamped_position(frame_w, frame_h);
2785 let menu_width = super::types::FILE_EXPLORER_CONTEXT_MENU_WIDTH;
2786 let menu_height = menu.height();
2787 if col >= menu_x
2788 && col < menu_x + menu_width
2789 && row >= menu_y
2790 && row < menu_y + menu_height
2791 {
2792 return Ok(());
2793 }
2794 }
2795
2796 if let Some(ref menu) = self.active_window_mut().tab_context_menu {
2798 let menu_x = menu.position.0;
2799 let menu_y = menu.position.1;
2800 let menu_width = 22u16; let menu_height = super::types::TabContextMenuItem::all().len() as u16 + 2; if col >= menu_x
2805 && col < menu_x + menu_width
2806 && row >= menu_y
2807 && row < menu_y + menu_height
2808 {
2809 return Ok(());
2811 }
2812 }
2813
2814 if let Some(explorer_area) = self.active_layout().file_explorer_area {
2815 if col >= explorer_area.x
2816 && col < explorer_area.x + explorer_area.width
2817 && row < explorer_area.y + explorer_area.height
2818 && row > explorer_area.y
2819 {
2821 let relative_row = row.saturating_sub(explorer_area.y + 1);
2822 let (is_multi, is_root_selected) =
2823 if let Some(explorer) = self.file_explorer_mut().as_mut() {
2824 let display_nodes = explorer.get_display_nodes();
2825 let scroll_offset = explorer.get_scroll_offset();
2826 let clicked_index = (relative_row as usize) + scroll_offset;
2827 let mut clicked_is_root = false;
2828 if clicked_index < display_nodes.len() {
2829 let (node_id, _) = display_nodes[clicked_index];
2830 explorer.set_selected(Some(node_id));
2831 clicked_is_root = node_id == explorer.tree().root_id();
2832 }
2833 (explorer.has_multi_selection(), clicked_is_root)
2834 } else {
2835 (false, false)
2836 };
2837 self.active_window_mut().key_context =
2838 crate::input::keybindings::KeyContext::FileExplorer;
2839 self.active_window_mut().tab_context_menu = None;
2840 self.active_window_mut().file_explorer_context_menu =
2841 Some(super::types::FileExplorerContextMenu::new(
2842 col,
2843 row + 1,
2844 is_multi,
2845 is_root_selected,
2846 ));
2847 return Ok(());
2848 }
2849 }
2850
2851 self.active_window_mut().file_explorer_context_menu = None;
2852
2853 let tab_hit = self
2855 .active_layout()
2856 .tab_layouts
2857 .iter()
2858 .find_map(
2859 |(split_id, tab_layout)| match tab_layout.hit_test(col, row) {
2860 Some(TabHit::TabName(target) | TabHit::CloseButton(target)) => {
2861 target.as_buffer().map(|bid| (*split_id, bid))
2864 }
2865 _ => None,
2866 },
2867 );
2868
2869 if let Some((split_id, buffer_id)) = tab_hit {
2870 self.active_window_mut().tab_context_menu =
2872 Some(TabContextMenu::new(buffer_id, split_id, col, row + 1));
2873 } else {
2874 self.active_window_mut().tab_context_menu = None;
2876 }
2877
2878 Ok(())
2879 }
2880
2881 pub(super) fn handle_tab_context_menu_click(
2883 &mut self,
2884 col: u16,
2885 row: u16,
2886 ) -> Option<AnyhowResult<()>> {
2887 let menu = self.active_window_mut().tab_context_menu.as_ref()?;
2888 let menu_x = menu.position.0;
2889 let menu_y = menu.position.1;
2890 let menu_width = 22u16;
2891 let items = super::types::TabContextMenuItem::all();
2892 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
2896 {
2897 self.active_window_mut().tab_context_menu = None;
2899 return Some(Ok(()));
2900 }
2901
2902 if row == menu_y || row == menu_y + menu_height - 1 {
2904 return Some(Ok(()));
2905 }
2906
2907 let item_idx = (row - menu_y - 1) as usize;
2909 if item_idx >= items.len() {
2910 return Some(Ok(()));
2911 }
2912
2913 let buffer_id = menu.buffer_id;
2915 let split_id = menu.split_id;
2916 let item = items[item_idx];
2917
2918 self.active_window_mut().tab_context_menu = None;
2920
2921 Some(self.execute_tab_context_menu_action(item, buffer_id, split_id))
2923 }
2924
2925 fn execute_tab_context_menu_action(
2927 &mut self,
2928 item: super::types::TabContextMenuItem,
2929 buffer_id: BufferId,
2930 leaf_id: LeafId,
2931 ) -> AnyhowResult<()> {
2932 use super::types::TabContextMenuItem;
2933 match item {
2934 TabContextMenuItem::Close => {
2935 self.close_tab_in_split(buffer_id, leaf_id);
2936 }
2937 TabContextMenuItem::CloseOthers => {
2938 self.close_other_tabs_in_split(buffer_id, leaf_id);
2939 }
2940 TabContextMenuItem::CloseToRight => {
2941 self.close_tabs_to_right_in_split(buffer_id, leaf_id);
2942 }
2943 TabContextMenuItem::CloseToLeft => {
2944 self.close_tabs_to_left_in_split(buffer_id, leaf_id);
2945 }
2946 TabContextMenuItem::CloseAll => {
2947 self.close_all_tabs_in_split(leaf_id);
2948 }
2949 TabContextMenuItem::CopyRelativePath => {
2950 self.copy_buffer_path(buffer_id, true);
2951 }
2952 TabContextMenuItem::CopyFullPath => {
2953 self.copy_buffer_path(buffer_id, false);
2954 }
2955 }
2956
2957 Ok(())
2958 }
2959
2960 pub(super) fn handle_file_explorer_context_menu_key(
2963 &mut self,
2964 code: crossterm::event::KeyCode,
2965 modifiers: crossterm::event::KeyModifiers,
2966 ) -> Option<AnyhowResult<()>> {
2967 use crossterm::event::KeyCode;
2968 use crossterm::event::KeyModifiers;
2969
2970 if modifiers != KeyModifiers::NONE {
2971 return None;
2972 }
2973
2974 match code {
2975 KeyCode::Up => {
2976 if let Some(ref mut menu) = self.active_window_mut().file_explorer_context_menu {
2977 menu.prev_item();
2978 }
2979 Some(Ok(()))
2980 }
2981 KeyCode::Down => {
2982 if let Some(ref mut menu) = self.active_window_mut().file_explorer_context_menu {
2983 menu.next_item();
2984 }
2985 Some(Ok(()))
2986 }
2987 KeyCode::Enter => {
2988 let item = {
2989 let menu = self
2990 .active_window_mut()
2991 .file_explorer_context_menu
2992 .as_ref()?;
2993 menu.items()[menu.highlighted]
2994 };
2995 self.active_window_mut().file_explorer_context_menu = None;
2996 self.execute_file_explorer_context_menu_action(item);
2997 Some(Ok(()))
2998 }
2999 KeyCode::Esc => {
3000 self.active_window_mut().file_explorer_context_menu = None;
3001 Some(Ok(()))
3002 }
3003 _ => None,
3004 }
3005 }
3006
3007 pub(super) fn handle_file_explorer_context_menu_click(
3009 &mut self,
3010 col: u16,
3011 row: u16,
3012 ) -> Option<AnyhowResult<()>> {
3013 let frame_w = self.active_chrome().last_frame_width;
3015 let frame_h = self.active_chrome().last_frame_height;
3016 let clicked_item: Option<super::types::FileExplorerContextMenuItem> = {
3017 let menu = self.active_window().file_explorer_context_menu.as_ref()?;
3018 let (menu_x, menu_y) = menu.clamped_position(frame_w, frame_h);
3019 let menu_width = super::types::FILE_EXPLORER_CONTEXT_MENU_WIDTH;
3020 let menu_height = menu.height();
3021
3022 if col < menu_x
3023 || col >= menu_x + menu_width
3024 || row < menu_y
3025 || row >= menu_y + menu_height
3026 {
3027 self.active_window_mut().file_explorer_context_menu = None;
3028 return Some(Ok(()));
3029 }
3030
3031 if row == menu_y || row == menu_y + menu_height - 1 {
3032 return Some(Ok(()));
3033 }
3034
3035 let item_idx = (row - menu_y - 1) as usize;
3036 menu.items().get(item_idx).copied()
3037 };
3038
3039 self.active_window_mut().file_explorer_context_menu = None;
3040 if let Some(item) = clicked_item {
3041 self.execute_file_explorer_context_menu_action(item);
3042 }
3043 Some(Ok(()))
3044 }
3045
3046 fn execute_file_explorer_context_menu_action(
3047 &mut self,
3048 item: super::types::FileExplorerContextMenuItem,
3049 ) {
3050 use super::types::FileExplorerContextMenuItem;
3051 match item {
3052 FileExplorerContextMenuItem::NewFile => self.file_explorer_new_file(),
3053 FileExplorerContextMenuItem::NewDirectory => self.file_explorer_new_directory(),
3054 FileExplorerContextMenuItem::Rename => self.file_explorer_rename(),
3055 FileExplorerContextMenuItem::Cut => self.active_window_mut().file_explorer_cut(),
3056 FileExplorerContextMenuItem::Copy => self.active_window_mut().file_explorer_copy(),
3057 FileExplorerContextMenuItem::Paste => self.file_explorer_paste(),
3058 FileExplorerContextMenuItem::Duplicate => self.file_explorer_duplicate(),
3059 FileExplorerContextMenuItem::Delete => self.file_explorer_delete(),
3060 FileExplorerContextMenuItem::CopyFullPath => self.file_explorer_copy_path(false),
3061 FileExplorerContextMenuItem::CopyRelativePath => self.file_explorer_copy_path(true),
3062 }
3063 }
3064
3065 fn show_file_explorer_status_tooltip(&mut self, path: std::path::PathBuf, col: u16, row: u16) {
3067 use crate::view::popup::{Popup, PopupPosition};
3068 use ratatui::style::Style;
3069
3070 let is_directory = path.is_dir();
3071
3072 let decoration = self
3074 .active_window()
3075 .file_explorer_decoration_cache
3076 .direct_for_path(&path)
3077 .cloned();
3078
3079 let bubbled_decoration = if is_directory && decoration.is_none() {
3081 self.active_window()
3082 .file_explorer_decoration_cache
3083 .bubbled_for_path(&path)
3084 .cloned()
3085 } else {
3086 None
3087 };
3088
3089 let has_unsaved_changes = if is_directory {
3091 self.windows
3093 .get(&self.active_window)
3094 .map(|w| &w.buffers)
3095 .expect("active window present")
3096 .iter()
3097 .any(|(buffer_id, state)| {
3098 if state.buffer.is_modified() {
3099 if let Some(metadata) = self.active_window().buffer_metadata.get(buffer_id)
3100 {
3101 if let Some(file_path) = metadata.file_path() {
3102 return file_path.starts_with(&path);
3103 }
3104 }
3105 }
3106 false
3107 })
3108 } else {
3109 self.windows
3110 .get(&self.active_window)
3111 .map(|w| &w.buffers)
3112 .expect("active window present")
3113 .iter()
3114 .any(|(buffer_id, state)| {
3115 if state.buffer.is_modified() {
3116 if let Some(metadata) = self.active_window().buffer_metadata.get(buffer_id)
3117 {
3118 return metadata.file_path() == Some(&path);
3119 }
3120 }
3121 false
3122 })
3123 };
3124
3125 let mut lines: Vec<String> = Vec::new();
3127
3128 if let Some(decoration) = &decoration {
3129 let symbol = &decoration.symbol;
3130 let explanation = match symbol.as_str() {
3131 "U" => "Untracked - File is not tracked by git",
3132 "M" => "Modified - File has unstaged changes",
3133 "A" => "Added - File is staged for commit",
3134 "D" => "Deleted - File is staged for deletion",
3135 "R" => "Renamed - File has been renamed",
3136 "C" => "Copied - File has been copied",
3137 "!" => "Conflicted - File has merge conflicts",
3138 "●" => "Has changes - Contains modified files",
3139 _ => "Unknown status",
3140 };
3141 lines.push(format!("{} - {}", symbol, explanation));
3142 } else if bubbled_decoration.is_some() {
3143 lines.push("● - Contains modified files".to_string());
3144 } else if has_unsaved_changes {
3145 if is_directory {
3146 lines.push("● - Contains unsaved changes".to_string());
3147 } else {
3148 lines.push("● - Unsaved changes in editor".to_string());
3149 }
3150 } else {
3151 return; }
3153
3154 if is_directory {
3156 if let Some(modified_files) = self.get_modified_files_in_directory(&path) {
3158 lines.push(String::new()); lines.push("Modified files:".to_string());
3160 let resolved_path = path.canonicalize().unwrap_or_else(|_| path.clone());
3162 const MAX_FILES: usize = 8;
3163 for (i, file) in modified_files.iter().take(MAX_FILES).enumerate() {
3164 let display_name = file
3166 .strip_prefix(&resolved_path)
3167 .unwrap_or(file)
3168 .to_string_lossy()
3169 .to_string();
3170 lines.push(format!(" {}", display_name));
3171 if i == MAX_FILES - 1 && modified_files.len() > MAX_FILES {
3172 lines.push(format!(
3173 " ... and {} more",
3174 modified_files.len() - MAX_FILES
3175 ));
3176 break;
3177 }
3178 }
3179 }
3180 } else {
3181 if let Some(stats) = self.get_git_diff_stats(&path) {
3183 lines.push(String::new()); lines.push(stats);
3185 }
3186 }
3187
3188 if lines.is_empty() {
3189 return;
3190 }
3191
3192 let mut popup = Popup::text(lines, &*self.theme.read().unwrap());
3194 popup.title = Some("Git Status".to_string());
3195 popup.transient = true;
3196 popup.position = PopupPosition::Fixed { x: col, y: row + 1 };
3197 popup.width = 50;
3198 popup.max_height = 15;
3199 popup.border_style = Style::default().fg(self.theme.read().unwrap().popup_border_fg);
3200 popup.background_style = Style::default().bg(self.theme.read().unwrap().popup_bg);
3201
3202 let __buffer_id = self.active_buffer();
3204 if let Some(state) = self
3205 .windows
3206 .get_mut(&self.active_window)
3207 .map(|w| &mut w.buffers)
3208 .expect("active window present")
3209 .get_mut(&__buffer_id)
3210 {
3211 state.popups.show(popup);
3212 }
3213 }
3214
3215 fn dismiss_file_explorer_status_tooltip(&mut self) {
3217 let __buffer_id = self.active_buffer();
3219 if let Some(state) = self
3220 .windows
3221 .get_mut(&self.active_window)
3222 .map(|w| &mut w.buffers)
3223 .expect("active window present")
3224 .get_mut(&__buffer_id)
3225 {
3226 state.popups.dismiss_transient();
3227 }
3228 }
3229
3230 fn get_git_diff_stats(&self, path: &std::path::Path) -> Option<String> {
3232 use crate::services::process_hidden::HideWindow;
3233 use std::process::Command;
3234
3235 let output = Command::new("git")
3237 .args(["diff", "--numstat", "--"])
3238 .arg(path)
3239 .current_dir(&self.working_dir)
3240 .hide_window()
3241 .output()
3242 .ok()?;
3243
3244 if !output.status.success() {
3245 return None;
3246 }
3247
3248 let stdout = String::from_utf8_lossy(&output.stdout);
3249 let line = stdout.lines().next()?;
3250 let parts: Vec<&str> = line.split('\t').collect();
3251
3252 if parts.len() >= 2 {
3253 let insertions = parts[0];
3254 let deletions = parts[1];
3255
3256 if insertions == "-" && deletions == "-" {
3258 return Some("Binary file changed".to_string());
3259 }
3260
3261 let ins: i32 = insertions.parse().unwrap_or(0);
3262 let del: i32 = deletions.parse().unwrap_or(0);
3263
3264 if ins > 0 || del > 0 {
3265 return Some(format!("+{} -{} lines", ins, del));
3266 }
3267 }
3268
3269 let staged_output = Command::new("git")
3271 .args(["diff", "--numstat", "--cached", "--"])
3272 .arg(path)
3273 .current_dir(&self.working_dir)
3274 .hide_window()
3275 .output()
3276 .ok()?;
3277
3278 if staged_output.status.success() {
3279 let staged_stdout = String::from_utf8_lossy(&staged_output.stdout);
3280 if let Some(line) = staged_stdout.lines().next() {
3281 let parts: Vec<&str> = line.split('\t').collect();
3282 if parts.len() >= 2 {
3283 let insertions = parts[0];
3284 let deletions = parts[1];
3285
3286 if insertions == "-" && deletions == "-" {
3287 return Some("Binary file staged".to_string());
3288 }
3289
3290 let ins: i32 = insertions.parse().unwrap_or(0);
3291 let del: i32 = deletions.parse().unwrap_or(0);
3292
3293 if ins > 0 || del > 0 {
3294 return Some(format!("+{} -{} lines (staged)", ins, del));
3295 }
3296 }
3297 }
3298 }
3299
3300 None
3301 }
3302
3303 fn get_modified_files_in_directory(
3305 &self,
3306 dir_path: &std::path::Path,
3307 ) -> Option<Vec<std::path::PathBuf>> {
3308 use crate::services::process_hidden::HideWindow;
3309 use std::process::Command;
3310
3311 let resolved_path = dir_path
3313 .canonicalize()
3314 .unwrap_or_else(|_| dir_path.to_path_buf());
3315
3316 let output = Command::new("git")
3318 .args(["status", "--porcelain", "--"])
3319 .arg(&resolved_path)
3320 .current_dir(&self.working_dir)
3321 .hide_window()
3322 .output()
3323 .ok()?;
3324
3325 if !output.status.success() {
3326 return None;
3327 }
3328
3329 let stdout = String::from_utf8_lossy(&output.stdout);
3330 let modified_files: Vec<std::path::PathBuf> = stdout
3331 .lines()
3332 .filter_map(|line| {
3333 if line.len() > 3 {
3336 let file_part = &line[3..];
3337 let file_name = if file_part.contains(" -> ") {
3339 file_part.split(" -> ").last().unwrap_or(file_part)
3340 } else {
3341 file_part
3342 };
3343 Some(self.working_dir.join(file_name))
3344 } else {
3345 None
3346 }
3347 })
3348 .collect();
3349
3350 if modified_files.is_empty() {
3351 None
3352 } else {
3353 Some(modified_files)
3354 }
3355 }
3356
3357 fn handle_floating_widget_click(&mut self, col: u16, row: u16) {
3363 let (panel_id, inner) = match self.floating_widget_panel.as_ref() {
3364 Some(fwp) => match fwp.last_inner_rect {
3365 Some(rect) => (fwp.panel_id, rect),
3366 None => return,
3367 },
3368 None => return,
3369 };
3370 if col < inner.x || col >= inner.x + inner.width {
3371 return;
3372 }
3373 if row < inner.y || row >= inner.y + inner.height {
3374 return;
3375 }
3376 let brow = (row - inner.y) as u32;
3377 let entries = self
3378 .floating_widget_panel
3379 .as_ref()
3380 .map(|f| f.entries.clone())
3381 .unwrap_or_default();
3382 let local_screen_col = (col - inner.x) as usize;
3383 let bcol = match entries.get(brow as usize) {
3384 Some(entry) => screen_col_to_byte(&entry.text, local_screen_col),
3385 None => return,
3386 };
3387 let (hit_payload, hit_event, hit_key, hit_kind) =
3388 match self
3389 .widget_registry
3390 .hit_test(super::FLOATING_PANEL_BUFFER_ID, brow, bcol as u32)
3391 {
3392 Some((_, hit)) => (
3393 hit.payload.clone(),
3394 hit.event_type.to_string(),
3395 hit.widget_key.clone(),
3396 hit.widget_kind,
3397 ),
3398 None => return,
3399 };
3400 if !hit_key.is_empty() {
3401 let tabbable = self
3402 .widget_registry
3403 .get(panel_id)
3404 .map(|p| p.tabbable.iter().any(|k| k == &hit_key))
3405 .unwrap_or(false);
3406 if tabbable {
3407 self.widget_registry
3408 .set_focus_key(panel_id, hit_key.clone());
3409 }
3410 self.rerender_widget_panel(panel_id);
3411 }
3412 let handled_specially = if hit_kind == "tree" && hit_event == "expand" {
3413 if let Some(item_key) = hit_payload.get("key").and_then(|v| v.as_str()) {
3414 self.handle_widget_tree_expand_toggle(panel_id, &hit_key, item_key);
3415 true
3416 } else {
3417 false
3418 }
3419 } else {
3420 false
3421 };
3422 if !handled_specially
3423 && self
3424 .plugin_manager
3425 .read()
3426 .unwrap()
3427 .has_hook_handlers("widget_event")
3428 {
3429 self.plugin_manager.read().unwrap().run_hook(
3430 "widget_event",
3431 crate::services::plugins::hooks::HookArgs::WidgetEvent {
3432 panel_id,
3433 widget_key: hit_key,
3434 event_type: hit_event,
3435 payload: hit_payload,
3436 },
3437 );
3438 }
3439 }
3440}
3441
3442fn screen_col_to_byte(text: &str, target_col: usize) -> usize {
3447 use unicode_width::UnicodeWidthChar;
3448 let mut byte = 0;
3449 let mut col = 0usize;
3450 for ch in text.chars() {
3451 let w = UnicodeWidthChar::width(ch).unwrap_or(0);
3452 if col + w > target_col {
3453 return byte;
3454 }
3455 col += w;
3456 byte += ch.len_utf8();
3457 }
3458 byte
3459}