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