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